Transaction

B072D629B01664…0F4B3D157744

Block 272,410 · index 1 · indexed

Summary

Hash
B072D629B0166421F87B3104B3BBA4A63144E1751DA5E75C1A4F0F4B3D157744
Block
272,410
Size
552183 bytes
Gas used
590,064,572 / 1,724,521,800
Fee
5173565ugnot
Status
success

Messages

#1AddPackagegno.land/p/moul/x/mmr/v011 arguments
Attached funds
10000000ugnot

Arguments · 11

  1. #1mmr
  2. #2README.md
  3. #3# mmr A **Merkle Mountain Range**: an append-only log whose root updates in O(log n) and whose inclusion proofs are O(log n) big. ## Why not a plain Merkle tree [`p/moul/x/merkle/v0`](../../merkle/v0) commits to a **fixed** list. Adding a leaf means rebuilding from every leaf, which on chain means re-reading the whole set and re-hashing it, on every append. An MMR never rebuilds: an append hashes at most log2(n) times and touches nothing else. That is the shape any on-chain log wants. A wiki committing to its revision history, a forge committing to its objects, an agent realm committing to the receipts it issued: all append, none rewrite. ## The headline property: this *is* the Tendermint tree The MMR root equals the Tendermint simple-tree root over the same leaves, **at every size**, not only at powers of two. Tendermint splits an `n`-leaf tree at the largest power of two below `n`. That left half is exactly the first mountain, and the right half recurses through the remaining set bits, which is exactly the peak bagging. The two constructions coincide. So this package is a drop-in **incremental builder for Tendermint roots**: append in O(log n) on chain, hand out a root any Tendermint verifier accepts. Proofs cross over in both directions, pinned by `TestMerkleProofVerifiesAgainstMMRRoot`. Use `merkle.New` when the leaf set is fixed and you want the simpler proof encoding. Use this when the log grows. ## Structure An MMR is a list of perfect binary trees ("mountains") of strictly decreasing height, one per set bit of the leaf count. Eleven leaves is `8 + 2 + 1`, so three mountains of height 3, 1 and 0. Appending pushes a height-0 mountain and merges equal-height neighbours, exactly like incrementing a binary counter. The root bags the peaks right to left: ``` root = InnerHash(p0, InnerHash(p1, InnerHash(p2, …))) ``` ## Usage ```go import "gno.land/p/moul/x/mmr/v0" log := mmr.New() i := log.Append([]byte("receipt-1")) // O(log n), returns the leaf index log.Append([]byte("receipt-2")) root := log.RootHex() // commit or emit this p, _ := log.Proof(i) // Index, Total, Path, Before, After // anywhere, later: if mmr.Verify(root, []byte("receipt-1"), p) { … } ``` ## Proofs are bound to position *and* to size A proof carries the leaf index and the leaf count the log had when it was issued. The verifier recomputes the entire peak structure from that count, which fixes the peak count, which mountain holds the leaf, the local index inside it, and therefore the exact expected length of every component. A proof cannot be replayed at another index, against another size, or padded. `TestRejects` covers thirteen such attempts. Because the root moves on every append, a proof is valid against the root at its own `Total` and no other. A realm that wants old proofs to keep verifying must keep the historical roots: one 32-byte hash per append, the price of an auditable log. ## Storage `Append` stores one node per leaf plus one per merge: **2n - popcount(n)** hashes for n leaves, under 64 bytes per leaf amortised, pinned by `TestNodeCount`. That is what on-chain proof generation costs. A realm that only ever needs to **verify** proofs, with the tree living off chain, should store the root alone and use [`p/moul/x/merkle/v0`](../../merkle/v0) instead. `PeakHashes()` returns the whole verifier-side state and stays logarithmic: 10 hashes at 1023 leaves, 1 at 1024. ## Related - Fixed-list trees and the scheme rationale: [`p/moul/x/merkle/v0`](../../merkle/v0) - Live demo: [`r/moul/x/provable/v0`](../../../../../r/moul/x/provable/v0) <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/x/mmr/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/mmr/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/mmr/v0" gno = "0.9"
  6. #6mmr.gno
  7. #7// Package mmr implements a Merkle Mountain Range: an append-only log with a // root that updates in O(log n) and inclusion proofs of O(log n) size. // // # Why not a plain Merkle tree // // gno.land/p/moul/x/merkle/v0 commits to a FIXED list. Adding a leaf means // rebuilding from every leaf, which on chain means re-reading the whole set // and re-hashing it, every time. An MMR never rebuilds: an append hashes at // most log2(n) times and touches nothing else. // // That is the shape any on-chain log wants. A wiki committing to its revision // history, a forge committing to its objects, an agent realm committing to the // receipts it issued: all append, none rewrite. // // # The structure // // An MMR is a list of perfect binary trees ("mountains") of strictly // decreasing height, one per set bit of the leaf count. Eleven leaves is // 8 + 2 + 1, so three mountains of height 3, 1 and 0. Appending a leaf pushes // a height-0 mountain and merges equal-height neighbours, exactly like // incrementing a binary counter. // // The root is the peaks "bagged" right to left: // // root = InnerHash(p0, InnerHash(p1, InnerHash(p2, …))) // // Hashing is gno.land/p/moul/x/merkle/v0's, which is Tendermint's: leaves are // tagged 0x00 and inner nodes 0x01. Leaf and inner preimages therefore cannot // collide, so the second-preimage forgery that works against untagged schemes // does not apply here. See that package for the worked attack. // // # This IS the Tendermint tree // // The root above equals the Tendermint simple-tree root over the same leaves, // at every size and not merely at powers of two. Tendermint splits an n-leaf // tree at the largest power of two below n: that left half is exactly the // first mountain, and the right half recurses through the remaining set bits, // which is exactly the bagging. The two constructions coincide. // // So this package is a drop-in INCREMENTAL BUILDER for Tendermint roots. // Append in O(log n) on chain and hand out a root that any Tendermint verifier // accepts; proofs cross over in both directions, which // TestMerkleProofVerifiesAgainstMMRRoot pins. Use merkle.New when the leaf set // is fixed and you want the simpler proof encoding, use this when the log // grows. // // # Proofs are bound to position and to size // // A Proof carries the leaf index and the leaf count the MMR had when it was // issued. The verifier recomputes the whole peak structure from that count, // which fixes the number of peaks, which mountain holds the leaf, the local // index inside it, and therefore the exact expected length of every component // of the proof. A proof cannot be replayed at another index, against another // size, or padded. // // Because the root changes on every append, a proof is valid against the root // at its own Total and no other. A realm that wants old proofs to keep // verifying must keep the historical roots; Roots grow by one 32-byte hash per // append, which is the price of an auditable log. // // # Storage // // Append stores one node per leaf plus one per merge, so 2n-popcount(n) hashes // for n leaves, under 64 bytes per leaf amortised. That is what on-chain proof // generation costs. A realm that only ever needs to VERIFY proofs, with the // tree living off chain, should store the root alone and use // gno.land/p/moul/x/merkle/v0's Verify instead. // // Live demo: gno.land/r/moul/x/provable/v0 package mmr import ( "encoding/hex" "errors" "gno.land/p/moul/x/merkle/v0" ) // HashSize is the length in bytes of every node hash. const HashSize = merkle.HashSize // MaxPeaks caps the peak count a Proof may claim. A 64-peak MMR would hold // more than 2^64 leaves. const MaxPeaks = 64 var ( ErrEmpty = errors.New("mmr: log is empty") ErrIndexRange = errors.New("mmr: leaf index out of range") ErrBadHex = errors.New("mmr: hash is not valid hex") ErrBadSize = errors.New("mmr: hash is not 32 bytes") ErrTooManyPeaks = errors.New("mmr: more peaks than MaxPeaks") ) // peak is one mountain: the position of its root in nodes, and its height. type peak struct { pos int height int } // MMR is an append-only Merkle mountain range. // // The zero value is an empty, ready-to-use log. type MMR struct { nodes [][]byte // every node, in postorder: [left subtree][right subtree][root] peaks []peak leaves int } // New returns an empty MMR. func New() *MMR { return &MMR{} } // Size returns the number of leaves appended so far. func (m *MMR) Size() int { return m.leaves } // Nodes returns the number of stored hashes, leaves and merges together. Use // it to reason about storage growth. func (m *MMR) Nodes() int { return len(m.nodes) } // Append adds a leaf and returns its index. O(log n) hashes, no rebuild. func (m *MMR) Append(leaf []byte) int { index := m.leaves m.nodes = append(m.nodes, merkle.LeafHash(leaf)) m.peaks = append(m.peaks, peak{pos: len(m.nodes) - 1, height: 0}) for len(m.peaks) >= 2 { right := m.peaks[len(m.peaks)-1] left := m.peaks[len(m.peaks)-2] if left.height != right.height { break } m.nodes = append(m.nodes, merkle.InnerHash(m.nodes[left.pos], m.nodes[right.pos])) m.peaks = m.peaks[:len(m.peaks)-2] m.peaks = append(m.peaks, peak{pos: len(m.nodes) - 1, height: left.height + 1}) } m.leaves++ return index } // Root returns the current root, nil while the log is empty. func (m *MMR) Root() []byte { if len(m.peaks) == 0 { return nil } return bag(m.peakHashes()) } // RootHex returns Root hex-encoded. func (m *MMR) RootHex() string { return hex.EncodeToString(m.Root()) } // PeakHashes returns the current peak hashes, left to right. This is the whole // state a verifier needs, and it is O(log n). func (m *MMR) PeakHashes() [][]byte { return m.peakHashes() } func (m *MMR) peakHashes() [][]byte { out := make([][]byte, len(m.peaks)) for i, p := range m.peaks { out[i] = m.nodes[p.pos] } return out } // bag folds peaks right to left: InnerHash(p0, InnerHash(p1, …)). func bag(peaks [][]byte) []byte { if len(peaks) == 0 { return nil } acc := peaks[len(peaks)-1] for i := len(peaks) - 2; i >= 0; i-- { acc = merkle.InnerHash(peaks[i], acc) } return acc } // Proof is an inclusion proof for one leaf of a log that held Total leaves. // // Path holds the sibling hashes inside the leaf's own mountain, leaf first. // Before and After hold the other peak hashes, in left-to-right order. type Proof struct { Index int Total int Path [][]byte Before [][]byte After [][]byte } // Proof returns the inclusion proof for the leaf at index, against the current // root. func (m *MMR) Proof(index int) (Proof, error) { if m.leaves == 0 { return Proof{}, ErrEmpty } if index < 0 || index >= m.leaves { return Proof{}, ErrIndexRange } j, local := locate(m.leaves, index) hashes := m.peakHashes() return Proof{ Index: index, Total: m.leaves, Path: m.pathIn(m.peaks[j], local), Before: hashes[:j], After: hashes[j+1:], }, nil } // pathIn walks down the perfect tree rooted at p, collecting the sibling at // each level, and returns them leaf first. // // The layout is postorder, so for a node at position pos and height h the // right child root sits at pos-1 and the left child root at pos-1-(2^h - 1), // a perfect subtree of height h-1 holding 2^h - 1 nodes. func (m *MMR) pathIn(p peak, local int) [][]byte { var out [][]byte pos, h := p.pos, p.height for h > 0 { rightRoot := pos - 1 leftRoot := pos - 1 - ((1 << uint(h)) - 1) half := 1 << uint(h-1) if local < half { out = append([][]byte{m.nodes[rightRoot]}, out...) pos = leftRoot } else { out = append([][]byte{m.nodes[leftRoot]}, out...) pos = rightRoot local -= half } h-- } return out }
  8. #8mmr_test.gno
  9. #9package mmr import ( "strconv" "testing" "gno.land/p/moul/x/merkle/v0" ) func leafAt(i int) []byte { return []byte("entry-" + strconv.Itoa(i)) } func build(n int) *MMR { m := New() for i := 0; i < n; i++ { if got := m.Append(leafAt(i)); got != i { panic("Append returned the wrong index") } } return m } // The property that matters: for every log size up to 40, every leaf ever // appended has a proof that verifies against the root at that size. func TestEveryProofVerifies(t *testing.T) { for n := 1; n <= 40; n++ { m := build(n) root := m.Root() for i := 0; i < n; i++ { p, err := m.Proof(i) if err != nil { t.Fatalf("n=%d i=%d: Proof: %v", n, i, err) } if !Verify(root, leafAt(i), p) { t.Errorf("n=%d i=%d: valid proof rejected", n, i) } } } } // A one-leaf MMR is a single height-0 mountain, so its root is just the // tagged leaf hash and the proof is empty. func TestSingleLeaf(t *testing.T) { m := build(1) if string(m.Root()) != string(merkle.LeafHash(leafAt(0))) { t.Error("one-leaf root is not the leaf hash") } p, err := m.Proof(0) if err != nil { t.Fatal(err) } if len(p.Path) != 0 || len(p.Before) != 0 || len(p.After) != 0 { t.Error("one-leaf proof should be empty") } } // The Tendermint simple tree and a right-bagged MMR are the SAME tree, at // every size and not only at powers of two. Tendermint splits an n-leaf tree // at the largest power of two below n: that left half is exactly the first // mountain, and the right half recurses into the remaining set bits, which is // exactly the bagging. So an MMR is a drop-in incremental builder for // Tendermint roots: append in O(log n) on chain, hand out a root any // Tendermint verifier accepts. func TestRootMatchesTendermintTreeAtEverySize(t *testing.T) { for n := 1; n <= 40; n++ { ls := make([][]byte, n) for i := range ls { ls[i] = leafAt(i) } if got, want := build(n).RootHex(), merkle.New(ls).RootHex(); got != want { t.Errorf("n=%d: MMR root %s, Tendermint tree root %s", n, got, want) } } } // The consequence of the equality above: a proof produced by the fixed-list // Tendermint tree verifies against the root an MMR maintained incrementally, // and both packages agree leaf by leaf. func TestMerkleProofVerifiesAgainstMMRRoot(t *testing.T) { for _, n := range []int{1, 2, 3, 5, 7, 11, 16, 23} { ls := make([][]byte, n) for i := range ls { ls[i] = leafAt(i) } m := build(n) tree := merkle.New(ls) for i := 0; i < n; i++ { tp, err := tree.Proof(i) if err != nil { t.Fatalf("n=%d i=%d: %v", n, i, err) } if !tp.Verify(m.Root(), leafAt(i)) { t.Errorf("n=%d i=%d: Tendermint proof rejected by the MMR root", n, i) } mp, err := m.Proof(i) if err != nil { t.Fatalf("n=%d i=%d: %v", n, i, err) } if !Verify(tree.Root(), leafAt(i), mp) { t.Errorf("n=%d i=%d: MMR proof rejected by the tree root", n, i) } } } } func TestPeakStructure(t *testing.T) { tests := []struct { leaves int want []int }{ {1, []int{0}}, {2, []int{1}}, {3, []int{1, 0}}, {4, []int{2}}, {7, []int{2, 1, 0}}, {11, []int{3, 1, 0}}, {16, []int{4}}, } for _, tc := range tests { if got := len(peakHeights(tc.leaves)); got != len(tc.want) { t.Errorf("n=%d: %d peaks, want %d", tc.leaves, got, len(tc.want)) continue } for i, h := range peakHeights(tc.leaves) { if h != tc.want[i] { t.Errorf("n=%d: peak %d height %d, want %d", tc.leaves, i, h, tc.want[i]) } } if got := len(build(tc.leaves).peaks); got != len(tc.want) { t.Errorf("n=%d: MMR built %d peaks, want %d", tc.leaves, got, len(tc.want)) } } } func TestRejects(t *testing.T) { m := build(11) root := m.Root() p3, _ := m.Proof(3) p7, _ := m.Proof(7) tests := []struct { name string root []byte leaf []byte proof Proof }{ {"forged leaf", root, []byte("never-appended"), p3}, {"another real leaf", root, leafAt(4), p3}, {"proof replayed at another index", root, leafAt(3), Proof{Index: 4, Total: p3.Total, Path: p3.Path, Before: p3.Before, After: p3.After}}, {"another leaf's proof", root, leafAt(3), p7}, {"wrong total", root, leafAt(3), Proof{Index: 3, Total: 10, Path: p3.Path, Before: p3.Before, After: p3.After}}, {"padded path", root, leafAt(3), Proof{Index: 3, Total: 11, Path: append(append([][]byte{}, p3.Path...), make([]byte, HashSize)), Before: p3.Before, After: p3.After}}, {"truncated path", root, leafAt(3), Proof{Index: 3, Total: 11, Path: p3.Path[:len(p3.Path)-1], Before: p3.Before, After: p3.After}}, {"peaks swapped", root, leafAt(3), Proof{Index: 3, Total: 11, Path: p3.Path, Before: p3.After, After: p3.Before}}, {"index beyond total", root, leafAt(3), Proof{Index: 11, Total: 11, Path: p3.Path, Before: p3.Before, After: p3.After}}, {"negative index", root, leafAt(3), Proof{Index: -1, Total: 11, Path: p3.Path, Before: p3.Before, After: p3.After}}, {"zero total", root, leafAt(3), Proof{Index: 0, Total: 0}}, {"short root", root[:31], leafAt(3), p3}, {"short hash in path", root, leafAt(3), Proof{Index: 3, Total: 11, Path: [][]byte{{1, 2, 3}, p3.Path[1]}, Before: p3.Before, After: p3.After}}, } for _, tc := range tests { if Verify(tc.root, tc.leaf, tc.proof) { t.Errorf("%s: accepted, must be rejected", tc.name) } } } // A proof is issued against one size. Appending moves the root, and the old // proof must stop verifying against the new one rather than quietly pass. func TestProofIsBoundToItsSize(t *testing.T) { m := build(5) old, _ := m.Proof(2) oldRoot := m.Root() if !Verify(oldRoot, leafAt(2), old) { t.Fatal("proof does not verify against its own root") } m.Append(leafAt(5)) if Verify(m.Root(), leafAt(2), old) { t.Error("a stale proof verified against the new root") } fresh, _ := m.Proof(2) if !Verify(m.Root(), leafAt(2), fresh) { t.Error("a reissued proof does not verify") } if !Verify(oldRoot, leafAt(2), old) { t.Error("the old proof stopped verifying against the old root") } } func TestEmptyAndRange(t *testing.T) { m := New() if m.Size() != 0 || m.Root() != nil || m.Nodes() != 0 { t.Error("a fresh MMR is not empty") } if _, err := m.Proof(0); err != ErrEmpty { t.Errorf("empty Proof: err = %v, want %v", err, ErrEmpty) } m.Append(leafAt(0)) for _, i := range []int{-1, 1, 99} { if _, err := m.Proof(i); err != ErrIndexRange { t.Errorf("index %d: err = %v, want %v", i, err, ErrIndexRange) } } } // Storage claim from the package doc: 2n - popcount(n) stored hashes. func TestNodeCount(t *testing.T) { for n := 1; n <= 40; n++ { want := 2*n - popcount(n) if got := build(n).Nodes(); got != want { t.Errorf("n=%d: %d nodes, want 2n-popcount(n) = %d", n, got, want) } } } func popcount(n int) int { c := 0 for n != 0 { c += n & 1 n >>= 1 } return c } func TestParseProofRoundTrip(t *testing.T) { m := build(11) for i := 0; i < 11; i++ { want, _ := m.Proof(i) path, before, after := want.Hex() got, err := ParseProof(i, 11, path, before, after) if err != nil { t.Fatalf("i=%d: ParseProof: %v", i, err) } if !Verify(m.Root(), leafAt(i), got) { t.Errorf("i=%d: reparsed proof does not verify", i) } } } func TestParseProofErrors(t *testing.T) { if _, err := ParseProof(0, 2, "zz", "", ""); err != ErrBadHex { t.Errorf("bad hex: err = %v", err) } if _, err := ParseProof(0, 2, "abcd", "", ""); err != ErrBadSize { t.Errorf("bad size: err = %v", err) } } // PeakHashes is the whole verifier-side state, and it must stay logarithmic. func TestPeakCountIsLogarithmic(t *testing.T) { m := build(1023) if got := len(m.PeakHashes()); got != 10 { t.Errorf("1023 leaves: %d peaks, want 10", got) } if got := len(build(1024).PeakHashes()); got != 1 { t.Errorf("1024 leaves: %d peaks, want 1", got) } }
  10. #10verify.gno
  11. #11package mmr import ( "encoding/hex" "strings" "gno.land/p/moul/x/merkle/v0" ) // peakHeights returns the mountain heights of a log of total leaves, left to // right. They are the set bits of total, high to low: 11 leaves is 8 + 2 + 1, // so heights 3, 1, 0. func peakHeights(total int) []int { var out []int for h := 62; h >= 0; h-- { if total&(1<<uint(h)) != 0 { out = append(out, h) } } return out } // locate returns which mountain holds the leaf at index, and the leaf's index // within that mountain. It returns -1 when index is out of range. func locate(total, index int) (int, int) { offset := 0 for j, h := range peakHeights(total) { size := 1 << uint(h) if index < offset+size { return j, index - offset } offset += size } return -1, 0 } // Verify reports whether leaf really sits at p.Index of a log that held // p.Total leaves and whose root is root. // // Every dimension of the proof is checked against p.Total rather than trusted: // the peak count, which mountain holds the leaf, the local index, the path // length and both peak-list lengths are all recomputed. A proof that does not // have exactly the shape p.Total implies is rejected before any hashing. // // Failures are false, never a panic, so a realm chooses whether a bad proof // aborts or branches. func Verify(root, leaf []byte, p Proof) bool { if len(root) != HashSize || p.Total <= 0 || p.Index < 0 || p.Index >= p.Total { return false } heights := peakHeights(p.Total) if len(heights) > MaxPeaks { return false } j, local := locate(p.Total, p.Index) if j < 0 { return false } if len(p.Path) != heights[j] || len(p.Before) != j || len(p.After) != len(heights)-1-j { return false } if !allHashes(p.Path) || !allHashes(p.Before) || !allHashes(p.After) { return false } node := merkle.LeafHash(leaf) for d, sib := range p.Path { if (local>>uint(d))&1 == 1 { node = merkle.InnerHash(sib, node) } else { node = merkle.InnerHash(node, sib) } } peaks := make([][]byte, 0, len(heights)) peaks = append(peaks, p.Before...) peaks = append(peaks, node) peaks = append(peaks, p.After...) return equal(bag(peaks), root) } func allHashes(hs [][]byte) bool { for _, h := range hs { if len(h) != HashSize { return false } } return true } func equal(a, b []byte) bool { if len(a) != len(b) || len(a) == 0 { return false } for i := range a { if a[i] != b[i] { return false } } return true } // Hex renders the three hash lists as comma-separated hex, in the order // path|before|after, which is what a realm call takes as arguments. func (p Proof) Hex() (path, before, after string) { return joinHex(p.Path), joinHex(p.Before), joinHex(p.After) } func joinHex(hs [][]byte) string { parts := make([]string, len(hs)) for i, h := range hs { parts[i] = hex.EncodeToString(h) } return strings.Join(parts, ",") } // ParseProof rebuilds a Proof from realm-call arguments. Each of path, before // and after is a comma-separated hex list, possibly empty. func ParseProof(index, total int, path, before, after string) (Proof, error) { ph, err := splitHex(path) if err != nil { return Proof{}, err } bh, err := splitHex(before) if err != nil { return Proof{}, err } ah, err := splitHex(after) if err != nil { return Proof{}, err } if len(bh)+len(ah)+1 > MaxPeaks { return Proof{}, ErrTooManyPeaks } return Proof{Index: index, Total: total, Path: ph, Before: bh, After: ah}, nil } func splitHex(s string) ([][]byte, error) { s = strings.TrimSpace(s) if s == "" { return nil, nil } var out [][]byte for _, raw := range strings.Split(s, ",") { raw = strings.TrimSpace(raw) if raw == "" { continue } b, err := hex.DecodeString(raw) if err != nil { return nil, ErrBadHex } if len(b) != HashSize { return nil, ErrBadSize } out = append(out, b) } return out, nil }
#2AddPackagegno.land/p/moul/x/plan9/memfs/v09 arguments
Attached funds
8000000ugnot

Arguments · 9

  1. #1memfs
  2. #2README.md
  3. #3# `gno.land/p/moul/x/plan9/memfs/v0` **A RAM file server**: Plan 9's `ramfs`, in a realm's heap. The reference implementation of [`ninep.File`](../../ninep/v0) and `ninep.Mutable`. ```go import memfs "gno.land/p/moul/x/plan9/memfs/v0" fs := memfs.New("g1...", runtime.ChainHeight()) fs.MkdirAll("/usr/glenda/bin", height) fs.WriteFile("/tmp/greeting", "hello\n", height) root := fs.Root() // a ninep.File, mountable into any namespace ``` Children live in an `avl.Tree`, so a directory listing is ordered by name and therefore identical on every validating node. A map would make `Render` a consensus bug. **`Mutable` is in-realm only.** A non-crossing method runs in the *caller's* frame, so a foreign realm calling `Create` or `Write` here would be mutating objects it does not own. The read half is safe from anywhere, which is exactly what makes a memfs tree mountable into somebody else's namespace: they get reads, and only its owner gets writes. **The clock is a parameter, not an import.** Every mutation takes the block height from the caller rather than reading chain state itself, so the same tree runs in a plain unit test. 9P behaviours reproduced rather than approximated: - A directory reports `Length` 0, as 9P does. - `Qid.Version` increments on every write, so a client holding a qid can tell "same file, changed" from "different file" without reading it. - Writing past the end extends the file with NUL bytes. - `DMAPPEND` pins every write to the end, whatever offset was asked for. - Removing a non-empty directory is refused. Design and analysis: [moul/gno-contracts#136](https://github.com/moul/gno-contracts/issues/136). --- **Not affiliated with Plan 9.** Plan 9 from Bell Labs is the work of the Computing Science Research Center at Bell Labs; the name and the marks are theirs, and the copyright is held by the [Plan 9 Foundation](https://p9f.org). This package borrows the vocabulary and none of the code: it is an independent homage, asking what that ecosystem's spirit looks like on a chain. Full attribution: [NOTICE](../../../../../NOTICE.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/x/plan9/memfs/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/plan9/memfs/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/plan9/memfs/v0" gno = "0.9"
  6. #6memfs.gno
  7. #7// Package memfs is a RAM file server: Plan 9's ramfs, in a realm's heap. // // It is the reference implementation of gno.land/p/moul/x/plan9/ninep's File // and Mutable, and the tree a namespace server hands out as a user's private // root. Children live in an avl.Tree, so a directory listing is ordered by // name and therefore identical on every validating node; a map would make // Render a consensus bug. // // Mutable is IN-REALM ONLY. A non-crossing method runs in the caller's frame, // so a foreign realm calling Create or Write here would be mutating objects it // does not own. Reads (the ninep.File half) are safe from anywhere, which is // what makes a memfs tree mountable into somebody else's namespace. // // The caller supplies the clock (a block height) on every mutation rather than // the tree reading chain state itself, so the same tree is exercisable in a // plain unit test. // // NOTICE. Plan 9 from Bell Labs is the work of the Computing Science Research // Center at Bell Labs; the name and the marks are theirs, and the copyright is // held by the Plan 9 Foundation (https://p9f.org). This package is not // affiliated with, endorsed by, or sponsored by them, and contains no Plan 9 // code: it borrows the vocabulary so that the design reads without a glossary, // and it is an homage, asking what that ecosystem's spirit looks like on a // chain. Full attribution: NOTICE.md at the root of moul/gno-contracts. package memfs import ( "strings" "gno.land/p/nt/avl/v0" ninep "gno.land/p/moul/x/plan9/ninep/v0" ) // FS is a mutable in-memory file tree. type FS struct { root *node next uint64 // qid path allocator, unique for this server's lifetime uid string } type node struct { fs *FS name string perm ninep.Perm qid ninep.Qid data string children *avl.Tree // name -> *node; nil for a plain file mtime int64 uid string gid string muid string } // New returns an empty file server owned by uid, with its root created at // block height now. func New(uid string, now int64) *FS { fs := &FS{uid: uid} fs.root = fs.newNode("/", ninep.DirPerm, now) return fs } // Root returns the server's root directory. func (fs *FS) Root() ninep.File { return fs.root } // RootMutable returns the root as a Mutable. Only call it from the realm that // owns this FS. func (fs *FS) RootMutable() ninep.Mutable { return fs.root } // Uid returns the owner this server stamps on new files. func (fs *FS) Uid() string { return fs.uid } func (fs *FS) newNode(name string, perm ninep.Perm, now int64) *node { fs.next++ qt := ninep.QTFILE var kids *avl.Tree if perm.IsDir() { qt = ninep.QTDIR kids = avl.NewTree() } return &node{ fs: fs, name: name, perm: perm, qid: ninep.Qid{Type: qt, Version: 0, Path: fs.next}, children: kids, mtime: now, uid: fs.uid, gid: fs.uid, muid: fs.uid, } } // Stat implements ninep.File. func (n *node) Stat() ninep.Stat { length := int64(len(n.data)) if n.perm.IsDir() { length = 0 // 9P reports zero for a directory } return ninep.Stat{ Qid: n.qid, Mode: n.perm, Mtime: n.mtime, Length: length, Name: n.name, Uid: n.uid, Gid: n.gid, Muid: n.muid, } } // Walk implements ninep.File. func (n *node) Walk(name string) (ninep.File, error) { if !n.perm.IsDir() { return nil, ninep.ErrNotDir } if !ninep.ValidName(name) { return nil, ninep.ErrBadName } v := n.children.Get(name) // avl.Get returns ONE value in gno if v == nil { return nil, ninep.ErrNotExist } return v.(*node), nil } // Read implements ninep.File. func (n *node) Read(off, count int64) (string, error) { if n.perm.IsDir() { return "", ninep.ErrIsDir } return ninep.Slice(n.data, off, count), nil } // ReadDir implements ninep.File. func (n *node) ReadDir() ([]ninep.Stat, error) { if !n.perm.IsDir() { return nil, ninep.ErrNotDir } out := []ninep.Stat{} n.children.Iterate("", "", func(_ string, v any) bool { out = append(out, v.(*node).Stat()) return false }) return out, nil } // Create implements ninep.Mutable. func (n *node) Create(name string, perm ninep.Perm, now int64) (ninep.File, error) { if !n.perm.IsDir() { return nil, ninep.ErrNotDir } if !ninep.ValidName(name) { return nil, ninep.ErrBadName } if n.children.Has(name) { return nil, ninep.ErrExist } child := n.fs.newNode(name, perm, now) n.children.Set(name, child) n.touch(now) return child, nil } // Remove implements ninep.Mutable. A non-empty directory is refused, as in // Plan 9. func (n *node) Remove(name string) error { if !n.perm.IsDir() { return ninep.ErrNotDir } v := n.children.Get(name) if v == nil { return ninep.ErrNotExist } child := v.(*node) if child.perm.IsDir() && child.children.Size() > 0 { return ninep.ErrNotEmpty } n.children.Remove(name) n.touch(n.mtime) return nil } // Write implements ninep.Mutable. Writing past the end extends the file with // NUL bytes, as a 9P server does. func (n *node) Write(off int64, data string, now int64) (int64, error) { if n.perm.IsDir() { return 0, ninep.ErrIsDir } if off < 0 { off = 0 } if n.perm&ninep.DMAPPEND != 0 { off = int64(len(n.data)) } cur := n.data if off > int64(len(cur)) { cur += strings.Repeat("\x00", int(off)-len(cur)) } end := off + int64(len(data)) tail := "" if end < int64(len(cur)) { tail = cur[end:] } n.data = cur[:off] + data + tail n.touch(now) return int64(len(data)), nil } // Truncate implements ninep.Mutable. func (n *node) Truncate(size int64, now int64) error { if n.perm.IsDir() { return ninep.ErrIsDir } if size < 0 { size = 0 } switch { case size < int64(len(n.data)): n.data = n.data[:size] case size > int64(len(n.data)): n.data += strings.Repeat("\x00", int(size)-len(n.data)) } n.touch(now) return nil } // touch bumps the qid version and the mtime. A client that kept a qid can tell // the file changed without reading it, which is the whole point of Qid.Version. func (n *node) touch(now int64) { n.qid.Version++ n.mtime = now n.muid = n.fs.uid } // MkdirAll creates p and every missing parent, and returns the leaf. An // existing directory is not an error; an existing plain file on the path is. func (fs *FS) MkdirAll(p string, now int64) (ninep.File, error) { cur := fs.root for _, e := range ninep.Elems(p) { v := cur.children.Get(e) if v == nil { f, err := cur.Create(e, ninep.DirPerm, now) if err != nil { return nil, err } cur = f.(*node) continue } cur = v.(*node) if !cur.perm.IsDir() { return nil, ninep.ErrNotDir } } return cur, nil } // WriteFile creates or replaces the file at p, creating parent directories as // needed. It is the seeding helper a realm wants at init. func (fs *FS) WriteFile(p, data string, now int64) error { dir, err := fs.MkdirAll(ninep.Dir(p), now) if err != nil { return err } name := ninep.Base(p) d := dir.(*node) v := d.children.Get(name) if v == nil { f, err := d.Create(name, ninep.FilePerm, now) if err != nil { return err } _, err = f.(*node).Write(0, data, now) return err } target := v.(*node) if err := target.Truncate(0, now); err != nil { return err } _, err = target.Write(0, data, now) return err }
  8. #8memfs_test.gno
  9. #9package memfs import ( "testing" ninep "gno.land/p/moul/x/plan9/ninep/v0" ) func TestRootStat(t *testing.T) { fs := New("glenda", 100) s := fs.Root().Stat() if s.Name != "/" { t.Errorf("root name: got %q", s.Name) } if !s.IsDir() { t.Error("root should be a directory") } if s.Length != 0 { t.Errorf("a directory reports length 0 in 9P, got %d", s.Length) } if s.Uid != "glenda" || s.Gid != "glenda" { t.Errorf("owner: got %q/%q", s.Uid, s.Gid) } if s.Mtime != 100 { t.Errorf("mtime: got %d, want 100", s.Mtime) } } func TestCreateWalkRead(t *testing.T) { fs := New("glenda", 1) root := fs.RootMutable() f, err := root.Create("greeting", ninep.FilePerm, 2) if err != nil { t.Fatalf("create: %v", err) } m := f.(ninep.Mutable) n, err := m.Write(0, "hello world", 3) if err != nil { t.Fatalf("write: %v", err) } if n != 11 { t.Errorf("wrote %d bytes, want 11", n) } got, err := root.Walk("greeting") if err != nil { t.Fatalf("walk: %v", err) } data, err := ninep.ReadAll(got) if err != nil { t.Fatalf("read: %v", err) } if data != "hello world" { t.Errorf("read back %q", data) } if l := got.Stat().Length; l != 11 { t.Errorf("length %d, want 11", l) } } func TestWalkErrors(t *testing.T) { fs := New("glenda", 1) root := fs.RootMutable() root.Create("file", ninep.FilePerm, 1) if _, err := root.Walk("absent"); err != ninep.ErrNotExist { t.Errorf("missing name: got %v, want ErrNotExist", err) } if _, err := root.Walk("a/b"); err != ninep.ErrBadName { t.Errorf("slash in name: got %v, want ErrBadName", err) } f, _ := root.Walk("file") if _, err := f.Walk("x"); err != ninep.ErrNotDir { t.Errorf("walk through a file: got %v, want ErrNotDir", err) } if _, err := f.ReadDir(); err != ninep.ErrNotDir { t.Errorf("readdir of a file: got %v, want ErrNotDir", err) } if _, err := root.Read(0, -1); err != ninep.ErrIsDir { t.Errorf("read of a directory: got %v, want ErrIsDir", err) } } func TestReadDirIsOrdered(t *testing.T) { fs := New("glenda", 1) root := fs.RootMutable() for _, name := range []string{"zulu", "alpha", "mike", "bravo"} { if _, err := root.Create(name, ninep.FilePerm, 1); err != nil { t.Fatalf("create %s: %v", name, err) } } ents, err := root.ReadDir() if err != nil { t.Fatalf("readdir: %v", err) } want := []string{"alpha", "bravo", "mike", "zulu"} if len(ents) != len(want) { t.Fatalf("got %d entries, want %d", len(ents), len(want)) } for i, w := range want { if ents[i].Name != w { t.Errorf("entry %d: got %q, want %q", i, ents[i].Name, w) } } } func TestCreateDuplicate(t *testing.T) { fs := New("glenda", 1) root := fs.RootMutable() root.Create("x", ninep.FilePerm, 1) if _, err := root.Create("x", ninep.FilePerm, 1); err != ninep.ErrExist { t.Errorf("got %v, want ErrExist", err) } if _, err := root.Create("", ninep.FilePerm, 1); err != ninep.ErrBadName { t.Errorf("empty name: got %v, want ErrBadName", err) } if _, err := root.Create("..", ninep.FilePerm, 1); err != ninep.ErrBadName { t.Errorf("dotdot: got %v, want ErrBadName", err) } } func TestRemove(t *testing.T) { fs := New("glenda", 1) root := fs.RootMutable() root.Create("file", ninep.FilePerm, 1) d, _ := root.Create("dir", ninep.DirPerm, 1) d.(ninep.Mutable).Create("inner", ninep.FilePerm, 1) if err := root.Remove("absent"); err != ninep.ErrNotExist { t.Errorf("remove missing: got %v", err) } if err := root.Remove("dir"); err != ninep.ErrNotEmpty { t.Errorf("remove non-empty dir: got %v, want ErrNotEmpty", err) } if err := d.(ninep.Mutable).Remove("inner"); err != nil { t.Fatalf("remove inner: %v", err) } if err := root.Remove("dir"); err != nil { t.Fatalf("remove emptied dir: %v", err) } if err := root.Remove("file"); err != nil { t.Fatalf("remove file: %v", err) } ents, _ := root.ReadDir() if len(ents) != 0 { t.Errorf("root should be empty, has %d entries", len(ents)) } } func TestWriteOffsets(t *testing.T) { tests := []struct { name string start string off int64 data string want string }{ {"append at end", "abc", 3, "def", "abcdef"}, {"overwrite middle", "abcdef", 2, "XY", "abXYef"}, {"overwrite past end", "abc", 2, "XYZ", "abXYZ"}, {"from zero", "abcdef", 0, "ZZ", "ZZcdef"}, {"gap is nul filled", "ab", 4, "Z", "ab\x00\x00Z"}, {"negative offset clamps", "abc", -2, "Z", "Zbc"}, } for _, tt := range tests { fs := New("glenda", 1) root := fs.RootMutable() f, _ := root.Create("f", ninep.FilePerm, 1) m := f.(ninep.Mutable) m.Write(0, tt.start, 1) m.Write(tt.off, tt.data, 2) got, _ := ninep.ReadAll(f) if got != tt.want { t.Errorf("%s: got %q, want %q", tt.name, got, tt.want) } } } func TestAppendOnlyIgnoresOffset(t *testing.T) { fs := New("glenda", 1) root := fs.RootMutable() f, _ := root.Create("log", ninep.DMAPPEND|0644, 1) m := f.(ninep.Mutable) m.Write(0, "one\n", 1) m.Write(0, "two\n", 2) // offset 0, but append-only pins it to the end got, _ := ninep.ReadAll(f) if got != "one\ntwo\n" { t.Errorf("got %q", got) } } func TestTruncate(t *testing.T) { fs := New("glenda", 1) root := fs.RootMutable() f, _ := root.Create("f", ninep.FilePerm, 1) m := f.(ninep.Mutable) m.Write(0, "abcdef", 1) m.Truncate(3, 2) if got, _ := ninep.ReadAll(f); got != "abc" { t.Errorf("shrink: got %q", got) } m.Truncate(5, 3) if got, _ := ninep.ReadAll(f); got != "abc\x00\x00" { t.Errorf("grow: got %q", got) } m.Truncate(0, 4) if got, _ := ninep.ReadAll(f); got != "" { t.Errorf("zero: got %q", got) } } func TestQidVersionAndPath(t *testing.T) { fs := New("glenda", 1) root := fs.RootMutable() a, _ := root.Create("a", ninep.FilePerm, 1) b, _ := root.Create("b", ninep.FilePerm, 1) if a.Stat().Qid.Path == b.Stat().Qid.Path { t.Error("two files share a qid path") } v0 := a.Stat().Qid.Version a.(ninep.Mutable).Write(0, "x", 2) if a.Stat().Qid.Version != v0+1 { t.Errorf("version did not advance on write: %d -> %d", v0, a.Stat().Qid.Version) } if a.Stat().Mtime != 2 { t.Errorf("mtime: got %d, want 2", a.Stat().Mtime) } } func TestMkdirAllAndWriteFile(t *testing.T) { fs := New("glenda", 1) if _, err := fs.MkdirAll("/usr/glenda/lib", 1); err != nil { t.Fatalf("mkdirall: %v", err) } // idempotent if _, err := fs.MkdirAll("/usr/glenda", 1); err != nil { t.Fatalf("mkdirall again: %v", err) } if err := fs.WriteFile("/usr/glenda/lib/profile", "font=pelm\n", 2); err != nil { t.Fatalf("writefile: %v", err) } f, err := ninep.Walk(fs.Root(), ninep.Elems("/usr/glenda/lib/profile")) if err != nil { t.Fatalf("walk: %v", err) } if got, _ := ninep.ReadAll(f); got != "font=pelm\n" { t.Errorf("got %q", got) } // rewriting replaces rather than appends if err := fs.WriteFile("/usr/glenda/lib/profile", "font=lucida\n", 3); err != nil { t.Fatalf("rewrite: %v", err) } f, _ = ninep.Walk(fs.Root(), ninep.Elems("/usr/glenda/lib/profile")) if got, _ := ninep.ReadAll(f); got != "font=lucida\n" { t.Errorf("rewrite got %q", got) } } func TestMkdirAllThroughAFileFails(t *testing.T) { fs := New("glenda", 1) fs.WriteFile("/etc", "not a directory", 1) if _, err := fs.MkdirAll("/etc/passwd", 2); err != ninep.ErrNotDir { t.Errorf("got %v, want ErrNotDir", err) } } func TestWalkDepthLimit(t *testing.T) { fs := New("glenda", 1) elems := []string{} for i := 0; i <= ninep.MaxDepth; i++ { elems = append(elems, "x") } if _, err := ninep.Walk(fs.Root(), elems); err != ninep.ErrTooDeep { t.Errorf("got %v, want ErrTooDeep", err) } }
#3AddPackagegno.land/p/moul/x/plan9/ns/v09 arguments
Attached funds
12000000ugnot

Arguments · 9

  1. #1ns
  2. #2README.md
  3. #3# `gno.land/p/moul/x/plan9/ns/v0` **A Plan 9 namespace**: a private, mutable mount table over [`ninep`](../../ninep/v0) file trees, with `bind(2)`'s flags and union directories. ```go import ns "gno.land/p/moul/x/plan9/ns/v0" n := ns.New(fs.Root()) n.Bind("/usr/glenda/bin", "/bin", ns.MAFTER|ns.MCREATE) n.ReadDir("/bin", false) // the concatenation of both directories ``` This is the idea the rest of the suite exists for. Plan 9's leverage does not come from "everything is a file", it comes from every process owning its own name-to-resource mapping, so a name can be *replaced* and any service can be composed, shadowed, sandboxed or mocked without the program knowing. Gno has one global tree of realm paths that looks the same to everybody; an `Ns` is a view of it that belongs to you. **Flags follow [`bind(2)`](http://man.cat-v.org/plan_9/2/bind):** `MREPL` replaces, `MBEFORE` splices the new directory in front of the old one, `MAFTER` behind it, and `MCREATE` (OR'd onto any of them) marks the union member that new files are created in. Four Plan 9 properties are load-bearing and reproduced deliberately: - **A union is top level only.** Binding `/a` onto `/b` unions the two directories at `/b`; `/b/c` resolves in whichever member won the walk and is not itself a union unless something is bound there too. - **A bind captures a channel, not a name.** The source is resolved once, at bind time, and the file it named is what gets stored. Rebinding the source afterwards does not retroactively move the target. - **A listing is a concatenation**, duplicates and all, so shadowing stays visible. Pass `unique` to collapse it first-wins instead. - **Creating in a union needs `MCREATE` on some member**, and is refused otherwise. A plain, unbound directory takes creates with no flag at all. - **The target of a bind must already exist**, exactly as `bind(1)` requires. `MaxUnion` (8), `MaxOps` (64) and `ninep.MaxDepth` (32) bound resolution, because each element of each member can cost a cross-realm call. `String()` prints the namespace in `ns(1)` format: one line per binding, in application order, then the working directory. **Live demo:** [`r/moul/x/plan9/ns`](../../../../../r/moul/x/plan9/ns/v0) gives every account one of these. Design and analysis: [moul/gno-contracts#136](https://github.com/moul/gno-contracts/issues/136). --- **Not affiliated with Plan 9.** Plan 9 from Bell Labs is the work of the Computing Science Research Center at Bell Labs; the name and the marks are theirs, and the copyright is held by the [Plan 9 Foundation](https://p9f.org). This package borrows the vocabulary and none of the code: it is an independent homage, asking what that ecosystem's spirit looks like on a chain. Full attribution: [NOTICE](../../../../../NOTICE.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/x/plan9/ns/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/plan9/ns/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/plan9/ns/v0" gno = "0.9"
  6. #6ns.gno
  7. #7// Package ns is a Plan 9 namespace: a private, mutable mount table over // gno.land/p/moul/x/plan9/ninep file trees. // // This is the idea the rest of the suite exists for. Plan 9's leverage does // not come from "everything is a file", it comes from every process owning its // own name-to-resource mapping, so a name can be REPLACED and any service can // be composed, shadowed, sandboxed or mocked without the program knowing. Gno // has one global tree and no way to hold a view of it; an Ns is that view. // // Bind flags follow bind(2): MREPL replaces, MBEFORE splices the new directory // in front of the old one, MAFTER behind it, and MCREATE (OR'd onto any of // them) marks the union member that new files are created in. // // Two Plan 9 properties are load-bearing and reproduced deliberately: // // - A union is TOP LEVEL ONLY. Binding /a onto /b unions the two directories // at /b; /b/c resolves in whichever member won the walk, and is not itself // a union unless something is bound there too. // - A bind captures a CHANNEL, not a name. The source is resolved once, at // bind time, and the resulting file is what is stored. Rebinding the // source afterwards does not retroactively move the target. // // ".." is removed lexically before resolution starts and never reaches a // server, per "Lexical File Names in Plan 9, or Getting Dot-Dot Right". // // NOTICE. Plan 9 from Bell Labs is the work of the Computing Science Research // Center at Bell Labs; the name and the marks are theirs, and the copyright is // held by the Plan 9 Foundation (https://p9f.org). This package is not // affiliated with, endorsed by, or sponsored by them, and contains no Plan 9 // code: it borrows the vocabulary so that the design reads without a glossary, // and it is an homage, asking what that ecosystem's spirit looks like on a // chain. Full attribution: NOTICE.md at the root of moul/gno-contracts. package ns import ( "errors" "strings" "gno.land/p/nt/avl/v0" ninep "gno.land/p/moul/x/plan9/ninep/v0" ) // Flag is a bind(2) mount flag. The low two bits pick the mode; MCREATE is // OR'd onto it. type Flag uint32 // Mount flags, with Plan 9's values. const ( MREPL Flag = 0x0000 // replace the old file with the new one MBEFORE Flag = 0x0001 // union, new directory first MAFTER Flag = 0x0002 // union, new directory last MCREATE Flag = 0x0004 // creates in this union go here ) func (f Flag) mode() Flag { return f & 0x0003 } func (f Flag) creates() bool { return f&MCREATE != 0 } // String renders the flag as the command-line letters bind(1) uses. func (f Flag) String() string { s := "" switch f.mode() { case MBEFORE: s = "-b" case MAFTER: s = "-a" } if f.creates() { if s == "" { s = "-c" } else { s += "c" } } return s } // Limits. Resolution costs one call per element per union member, and those // calls may cross a realm boundary, so both are capped rather than trusted. const ( MaxUnion = 8 // members in one union MaxOps = 64 ) var ( // ErrNoRoot means the namespace has nothing bound at "/". ErrNoRoot = errors.New("no root in namespace") // ErrWideUnion means a bind would exceed MaxUnion members. ErrWideUnion = errors.New("union too wide") // ErrTooManyOps means the namespace has reached MaxOps bindings. ErrTooManyOps = errors.New("too many namespace operations") // ErrNotBound means unmount was asked to remove something that is not // bound at that name. ErrNotBound = errors.New("not bound") ) // member is one element of a union. A nil file is the placeholder standing for // "whatever walking to this name would have found", which is how MBEFORE and // MAFTER keep the original directory in the union. type member struct { file ninep.File create bool } type entry struct { members []member } // Op records one bind or mount, in application order, so that String can print // the namespace the way Plan 9's ns(1) does. type Op struct { Verb string // "bind" or "mount" Flag Flag Source string Target string } // Ns is one namespace: a mount table, a working directory, and the ordered // list of operations that produced them. type Ns struct { table *avl.Tree // cleaned target path -> *entry ops []Op cwd string } // New returns a namespace whose root is the given file. The root is the one // binding that cannot be expressed as a bind of something else, so it is // installed directly. func New(root ninep.File) *Ns { n := &Ns{table: avl.NewTree(), cwd: "/"} n.table.Set("/", &entry{members: []member{{file: root, create: true}}}) return n } // Cwd returns the working directory. func (n *Ns) Cwd() string { return n.cwd } // Cd sets the working directory, which must resolve to a directory. func (n *Ns) Cd(p string) error { abs := n.Abs(p) st, err := n.Stat(abs) if err != nil { return err } if !st.IsDir() { return ninep.ErrNotDir } n.cwd = abs return nil } // Abs resolves p against the working directory. func (n *Ns) Abs(p string) string { return ninep.Abs(n.cwd, p) } func (n *Ns) entry(p string) *entry { v := n.table.Get(p) if v == nil { return nil } return v.(*entry) } // expand substitutes the walk result for the placeholder member. func expand(ms []member, under []member) []member { out := []member{} for _, m := range ms { if m.file == nil { out = append(out, under...) continue } out = append(out, m) } return out } // resolve walks p and returns the union found there, richest form: members // still carry their create bit, and bound reports whether a mount entry // applied at the final element. func (n *Ns) resolve(p string) ([]member, error) { root := n.entry("/") if root == nil { return nil, ErrNoRoot } cur := expand(root.members, nil) if len(cur) == 0 { return nil, ErrNoRoot } elems := ninep.Elems(ninep.Clean(p)) if len(elems) > ninep.MaxDepth { return nil, ninep.ErrTooDeep } prefix := "" for _, e := range elems { // First match wins. Walking INTO a union does not produce a union // of the members' subdirectories: in Plan 9 a union is top level // only, and a walk returns one channel. walked := []member{} for _, m := range cur { f, err := m.file.Walk(e) if err == nil { walked = append(walked, member{file: f}) break } } prefix += "/" + e next := walked if ent := n.entry(prefix); ent != nil { next = expand(ent.members, walked) } if len(next) == 0 { return nil, ninep.ErrNotExist } cur = next } return cur, nil } // Resolve returns every union member visible at p, in search order. func (n *Ns) Resolve(p string) ([]ninep.File, error) { ms, err := n.resolve(n.Abs(p)) if err != nil { return nil, err } out := []ninep.File{} for _, m := range ms { out = append(out, m.file) } return out, nil } // Open returns the file a name resolves to: the first member of its union, // which is the one a walk through this name would reach. func (n *Ns) Open(p string) (ninep.File, error) { fs, err := n.Resolve(p) if err != nil { return nil, err } return fs[0], nil } // Stat returns the entry for p, with the name replaced by the last element of // the path as asked for, so that a union member's own name never leaks. func (n *Ns) Stat(p string) (ninep.Stat, error) { f, err := n.Open(p) if err != nil { return ninep.Stat{}, err } st := f.Stat() st.Name = ninep.Base(n.Abs(p)) return st, nil } // ReadFile reads the whole file at p. func (n *Ns) ReadFile(p string) (string, error) { f, err := n.Open(p) if err != nil { return "", err } return ninep.ReadAll(f) } // ReadDir lists p. A union directory is the CONCATENATION of its members' // contents, as in Plan 9, so duplicate names can appear and shadowing is // visible. Pass unique to collapse them first-wins instead, which is the set // of names a walk can actually reach. func (n *Ns) ReadDir(p string, unique bool) ([]ninep.Stat, error) { ms, err := n.resolve(n.Abs(p)) if err != nil { return nil, err } if len(ms) == 1 && !ms[0].file.Stat().IsDir() { return nil, ninep.ErrNotDir } out := []ninep.Stat{} seen := map[string]bool{} for _, m := range ms { ents, err := m.file.ReadDir() if err != nil { continue // a non-directory member contributes nothing } for _, e := range ents { if unique { if seen[e.Name] { continue } seen[e.Name] = true } out = append(out, e) } } return out, nil } // CreateTarget returns the directory that a create at p should happen in. // // For a plain directory that is just the directory. For a union it is the // first member carrying MCREATE, and if no member has it, creation is refused, // which is bind(2)'s rule. func (n *Ns) CreateTarget(p string) (ninep.Mutable, error) { abs := n.Abs(p) ms, err := n.resolve(abs) if err != nil { return nil, err } isUnion := n.entry(abs) != nil && len(ms) > 1 for _, m := range ms { if isUnion && !m.create { continue } mu, ok := m.file.(ninep.Mutable) if !ok { continue // a read-only server, for instance a mounted realm } if !mu.Stat().IsDir() { continue } return mu, nil } if isUnion { return nil, ninep.ErrNoCreate } return nil, ninep.ErrPerm } // Bind makes source visible at target, as bind(1) does. Both names are // resolved in THIS namespace, and the source is resolved once: what is stored // is the file it names today, not the name. func (n *Ns) Bind(source, target string, flag Flag) error { return n.BindVerb("bind", source, target, flag) } // BindVerb is Bind with the verb that String should print. Plan 9 spells the // same operation "bind" or "mount" depending on whether the source is a name // or a channel, and ns(1) echoes back whichever was used. func (n *Ns) BindVerb(verb, source, target string, flag Flag) error { ms, err := n.resolve(n.Abs(source)) if err != nil { return err } files := []ninep.File{} for _, m := range ms { files = append(files, m.file) } return n.graft(verb, source, target, flag, files) } // Mount grafts a file tree that has no name in this namespace yet, which is // how a service posted by another realm gets in. source is a label, used only // when printing the namespace. func (n *Ns) Mount(f ninep.File, source, target string, flag Flag) error { return n.graft("mount", source, target, flag, []ninep.File{f}) } func (n *Ns) graft(verb, source, target string, flag Flag, files []ninep.File) error { if len(n.ops) >= MaxOps { return ErrTooManyOps } abs := n.Abs(target) // bind(1) requires the target to exist: you can only rebind a name that // already resolves to something. if _, err := n.resolve(abs); err != nil { return err } add := []member{} for _, f := range files { add = append(add, member{file: f, create: flag.creates()}) } ent := n.entry(abs) var members []member switch { case ent == nil: // The name has never been bound: its current contents are the // placeholder. It does NOT carry MCREATE, which is why creating // in a freshly unioned directory is refused until some member is // bound with -c. members = []member{{file: nil}} default: members = ent.members } switch flag.mode() { case MBEFORE: members = append(add, members...) case MAFTER: members = append(members, add...) default: // MREPL: the old file is gone, not unioned members = add } if len(members) > MaxUnion { return ErrWideUnion } n.table.Set(abs, &entry{members: members}) n.ops = append(n.ops, Op{Verb: verb, Flag: flag, Source: source, Target: abs}) return nil } // Unmount undoes bindings at target. With an empty source it removes every // binding there, restoring the name to whatever it resolved to originally. func (n *Ns) Unmount(source, target string) error { abs := n.Abs(target) ent := n.entry(abs) if ent == nil { return ErrNotBound } if source == "" { n.table.Remove(abs) n.dropOps(abs, "") return nil } ms, err := n.resolve(n.Abs(source)) if err != nil { return err } kept := []member{} removed := false for _, m := range ent.members { drop := false for _, s := range ms { if m.file != nil && m.file == s.file { drop = true break } } if drop { removed = true continue } kept = append(kept, m) } if !removed { return ErrNotBound } if onlyPlaceholders(kept) { // Nothing but "whatever was here originally" is left, so the entry // is a no-op: drop it rather than leave a phantom binding behind. n.table.Remove(abs) } else { n.table.Set(abs, &entry{members: kept}) } n.dropOps(abs, source) return nil } func onlyPlaceholders(ms []member) bool { for _, m := range ms { if m.file != nil { return false } } return true } func (n *Ns) dropOps(target, source string) { kept := []Op{} for _, op := range n.ops { if op.Target == target && (source == "" || op.Source == source) { continue } kept = append(kept, op) } n.ops = kept } // Ops returns the bindings in application order. func (n *Ns) Ops() []Op { return n.ops } // String prints the namespace the way Plan 9's ns(1) does: one line per // binding, in the order they were applied, then the working directory. func (n *Ns) String() string { var b strings.Builder for _, op := range n.ops { b.WriteString(op.Verb) if f := op.Flag.String(); f != "" { b.WriteString(" " + f) } b.WriteString(" " + quote(op.Source) + " " + quote(op.Target) + "\n") } b.WriteString("cd " + quote(n.cwd) + "\n") return b.String() } // quote applies rc quoting: single quotes when the word contains a space or a // quote, with an embedded quote doubled. func quote(s string) string { if s != "" && !strings.ContainsAny(s, " \t'") { return s } return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
  8. #8ns_test.gno
  9. #9package ns import ( "testing" memfs "gno.land/p/moul/x/plan9/memfs/v0" ninep "gno.land/p/moul/x/plan9/ninep/v0" ) // newNs builds a namespace over a fresh ram tree seeded with files. func newNs(t *testing.T, files ...string) (*Ns, *memfs.FS) { t.Helper() fs := memfs.New("glenda", 1) for _, f := range files { if err := fs.WriteFile(f, "contents of "+f+"\n", 1); err != nil { t.Fatalf("seed %s: %v", f, err) } } return New(fs.Root()), fs } func names(ents []ninep.Stat) []string { out := []string{} for _, e := range ents { out = append(out, e.Name) } return out } func eq(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func TestResolveRoot(t *testing.T) { n, _ := newNs(t, "/bin/ls", "/bin/cat") st, err := n.Stat("/") if err != nil { t.Fatalf("stat root: %v", err) } if !st.IsDir() { t.Error("root is not a directory") } ents, err := n.ReadDir("/", false) if err != nil { t.Fatalf("readdir: %v", err) } if !eq(names(ents), []string{"bin"}) { t.Errorf("root listing: %v", names(ents)) } } func TestReadFileAndMissing(t *testing.T) { n, _ := newNs(t, "/bin/ls") got, err := n.ReadFile("/bin/ls") if err != nil { t.Fatalf("readfile: %v", err) } if got != "contents of /bin/ls\n" { t.Errorf("got %q", got) } if _, err := n.ReadFile("/bin/absent"); err != ninep.ErrNotExist { t.Errorf("missing: got %v, want ErrNotExist", err) } } func TestDotDotIsLexical(t *testing.T) { n, _ := newNs(t, "/a/b/c") // /a/b/../b/c never asks a server about "..". if _, err := n.Stat("/a/b/../b/c"); err != nil { t.Fatalf("lexical dotdot: %v", err) } // Above the root stays at the root. if _, err := n.Stat("/../../a"); err != nil { t.Fatalf("dotdot above root: %v", err) } } func TestBindReplace(t *testing.T) { n, _ := newNs(t, "/bin/ls", "/usr/glenda/bin/rc") if err := n.Bind("/usr/glenda/bin", "/bin", MREPL); err != nil { t.Fatalf("bind: %v", err) } ents, _ := n.ReadDir("/bin", false) if !eq(names(ents), []string{"rc"}) { t.Errorf("after MREPL /bin should hold only rc, got %v", names(ents)) } if _, err := n.Stat("/bin/ls"); err != ninep.ErrNotExist { t.Errorf("replaced file still reachable: %v", err) } } func TestBindAfterUnion(t *testing.T) { n, _ := newNs(t, "/bin/ls", "/usr/glenda/bin/rc") if err := n.Bind("/usr/glenda/bin", "/bin", MAFTER); err != nil { t.Fatalf("bind -a: %v", err) } ents, _ := n.ReadDir("/bin", false) if !eq(names(ents), []string{"ls", "rc"}) { t.Errorf("union order: got %v, want [ls rc]", names(ents)) } if _, err := n.Stat("/bin/rc"); err != nil { t.Errorf("unioned file not reachable: %v", err) } if _, err := n.Stat("/bin/ls"); err != nil { t.Errorf("original file lost: %v", err) } } func TestBindBeforeUnion(t *testing.T) { n, _ := newNs(t, "/bin/ls", "/usr/glenda/bin/rc") if err := n.Bind("/usr/glenda/bin", "/bin", MBEFORE); err != nil { t.Fatalf("bind -b: %v", err) } ents, _ := n.ReadDir("/bin", false) if !eq(names(ents), []string{"rc", "ls"}) { t.Errorf("union order: got %v, want [rc ls]", names(ents)) } } func TestUnionShadowingIsVisible(t *testing.T) { // Both directories hold a file called "ls". Plan 9 concatenates, so the // listing shows the duplicate and makes the shadowing visible. fs := memfs.New("glenda", 1) fs.WriteFile("/bin/ls", "system ls\n", 1) fs.WriteFile("/usr/glenda/bin/ls", "my ls\n", 1) n := New(fs.Root()) if err := n.Bind("/usr/glenda/bin", "/bin", MBEFORE); err != nil { t.Fatalf("bind: %v", err) } ents, _ := n.ReadDir("/bin", false) if !eq(names(ents), []string{"ls", "ls"}) { t.Errorf("concatenation should show both: got %v", names(ents)) } uniq, _ := n.ReadDir("/bin", true) if !eq(names(uniq), []string{"ls"}) { t.Errorf("unique listing: got %v", names(uniq)) } // A walk reaches the first member, which is the one bound before. got, err := n.ReadFile("/bin/ls") if err != nil { t.Fatalf("read: %v", err) } if got != "my ls\n" { t.Errorf("shadowing: got %q, want my ls", got) } } func TestUnionIsTopLevelOnly(t *testing.T) { // /a and /b each hold a directory "sub", but only /a/sub/x exists. // Unioning /b onto /a does NOT union /a/sub with /b/sub. fs := memfs.New("glenda", 1) fs.WriteFile("/a/sub/x", "x\n", 1) fs.WriteFile("/b/sub/y", "y\n", 1) n := New(fs.Root()) if err := n.Bind("/b", "/a", MAFTER); err != nil { t.Fatalf("bind: %v", err) } if _, err := n.Stat("/a/sub/x"); err != nil { t.Errorf("/a/sub/x should still resolve: %v", err) } if _, err := n.Stat("/a/sub/y"); err != ninep.ErrNotExist { t.Errorf("unions are top level only, /a/sub/y must not resolve: %v", err) } } func TestBindCapturesAChannelNotAName(t *testing.T) { fs := memfs.New("glenda", 1) fs.WriteFile("/one/f", "one\n", 1) fs.WriteFile("/two/f", "two\n", 1) n := New(fs.Root()) if err := n.Bind("/one", "/mnt", MREPL); err != nil { // /mnt does not exist yet, so this must fail: see the next test. fs.MkdirAll("/mnt", 1) if err := n.Bind("/one", "/mnt", MREPL); err != nil { t.Fatalf("bind: %v", err) } } // Now rebind the SOURCE name elsewhere. The earlier bind captured the // directory, so /mnt must not follow. if err := n.Bind("/two", "/one", MREPL); err != nil { t.Fatalf("rebind source: %v", err) } got, _ := n.ReadFile("/mnt/f") if got != "one\n" { t.Errorf("bind should have captured the channel: /mnt/f is %q", got) } got, _ = n.ReadFile("/one/f") if got != "two\n" { t.Errorf("/one/f is %q, want two", got) } } func TestBindTargetMustExist(t *testing.T) { n, _ := newNs(t, "/bin/ls") if err := n.Bind("/bin", "/nowhere", MREPL); err != ninep.ErrNotExist { t.Errorf("bind onto a missing name: got %v, want ErrNotExist", err) } if err := n.Bind("/nowhere", "/bin", MREPL); err != ninep.ErrNotExist { t.Errorf("bind from a missing name: got %v, want ErrNotExist", err) } } func TestCreateTargetNeedsMCREATE(t *testing.T) { fs := memfs.New("glenda", 1) fs.MkdirAll("/bin", 1) fs.MkdirAll("/usr/glenda/bin", 1) n := New(fs.Root()) // A plain directory takes creates with no flag at all. if _, err := n.CreateTarget("/bin"); err != nil { t.Fatalf("plain directory should accept a create: %v", err) } // Made into a union with no MCREATE anywhere, creation is refused. if err := n.Bind("/usr/glenda/bin", "/bin", MAFTER); err != nil { t.Fatalf("bind: %v", err) } if _, err := n.CreateTarget("/bin"); err != ninep.ErrNoCreate { t.Errorf("union without MCREATE: got %v, want ErrNoCreate", err) } // Rebinding with MCREATE picks that member. if err := n.Bind("/usr/glenda/bin", "/bin", MAFTER|MCREATE); err != nil { t.Fatalf("bind -ac: %v", err) } target, err := n.CreateTarget("/bin") if err != nil { t.Fatalf("union with MCREATE: %v", err) } if _, err := target.Create("new", ninep.FilePerm, 2); err != nil { t.Fatalf("create: %v", err) } // It landed in the MCREATE member, not in /bin's own directory. if _, err := ninep.Walk(fs.Root(), ninep.Elems("/usr/glenda/bin/new")); err != nil { t.Errorf("create went to the wrong member: %v", err) } } func TestUnmount(t *testing.T) { n, _ := newNs(t, "/bin/ls", "/usr/glenda/bin/rc") n.Bind("/usr/glenda/bin", "/bin", MAFTER) ents, _ := n.ReadDir("/bin", false) if len(ents) != 2 { t.Fatalf("setup: %v", names(ents)) } if err := n.Unmount("/usr/glenda/bin", "/bin"); err != nil { t.Fatalf("unmount: %v", err) } ents, _ = n.ReadDir("/bin", false) if !eq(names(ents), []string{"ls"}) { t.Errorf("after unmount: %v", names(ents)) } if err := n.Unmount("", "/bin"); err != ErrNotBound { t.Errorf("unmount of an unbound name: got %v, want ErrNotBound", err) } } func TestUnmountAll(t *testing.T) { n, _ := newNs(t, "/bin/ls", "/usr/glenda/bin/rc") n.Bind("/usr/glenda/bin", "/bin", MAFTER) if err := n.Unmount("", "/bin"); err != nil { t.Fatalf("unmount all: %v", err) } ents, _ := n.ReadDir("/bin", false) if !eq(names(ents), []string{"ls"}) { t.Errorf("name should be restored to its original contents: %v", names(ents)) } if len(n.Ops()) != 0 { t.Errorf("ops should be gone: %v", n.Ops()) } } func TestCd(t *testing.T) { n, _ := newNs(t, "/usr/glenda/lib/profile") if err := n.Cd("/usr/glenda"); err != nil { t.Fatalf("cd: %v", err) } if n.Cwd() != "/usr/glenda" { t.Errorf("cwd: %q", n.Cwd()) } got, err := n.ReadFile("lib/profile") if err != nil { t.Fatalf("relative read: %v", err) } if got == "" { t.Error("relative read came back empty") } if err := n.Cd("lib/profile"); err != ninep.ErrNotDir { t.Errorf("cd into a file: got %v, want ErrNotDir", err) } if err := n.Cd("/nowhere"); err != ninep.ErrNotExist { t.Errorf("cd nowhere: got %v", err) } } func TestStringMatchesNsFormat(t *testing.T) { n, _ := newNs(t, "/bin/ls", "/usr/glenda/bin/rc", "/tmp/x") n.Bind("/usr/glenda/bin", "/bin", MAFTER|MCREATE) n.Bind("/tmp", "/usr/glenda", MBEFORE) n.Cd("/usr/glenda") want := "bind -ac /usr/glenda/bin /bin\n" + "bind -b /tmp /usr/glenda\n" + "cd /usr/glenda\n" if got := n.String(); got != want { t.Errorf("ns output:\ngot:\n%s\nwant:\n%s", got, want) } } func TestFlagString(t *testing.T) { tests := []struct { flag Flag want string }{ {MREPL, ""}, {MBEFORE, "-b"}, {MAFTER, "-a"}, {MCREATE, "-c"}, {MAFTER | MCREATE, "-ac"}, {MBEFORE | MCREATE, "-bc"}, } for _, tt := range tests { if got := tt.flag.String(); got != tt.want { t.Errorf("Flag(%d): got %q, want %q", uint32(tt.flag), got, tt.want) } } } func TestUnionWidthIsBounded(t *testing.T) { fs := memfs.New("glenda", 1) fs.MkdirAll("/bin", 1) for i := 0; i < MaxUnion+2; i++ { fs.MkdirAll("/d"+string(rune('a'+i)), 1) } n := New(fs.Root()) var err error for i := 0; i < MaxUnion+2; i++ { err = n.Bind("/d"+string(rune('a'+i)), "/bin", MAFTER) if err != nil { break } } if err != ErrWideUnion { t.Errorf("got %v, want ErrWideUnion", err) } } func TestReadDirOnAFile(t *testing.T) { n, _ := newNs(t, "/bin/ls") if _, err := n.ReadDir("/bin/ls", false); err != ninep.ErrNotDir { t.Errorf("got %v, want ErrNotDir", err) } } func TestDepthIsBounded(t *testing.T) { n, _ := newNs(t, "/a") p := "" for i := 0; i <= ninep.MaxDepth; i++ { p += "/x" } if _, err := n.Stat(p); err != ninep.ErrTooDeep { t.Errorf("got %v, want ErrTooDeep", err) } }
#4AddPackagegno.land/p/moul/x/plan9/synfs/v09 arguments
Attached funds
6000000ugnot

Arguments · 9

  1. #1synfs
  2. #2README.md
  3. #3# `gno.land/p/moul/x/plan9/synfs/v0` **A synthetic, read-only file server whose contents are computed at read time.** This is the package that makes the rest of the suite adoptable. ```go import synfs "gno.land/p/moul/x/plan9/synfs/v0" t := synfs.New("dev", "sys", func() int64 { return runtime.ChainHeight() }) t.Root(). Add("sysname", func() string { return runtime.ChainID() }). Add("height", func() string { return strconv.FormatInt(runtime.ChainHeight(), 10) }) ``` In Plan 9 a device is not storage, it is code behind a name: reading `/dev/time` runs a function. A realm that wants to publish its state as a browsable tree does the same thing here, in a few lines, and gets `ls`, `cat`, `stat` and mountability for free. **Read-only by construction.** The tree implements `ninep.File` and not `ninep.Mutable`, so every method is free of side effects and the tree is safe to hand to a namespace owned by somebody else. That is the property the whole cross-realm mount story rests on. Two details worth knowing: - **A synthetic file pins `Qid.Version` at 0 forever.** Its contents can change on every block, so a version would be a lie; a client that needs change detection should read the file. - **`AddRange` serves the 9P read window itself**, for a file with no natural end. `/dev/zero` uses it: an unbounded read returns nothing rather than an endless value, which is what stops `cat /dev/zero` from being a denial of service. **Live demo:** [`r/moul/x/plan9/dev`](../../../../../r/moul/x/plan9/dev/v0) publishes the chain itself as a device tree. Design and analysis: [moul/gno-contracts#136](https://github.com/moul/gno-contracts/issues/136). --- **Not affiliated with Plan 9.** Plan 9 from Bell Labs is the work of the Computing Science Research Center at Bell Labs; the name and the marks are theirs, and the copyright is held by the [Plan 9 Foundation](https://p9f.org). This package borrows the vocabulary and none of the code: it is an independent homage, asking what that ecosystem's spirit looks like on a chain. Full attribution: [NOTICE](../../../../../NOTICE.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/x/plan9/synfs/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/plan9/synfs/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/plan9/synfs/v0" gno = "0.9"
  6. #6synfs.gno
  7. #7// Package synfs builds a synthetic, read-only ninep file server whose contents // are computed by a function at read time. // // This is the package that makes the rest of the suite adoptable. Plan 9's // device drivers are not storage, they are code behind a name: reading // /dev/time runs a function. A realm that wants to publish its state as a // browsable tree does the same thing here, in a few lines, and gets ls, cat, // stat and mountability for free: // // t := synfs.New("dev", "g1...", func() int64 { return runtime.ChainHeight() }) // t.Root(). // Add("sysname", func() string { return runtime.ChainID() }). // Add("height", func() string { return strconv.FormatInt(runtime.ChainHeight(), 10) }) // // The tree is READ-ONLY by construction, which is what makes it safe to hand // to another realm's namespace: every method is free of side effects, so it // can be called from a frame that does not own these objects. // // A synthetic file reports Qid.Version 0 forever. Its contents can change on // every block, so a version would be a lie; clients that need change detection // should read the file. // // NOTICE. Plan 9 from Bell Labs is the work of the Computing Science Research // Center at Bell Labs; the name and the marks are theirs, and the copyright is // held by the Plan 9 Foundation (https://p9f.org). This package is not // affiliated with, endorsed by, or sponsored by them, and contains no Plan 9 // code: it borrows the vocabulary so that the design reads without a glossary, // and it is an homage, asking what that ecosystem's spirit looks like on a // chain. Full attribution: NOTICE.md at the root of moul/gno-contracts. package synfs import ( "gno.land/p/nt/avl/v0" ninep "gno.land/p/moul/x/plan9/ninep/v0" ) // ReadFn computes a whole file. type ReadFn func() string // RangeFn serves a 9P read window directly, for files that are cheaper (or // only possible) to generate a slice at a time, such as an endless one. type RangeFn func(off, count int64) (string, error) // Tree owns the qid allocator, the clock and the owner name shared by every // node in one synthetic server. type Tree struct { root *Dir next uint64 clock func() int64 uid string } // New returns a tree whose root directory is named name, owned by uid, using // clock (normally the block height) as every node's mtime. clock may be nil, // in which case the mtime is zero. func New(name, uid string, clock func() int64) *Tree { t := &Tree{clock: clock, uid: uid} t.root = t.NewDir(name) return t } // Root returns the tree's root directory. func (t *Tree) Root() *Dir { return t.root } // Uid returns the owner stamped on every node. func (t *Tree) Uid() string { return t.uid } func (t *Tree) now() int64 { if t.clock == nil { return 0 } return t.clock() } func (t *Tree) qid(dir bool) ninep.Qid { t.next++ qt := ninep.QTFILE if dir { qt = ninep.QTDIR } return ninep.Qid{Type: qt, Version: 0, Path: t.next} } // NewDir returns a detached directory belonging to this tree. Attach it with // AddDir. func (t *Tree) NewDir(name string) *Dir { return &Dir{ tree: t, name: name, perm: ninep.DMDIR | 0555, qid: t.qid(true), children: avl.NewTree(), } } // Dir is a synthetic directory. Its children are held in an avl tree, so a // listing is ordered by name and identical on every node. type Dir struct { tree *Tree name string perm ninep.Perm qid ninep.Qid children *avl.Tree // name -> ninep.File } // Add attaches a read-only file computed by fn, mode 0444. It returns the // directory, so calls chain. func (d *Dir) Add(name string, fn ReadFn) *Dir { return d.AddPerm(name, 0444, fn) } // AddPerm is Add with an explicit mode. func (d *Dir) AddPerm(name string, perm ninep.Perm, fn ReadFn) *Dir { whole := fn return d.AddRange(name, perm, func(off, count int64) (string, error) { return ninep.Slice(whole(), off, count), nil }) } // AddRange attaches a file that serves a read window itself. Use it for a file // with no natural end, where materialising the whole thing would be wrong. func (d *Dir) AddRange(name string, perm ninep.Perm, fn RangeFn) *Dir { if !ninep.ValidName(name) { panic("synfs: invalid file name: " + name) } d.children.Set(name, &file{ tree: d.tree, name: name, perm: perm &^ ninep.DMDIR, qid: d.tree.qid(false), fn: fn, }) return d } // AddDir attaches a subdirectory built with Tree.NewDir. func (d *Dir) AddDir(sub *Dir) *Dir { if !ninep.ValidName(sub.name) { panic("synfs: invalid directory name: " + sub.name) } d.children.Set(sub.name, sub) return d } // AddFile attaches any ninep.File under the given name, which is how a // synthetic tree splices in a tree served by something else. func (d *Dir) AddFile(name string, f ninep.File) *Dir { if !ninep.ValidName(name) { panic("synfs: invalid file name: " + name) } d.children.Set(name, f) return d } // Names returns the directory's entries, in order. Useful for a file that // wants to describe its own directory, as /dev/drivers does. func (d *Dir) Names() []string { out := []string{} d.children.Iterate("", "", func(k string, _ any) bool { out = append(out, k) return false }) return out } // Stat implements ninep.File. func (d *Dir) Stat() ninep.Stat { return ninep.Stat{ Qid: d.qid, Mode: d.perm, Mtime: d.tree.now(), Name: d.name, Uid: d.tree.uid, Gid: d.tree.uid, Muid: d.tree.uid, } } // Walk implements ninep.File. func (d *Dir) Walk(name string) (ninep.File, error) { if !ninep.ValidName(name) { return nil, ninep.ErrBadName } v := d.children.Get(name) if v == nil { return nil, ninep.ErrNotExist } return v.(ninep.File), nil } // Read implements ninep.File. func (d *Dir) Read(off, count int64) (string, error) { return "", ninep.ErrIsDir } // ReadDir implements ninep.File. func (d *Dir) ReadDir() ([]ninep.Stat, error) { out := []ninep.Stat{} d.children.Iterate("", "", func(_ string, v any) bool { out = append(out, v.(ninep.File).Stat()) return false }) return out, nil } type file struct { tree *Tree name string perm ninep.Perm qid ninep.Qid fn RangeFn } func (f *file) Stat() ninep.Stat { // A synthetic file has no stored length. 9P servers for such files report // zero rather than run the generator just to measure it, and so does this. return ninep.Stat{ Qid: f.qid, Mode: f.perm, Mtime: f.tree.now(), Name: f.name, Uid: f.tree.uid, Gid: f.tree.uid, Muid: f.tree.uid, } } func (f *file) Walk(name string) (ninep.File, error) { return nil, ninep.ErrNotDir } func (f *file) Read(off, count int64) (string, error) { return f.fn(off, count) } func (f *file) ReadDir() ([]ninep.Stat, error) { return nil, ninep.ErrNotDir }
  8. #8synfs_test.gno
  9. #9package synfs import ( "strconv" "strings" "testing" ninep "gno.land/p/moul/x/plan9/ninep/v0" ) func fixedClock(h int64) func() int64 { return func() int64 { return h } } func TestTreeShape(t *testing.T) { tr := New("dev", "sys", fixedClock(42)) tr.Root(). Add("sysname", func() string { return "gnoland-1" }). Add("height", func() string { return "42" }) st := tr.Root().Stat() if st.Name != "dev" { t.Errorf("root name %q", st.Name) } if !st.IsDir() { t.Error("root should be a directory") } if st.Mtime != 42 { t.Errorf("mtime %d, want 42", st.Mtime) } if st.Mode.String() != "dr-xr-xr-x" { t.Errorf("mode %q, want dr-xr-xr-x", st.Mode.String()) } } func TestReadComputed(t *testing.T) { height := int64(100) tr := New("dev", "sys", func() int64 { return height }) tr.Root().Add("height", func() string { return strconv.FormatInt(height, 10) }) f, err := tr.Root().Walk("height") if err != nil { t.Fatalf("walk: %v", err) } got, _ := ninep.ReadAll(f) if got != "100" { t.Errorf("got %q", got) } // The generator runs on every read, so the file follows the chain. height = 101 got, _ = ninep.ReadAll(f) if got != "101" { t.Errorf("after the block advanced: got %q", got) } if v := f.Stat().Qid.Version; v != 0 { t.Errorf("a synthetic file pins version 0, got %d", v) } } func TestReadWindow(t *testing.T) { tr := New("dev", "sys", nil) tr.Root().Add("msg", func() string { return "hello world" }) f, _ := tr.Root().Walk("msg") tests := []struct { off, count int64 want string }{ {0, -1, "hello world"}, {0, 5, "hello"}, {6, 5, "world"}, {99, 5, ""}, } for _, tt := range tests { got, err := f.Read(tt.off, tt.count) if err != nil { t.Fatalf("read: %v", err) } if got != tt.want { t.Errorf("Read(%d,%d): got %q, want %q", tt.off, tt.count, got, tt.want) } } } func TestAddRangeEndlessFile(t *testing.T) { // /dev/zero has no end: it must serve the window rather than a value. tr := New("dev", "sys", nil) tr.Root().AddRange("zero", 0444, func(off, count int64) (string, error) { if count < 0 { count = 0 } return strings.Repeat("0", int(count)), nil }) f, _ := tr.Root().Walk("zero") got, _ := f.Read(0, 4) if got != "0000" { t.Errorf("got %q", got) } got, _ = f.Read(0, -1) if got != "" { t.Errorf("an unbounded read of an endless file must not hang or grow: got %q", got) } } func TestReadDirIsOrdered(t *testing.T) { tr := New("dev", "sys", fixedClock(1)) tr.Root(). Add("zero", func() string { return "" }). Add("null", func() string { return "" }). Add("time", func() string { return "" }) ents, err := tr.Root().ReadDir() if err != nil { t.Fatalf("readdir: %v", err) } want := []string{"null", "time", "zero"} if len(ents) != len(want) { t.Fatalf("got %d entries", len(ents)) } for i, w := range want { if ents[i].Name != w { t.Errorf("entry %d: got %q, want %q", i, ents[i].Name, w) } } if got := tr.Root().Names(); len(got) != 3 || got[0] != "null" { t.Errorf("Names: %v", got) } } func TestSubdirectories(t *testing.T) { tr := New("proc", "sys", fixedClock(7)) sub := tr.NewDir("1") sub.Add("status", func() string { return "running" }) tr.Root().AddDir(sub) f, err := ninep.Walk(tr.Root(), ninep.Elems("/1/status")) if err != nil { t.Fatalf("walk: %v", err) } got, _ := ninep.ReadAll(f) if got != "running" { t.Errorf("got %q", got) } } func TestErrors(t *testing.T) { tr := New("dev", "sys", nil) tr.Root().Add("null", func() string { return "" }) if _, err := tr.Root().Read(0, -1); err != ninep.ErrIsDir { t.Errorf("read a directory: got %v", err) } if _, err := tr.Root().Walk("absent"); err != ninep.ErrNotExist { t.Errorf("walk missing: got %v", err) } if _, err := tr.Root().Walk("a/b"); err != ninep.ErrBadName { t.Errorf("walk bad name: got %v", err) } f, _ := tr.Root().Walk("null") if _, err := f.Walk("x"); err != ninep.ErrNotDir { t.Errorf("walk through a file: got %v", err) } if _, err := f.ReadDir(); err != ninep.ErrNotDir { t.Errorf("readdir a file: got %v", err) } } func TestSyntheticTreeIsReadOnly(t *testing.T) { // The whole point: a synthetic tree implements File and NOT Mutable, so // it is safe to hand to a namespace owned by somebody else. tr := New("dev", "sys", nil) var f ninep.File = tr.Root() if _, ok := f.(ninep.Mutable); ok { t.Error("a synthetic tree must not be Mutable") } }
#5AddPackagegno.land/p/moul/x/vm/bf/v019 arguments
Attached funds
21000000ugnot

Arguments · 19

  1. #1bf
  2. #2README.md
  3. #3# p/moul/x/vm/bf A Brainfuck machine for gno.land, and the measuring stick the guest-VM work is calibrated against. The package ships two things that look alike and are not: - **`Execute`**, the naive interpreter from 2023: a switch over the source bytes, a brace matcher that rescans the program on every loop edge. It is kept verbatim as rung 0 of the ladder below, because a baseline you have edited is not a baseline. It panics on `,` and on unbalanced brackets, and both bugs are pinned by tests so nobody "fixes" the reference. - **`Compile` + `Machine`**, the real one: resolved jump targets, fused operator runs, loop idioms folded into single ops, and a fuel-metered step loop over a [`vmkit`](../vmkit) `Host`, so a program pauses when it runs out and resumes in a later transaction. Live demo: [`r/moul/x/vm/bfdemo`](/r/moul/x/vm/bfdemo/v0). ```go m, err := bf.Load("+++++[->++++++++++<]>++.") if err != nil { return err } used, status := m.Step(host, 1000) // status: running, halted, trapped, out of fuel snap := m.Snapshot() // resume later: NewMachine(prog).Restore(snap) ``` ## The optimization ladder Measured 2026-09-22 with `gno test -print-runtime-metrics`, `gno` built from `gnolang/gno` master@877379432. Reproduce with: ```sh gno test -print-runtime-metrics . ``` The program is `heavy` (`ladder_test.gno`): 408 source bytes, **121,201 guest instructions**, no output, so it measures the dispatch loop and nothing else. | rung | what changed | machine ops | cycles | cycles / guest op | |---|---|---:|---:|---:| | 0 | `Execute`, the original | 161,200 dispatches | 1.4G | 11,551 | | 1 | compile to ops, resolve jump targets | 121,202 | 613.4M | 5,061 | | 2 | fuse runs of `+` and `>` | 81,203 | 444.6M | 3,668 | | 3 | fold loop idioms (`[-]`, `[->+<]`, `[>]`) | 1,203 | 10.0M | 83 | | 5 | rung 3 with a fuel budget enforced | 1,203 | 11.0M | 91 | From the 1x and 4x pairs, which cancel the fixed per-test overhead: rung 0 runs at **11,003 cycles per guest instruction**, rung 3 at **79.5**. The ladder is worth **138x**, and almost all of it is rung 3: folding a counted loop into the multiply it performs is the only optimization here that changes the complexity rather than the constant. ### Two denominators, and why it matters A guest instruction is one operator the language executes, counted the way any jump-table interpreter counts it: 121,201 for `heavy`. The naive interpreter *dispatches* more often than that for the same program, because its `]` handler scans back to the matching `[` and lands on it, so `[` is re-evaluated on every iteration of every loop: 161,200, a factor of 1.33. Rung 1 removes exactly that excess, so quoting cycles-per-*dispatch* would credit rung 1 twice and make rung 0 look 33% better than it is. Both counts come from an independent simulator, not from this package. ## The other axis: what the GnoVM charges for The rungs above are the classic optimizations, fewer instructions for the same program. On the GnoVM a second axis matters as much, and it is not in any interpreter textbook: the same instruction stream, run by loops that differ from each other by one line. `micro_test.gno` is that matrix. Rung 1's stream, 121,202 ops: | loop | cycles | allocs | vs. locals | |---|---:|---:|---:| | cursors as struct fields | 523.7M | 31.5M | +18% | | cursors in locals | 442.4M | 31.5M | baseline | | + `vmkit.Meter.Charge` per op | 821.3M | 110.1M | **+86%** | | + inline fuel counter instead | 514.2M | 31.5M | +16% | | + tape as a local slice | 492.1M | 31.6M | +11% | Rung 3's stream, 1,203 ops, same ordering: 9.0M / 8.2M / 11.9M / 8.9M / 8.7M. Three things follow, and the shipped `Machine.Step` does all three: 1. **A method call per guest instruction is the most expensive thing in the loop.** Charging fuel through `vmkit.Meter` costs 86% on top of the entire dispatch loop and more than triples the allocation count. `Meter` is the right type at the API boundary, where it is called once per slice. It is not a hot-path type, and no guest VM in the zoo should treat it as one. An inline counter does the same job for 16%. 2. **Reaching through a struct pointer for the program counter and the tape pointer costs 16%.** Hoist them into locals and write back once. 3. **The fixed-array tape is not the win the textbooks claim.** An array is supposed to remove a bounds check; here a slice is 4% *faster*, because a slice header can be copied into a local and an array cannot. It is the smallest effect on this page, and the classic ladder puts it above idiom recognition, which is worth 44x. The pc range check is also hoisted out of the loop: every jump target is produced by `Compile` and every restored pc is validated by `Restore`, so checking per op buys nothing. ## Snapshots A snapshot carries the tape only up to the highest cell the program has reached, so hello world pauses in **43 bytes**, not 30,000. That is what makes continuations cheaper than re-running, which is the kill criterion `vmkit` set for them. The program is *not* in the snapshot: a realm stores it once beside the instance, not once per pause. ## Semantics Tape of 30,000 wrapping byte cells, matching the original. `,` past the end of input yields a zero cell, the most common of the three conventions the language never settled. `Compile` rejects unbalanced brackets and sources above 64 KiB. The idiom rewriter is deliberately conservative: it folds a loop only when the body moves and adds, returns to where it started, and takes exactly one off the current cell. A loop that adds one per iteration still terminates by wrapping after 256 rounds, and is left as a real loop, because folding it would be a different program. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/x/vm/bf/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/vm/bf/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. #4bf.gno
  5. #5// Package bf is a Brainfuck machine for gno.land, and the measuring stick the // rest of the guest-VM work is calibrated against. // // It ships two things that look alike and are not: // // - [Execute], the naive interpreter: a switch over the source bytes, a // brace matcher that rescans the program on every loop edge. It is kept // verbatim as rung 0 of the optimization ladder, because a baseline you // have edited is not a baseline. // - [Compile] plus [Machine], the real one: the program is compiled to an // op array with resolved jumps, runs of identical operators are fused, // the common loop idioms become single ops, and execution is metered by // a caller-supplied fuel budget against a // [vmkit](/p/moul/x/vm/vmkit/v0) Host, so a program can pause when it // runs out and resume in a later transaction. // // The ladder between them is measured, not asserted: see the README for the // table, and ladder_test.gno for the harness that produces it. // // Live demo: [r/moul/x/vm/bfdemo](/r/moul/x/vm/bfdemo/v0). package bf import "strings" // NaiveTapeSize is the tape length used by [Execute]. It is 30,000 because // that is what the original had, and rung 0 is not allowed to drift. const NaiveTapeSize = 30000 // Execute runs code on a fresh tape and returns everything it wrote, using // the original 2023 implementation: no compilation step, one switch per // source byte, and a brace matcher that rescans the source every time a loop // opens or closes. // // It is rung 0 of the ladder and exists to be measured. Two of its properties // are bugs that are deliberately preserved, and are the reason nothing should // call it on untrusted input: // // - `,` panics with "unsupported": there is no input. // - unbalanced brackets run the scan off the end of the source and panic // with an index out of range. // // Use [Compile] and [Machine] for anything real: they reject a malformed // program up front, read input from the host, and cannot run unbounded. func Execute(code string) string { var ( memory = make([]byte, NaiveTapeSize) // memory tape pointer = 0 // initial memory pointer buf strings.Builder ) // Loop through each character in the code for i := 0; i < len(code); i++ { switch code[i] { case '>': // Increment memory pointer pointer++ if pointer >= NaiveTapeSize { pointer = 0 } case '<': // Decrement memory pointer pointer-- if pointer < 0 { pointer = NaiveTapeSize - 1 } case '+': // Increment the byte at the memory pointer memory[pointer]++ case '-': // Decrement the byte at the memory pointer memory[pointer]-- case '.': // Output the byte at the memory pointer buf.WriteByte(memory[pointer]) case ',': // Input a byte and store it in the memory panic("unsupported") case '[': // Jump forward past the matching ']' if the byte at the memory pointer is zero if memory[pointer] == 0 { braceCount := 1 for braceCount > 0 { i++ if code[i] == '[' { braceCount++ } else if code[i] == ']' { braceCount-- } } } case ']': // Jump backward to the matching '[' if the byte at the memory pointer is nonzero if memory[pointer] != 0 { braceCount := 1 for braceCount > 0 { i-- if code[i] == ']' { braceCount++ } else if code[i] == '[' { braceCount-- } } i-- // Move back one more to compensate for the upcoming increment in the loop } } } return buf.String() }
  6. #6bf_test.gno
  7. #7package bf import ( "testing" "gno.land/p/moul/x/vm/vmkit/v0" "gno.land/p/nt/uassert/v0" ) // hello is the classic 106 byte hello-world. It writes "Hello World", 11 // bytes: the original run.gno comment was optimistic about the trailing "!\n". const hello = "++++++++++[>+++++++>++++++++++>+++>+<<<<-]>++.>+.+++++++..+++.>++.<<+++++++++++++++.>.+++.------.--------." // corpus is the set of programs every ladder level must agree on. Each one is // here because it exercises a rewrite that could plausibly be wrong. var corpus = []struct { name string src string want string }{ {"empty", "", ""}, {"comment only", "this is not brainfuck", ""}, {"hello", hello, "Hello World"}, {"clear idiom", "+++++[-]+++++++++++++++++++++++++++++++++++++++++++++++.", "/"}, {"move idiom", "+++++++++++++++++++++++++++++++++++++++++++++++++++[->+<]>.", "3"}, {"multiply idiom", "+++++[->++++++++++<]>++.", "4"}, {"two targets", "+++[->+>++<<]>+++++++++++++++++++++++++++++++++++++++.>+++++++++++++++++++++++++++++++++++++++++.", "*/"}, {"scan walks", ">+>+>+>+[<]++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++.", "H"}, {"scan right", ">>>+++++++++<<<[>]+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++.", "?"}, {"nested loops", "++[>++[>+<-]<-]>>+++++++++++++++++++++++++++++++++++++++++++++++++.", "5"}, {"dead run", "+++--+++--+++++++++++++++++++++++++++++++++++++++++++.", "-"}, {"skipped loop", "[++++++++++++++++++++]+++++++++++++++++++++++++++++++++++++++++++++++.", "/"}, {"wraps under zero", "-[>+<-]>++++++++++.", "\t"}, } func runLevel(t *testing.T, src string, lvl Level, input string) string { t.Helper() prog, err := Compile(src, lvl) uassert.NoError(t, err) if prog == nil { return "" } h := vmkit.NewTestHost().WithInput([]byte(input)) m := NewMachine(prog) _, status := m.Step(h, vmkit.Unmetered) uassert.Equal(t, "halted", status.String()) return h.OutString() } // TestLadderLevelsAgree is the test the whole ladder rests on: an // optimization that changes the output is not an optimization. func TestLadderLevelsAgree(t *testing.T) { for _, tc := range corpus { naive := Execute(tc.src) uassert.Equal(t, tc.want, naive) for _, lvl := range []Level{LevelJumps, LevelFuse, LevelIdioms} { got := runLevel(t, tc.src, lvl, "") if got != tc.want { t.Errorf("%s at level %s: got %q, want %q", tc.name, lvl.String(), got, tc.want) } } } } func TestExecuteBaseline(t *testing.T) { // Rung 0 is kept verbatim, so this is also a regression test on the // thing the published cycles-per-op number was measured against. uassert.Equal(t, "Hello World", Execute(hello)) uassert.Equal(t, 11, len(Execute(hello))) } func TestCompileRejectsUnbalanced(t *testing.T) { cases := []string{"[", "]", "[[]", "+[->+<", "][", "[[[]]"} for _, src := range cases { _, err := CompileDefault(src) if err == nil { t.Errorf("Compile(%q) should have failed", src) } } } func TestCompileRejectsOversizeSource(t *testing.T) { src := "+" for len(src) <= MaxSource { src += src // doubling, not O(n^2) appending } _, err := CompileDefault(src) uassert.ErrorIs(t, err, ErrSourceTooLong) } func TestCompileRejectsUnknownLevel(t *testing.T) { _, err := Compile("+", Level(42)) uassert.Error(t, err) _, err = Compile("+", Level(-1)) uassert.Error(t, err) } // TestLadderShrinksTheOpStream is the compile-time half of the ladder: each // rung has to produce strictly fewer ops than the one below it, or it is not // buying anything. func TestLadderShrinksTheOpStream(t *testing.T) { jumps, err := Compile(hello, LevelJumps) uassert.NoError(t, err) fuse, err := Compile(hello, LevelFuse) uassert.NoError(t, err) idioms, err := Compile(hello, LevelIdioms) uassert.NoError(t, err) uassert.True(t, jumps.Len() > fuse.Len()) uassert.True(t, fuse.Len() > idioms.Len()) uassert.Equal(t, "jumps", jumps.Level().String()) uassert.Equal(t, hello, idioms.Source()) } func TestIdiomRecognition(t *testing.T) { cases := []struct { name string src string want int // ops, excluding the trailing halt }{ {"clear", "[-]", 1}, // opSet {"move", "[->+<]", 2}, // opAddMul, opSet {"multiply two", "[->++>+++<<]", 3}, // two opAddMul, opSet {"scan right", "[>]", 1}, // opScan {"scan left", "[<]", 1}, // opScan } for _, tc := range cases { prog, err := Compile(tc.src, LevelIdioms) uassert.NoError(t, err) if prog == nil { continue } if got := prog.Len() - 1; got != tc.want { t.Errorf("%s: %q compiled to %d ops, want %d", tc.name, tc.src, got, tc.want) } } } // TestPlusLoopIsNotRewritten pins the conservative half of the rewrite: a // loop that adds one per iteration still reaches zero by wrapping, but only // after 256 iterations, and folding it would be a different program. It has // to stay a real loop. func TestPlusLoopIsNotRewritten(t *testing.T) { prog, err := Compile("[+]", LevelIdioms) uassert.NoError(t, err) // opJmpZ, opAdd, opJmpNZ, opHalt: untouched. uassert.Equal(t, 4, prog.Len()) } func TestInputThroughTheHost(t *testing.T) { // Read three bytes and echo them back. const echo3 = ",.,.,." h := vmkit.NewTestHost().WithInput([]byte("gno")) m, err := Load(echo3) uassert.NoError(t, err) _, status := m.Step(h, vmkit.Unmetered) uassert.Equal(t, "halted", status.String()) uassert.Equal(t, "gno", h.OutString()) } func TestInputEndsAsZero(t *testing.T) { // Past the end of input, `,` yields a zero cell. Without input at all, // the naive interpreter would have panicked here. h := vmkit.NewTestHost().WithInput([]byte("a")) m, err := Load(",.+++++++++++++++++++++++++++++++++++++++++++++++.,+++++++++++++++++++++++++++++++++++++++++++++++++.") uassert.NoError(t, err) _, status := m.Step(h, vmkit.Unmetered) uassert.Equal(t, "halted", status.String()) uassert.Equal(t, "a\x901", h.OutString()) } func TestExecutePanicsOnInput(cur realm, t *testing.T) { // Rung 0's second preserved bug, pinned so nobody "fixes" the baseline. uassert.PanicsWithMessage(t, cur, "unsupported", func() { Execute(",") }) } func TestMachineWithoutProgram(t *testing.T) { m := &Machine{status: vmkit.Running} used, status := m.Step(vmkit.NewTestHost(), 10) uassert.Equal(t, int64(0), used) uassert.Equal(t, "trapped", status.String()) uassert.Equal(t, "no program", m.Trap()) }
  8. #8compile.gno
  9. #9package bf import ( "errors" "gno.land/p/nt/ufmt/v0" ) // TapeSize is the tape length of a compiled [Machine]. It matches // [NaiveTapeSize] so that the ladder compares like with like: a rung that // changed the semantics would not be a rung, it would be a different program. const TapeSize = 30000 // MaxSource bounds the program a realm will accept. Compilation is linear in // the source, but the op array and every snapshot of it are consensus state, // so the size has to be bounded somewhere the caller can see. const MaxSource = 64 * 1024 // ErrSourceTooLong is returned by [Compile] for a program above [MaxSource]. var ErrSourceTooLong = errors.New("bf: source too long") // Level selects how far up the optimization ladder [Compile] goes. // // Every level produces the same output for the same program: the levels exist // so the cost of each optimization can be measured in isolation, which is the // published result this package is really for. Rung 0 of the ladder is // [Execute] and has no compiler at all. type Level int const ( // LevelJumps is rung 1: one op per source byte, with the jump targets // resolved at compile time. This is the rung that kills the brace // rescan, which in [Execute] costs O(loop body) on every iteration. LevelJumps Level = iota // LevelFuse is rung 2: runs of identical operators collapse, so // "+++++" is one add-5 and ">>>" is one move-3. Real programs are // mostly runs. LevelFuse // LevelIdioms is rung 3: a loop whose body only moves and adds, and // which returns to where it started while decrementing the current // cell by one, is replaced by the multiply-adds it performs plus a // clear. "[-]" becomes one op, "[->+<]" becomes two. LevelIdioms ) // Default is the level [CompileDefault] uses, and the one a realm should // want: every optimization this package knows, same semantics. const Default = LevelIdioms // String names the level, for the ladder table and for realm output. func (l Level) String() string { switch l { case LevelJumps: return "jumps" case LevelFuse: return "fuse" case LevelIdioms: return "idioms" } return "unknown" } type opcode byte const ( opAdd opcode = iota // tape[p] += arg (arg normalized to 0..255) opMove // p += arg opOut // write tape[p] to the host opIn // read one input byte into tape[p] opJmpZ // if tape[p] == 0 { pc = arg } opJmpNZ // if tape[p] != 0 { pc = arg } opSet // tape[p] = byte(arg) opAddMul // tape[p+off] += tape[p] * byte(arg) opScan // while tape[p] != 0 { p += arg } opHalt // end of program ) // op is one compiled instruction. It is a flat struct on purpose: the // execution loop indexes an array of these, and an op that owned a slice // would allocate in the hot path. type op struct { code opcode arg int off int } // Program is compiled Brainfuck, ready to run on a [Machine]. type Program struct { ops []op src string level Level } // Source returns the program text the [Program] was compiled from. func (p *Program) Source() string { return p.src } // Level returns the ladder rung this program was compiled at. func (p *Program) Level() Level { return p.level } // Len returns the number of compiled ops, including the trailing halt. It is // the number worth quoting next to the source length: the ratio is exactly // what the ladder buys. func (p *Program) Len() int { return len(p.ops) } // CompileDefault compiles at [Default]. func CompileDefault(src string) (*Program, error) { return Compile(src, Default) } // Compile turns Brainfuck source into a [Program] at the requested ladder // level. Everything that is not one of the eight operators is a comment, as // the language requires. // // It fails on unbalanced brackets rather than trusting the runtime to notice, // which is the first thing that separates it from [Execute]: a malformed // program is rejected before anybody pays to run it. func Compile(src string, lvl Level) (*Program, error) { if len(src) > MaxSource { return nil, ErrSourceTooLong } if lvl < LevelJumps || lvl > LevelIdioms { return nil, ufmt.Errorf("bf: unknown level %d", int(lvl)) } ops := []op{} opens := []int{} // pc of each unclosed opJmpZ for i := 0; i < len(src); i++ { c := src[i] switch c { case '+', '-': delta := 1 if c == '-' { delta = -1 } if lvl >= LevelFuse { n := 1 for i+n < len(src) && (src[i+n] == '+' || src[i+n] == '-') { if src[i+n] == '+' { delta++ } else { delta-- } n++ } i += n - 1 } if a := normAdd(delta); a != 0 { ops = append(ops, op{code: opAdd, arg: a}) } case '>', '<': delta := 1 if c == '<' { delta = -1 } if lvl >= LevelFuse { n := 1 for i+n < len(src) && (src[i+n] == '>' || src[i+n] == '<') { if src[i+n] == '>' { delta++ } else { delta-- } n++ } i += n - 1 } if d := normMove(delta); d != 0 { ops = append(ops, op{code: opMove, arg: d}) } case '.': ops = append(ops, op{code: opOut}) case ',': ops = append(ops, op{code: opIn}) case '[': opens = append(opens, len(ops)) ops = append(ops, op{code: opJmpZ}) case ']': if len(opens) == 0 { return nil, ufmt.Errorf("bf: unmatched ']' at byte %d", i) } open := opens[len(opens)-1] opens = opens[:len(opens)-1] if lvl >= LevelIdioms { if idiom, ok := simpleLoop(ops[open+1:]); ok { ops = append(ops[:open], idiom...) continue } } // pc lands on the op after the jump, so a jump target is // the index of the partner op itself. ops = append(ops, op{code: opJmpNZ, arg: open}) ops[open].arg = len(ops) - 1 } } if len(opens) != 0 { return nil, ufmt.Errorf("bf: %d unmatched '['", len(opens)) } ops = append(ops, op{code: opHalt}) return &Program{ops: ops, src: src, level: lvl}, nil } // normAdd folds a signed delta into the 0..255 the byte tape actually sees, // so the execution loop never has to convert a negative int to a byte. func normAdd(d int) int { d %= 256 if d < 0 { d += 256 } return d } // normMove folds a pointer delta into one lap of the tape. Moving right // TapeSize times is a no-op on a wrapping tape, in the naive interpreter as // much as here, so collapsing it changes nothing but the op count. func normMove(d int) int { d %= TapeSize if d < 0 { d += TapeSize } if d > TapeSize/2 { d -= TapeSize } return d } // cell is one offset the body of a simple loop writes to. type cell struct { off int delta int } // simpleLoop decides whether a loop body can be replaced by straight-line // code, and returns that code. // // Two shapes qualify, and they are the two that dominate real programs: // // - The body only moves, and ends somewhere other than where it started: // that is a scan, "[>]" walking to the next zero cell. // - The body only moves and adds, ends where it started, and takes exactly // one off the current cell: that is a multiply-add. The loop runs // tape[p] times, so every other cell it touches gains its delta times // tape[p], and the current cell ends at zero. "[-]" is the degenerate // case with no other cells. // // Anything else, including any body containing I/O or a nested loop that was // itself rewritten, is left alone. Being conservative here costs a few ops in // rare programs and is the only reason this rewrite is safe at all: a loop // whose current cell does not reach zero in steps of one is not guaranteed to // terminate, and constant-folding it would change the program. func simpleLoop(body []op) ([]op, bool) { cursor := 0 cells := []cell{} adds := 0 for _, o := range body { switch o.code { case opMove: cursor += o.arg case opAdd: adds++ // Carry the delta as a signed value; it is normalized // back into 0..255 when it is emitted. d := o.arg if d > 128 { d -= 256 } cells = addCell(cells, cursor, d) default: return nil, false } } if cursor != 0 { // Net movement: only a pure scan qualifies, and only when the // step is not zero (which normMove already guarantees here). if adds != 0 { return nil, false } return []op{{code: opScan, arg: cursor}}, true } // Balanced. The current cell must fall by exactly one per iteration, // or the loop is not a counted multiply-add. if delta(cells, 0) != -1 { return nil, false } out := []op{} for _, c := range cells { if c.off == 0 || c.delta == 0 { continue } out = append(out, op{code: opAddMul, arg: normAdd(c.delta), off: c.off}) } out = append(out, op{code: opSet, arg: 0}) return out, true } // addCell accumulates a delta at an offset, keeping cells sorted by offset so // that the op stream a program compiles to is identical on every node. func addCell(cells []cell, off, d int) []cell { for i := range cells { if cells[i].off == off { cells[i].delta += d return cells } if cells[i].off > off { cells = append(cells, cell{}) copy(cells[i+1:], cells[i:]) cells[i] = cell{off: off, delta: d} return cells } } return append(cells, cell{off: off, delta: d}) } func delta(cells []cell, off int) int { for _, c := range cells { if c.off == off { return c.delta } } return 0 }
  10. #10gnomod.toml
  11. #11module = "gno.land/p/moul/x/vm/bf/v0" gno = "0.9"
  12. #12ladder_test.gno
  13. #13package bf import ( "testing" "gno.land/p/moul/x/vm/vmkit/v0" "gno.land/p/nt/uassert/v0" ) // heavy is the benchmark program: 200 increments, then an outer loop that on // each of its 200 iterations moves right, adds 200, clears that cell with the // "[-]" idiom, and moves back. // // It writes nothing, so a measurement of it is a measurement of the dispatch // loop and not of the output path. var heavy = rep("+", 200) + "[" + "-" + ">" + rep("+", 200) + "[-]" + "<" + "]" // GuestOps and NaiveDispatches are the two counts for [heavy], and the // difference between them is the reason the ladder needs both named. // // A guest instruction is one operator the language executes, counted the way // any jump-table interpreter counts it. The naive interpreter dispatches more // often than that for the same program: its "]" handler scans back to the // matching "[" and lands on it, so "[" is re-evaluated on every iteration of // every loop. On heavy that is 1.33 dispatches per guest instruction. // // Rung 1 removes exactly that excess, so a ladder that used the naive // dispatch count as its denominator would be crediting rung 1 twice. Both // numbers come from an independent simulator, not from this package, so the // denominator is not derived from the thing being measured. const ( GuestOps = 121201 NaiveDispatches = 161200 ) func rep(s string, n int) string { out := "" for i := 0; i < n; i++ { out += s } return out } // nullHost is a Host that costs as close to nothing as a Host can, so the // ladder measures the dispatch loop rather than the storage or output path. type nullHost struct{} func (nullHost) Caller() address { return address("") } func (nullHost) Origin() address { return address("") } func (nullHost) Now() int64 { return 0 } func (nullHost) Height() int64 { return 0 } func (nullHost) Get(key []byte) []byte { return nil } func (nullHost) Set(key, val []byte) {} func (nullHost) Input() []byte { return nil } func (nullHost) Output(p []byte) {} func (nullHost) Emit(typ string, kv ...string) {} func (nullHost) Send(to address, amount int64) error { return vmkit.ErrNotGranted } func (nullHost) Log(msg string) {} // runHeavy compiles heavy at lvl and runs it on the shipped machine, // returning how many machine ops it executed. The count is the fuel the // machine charged itself, so the ladder's "machine ops" column is measured by // the machine and not counted by hand. func runHeavy(t *testing.T, lvl Level) int64 { t.Helper() prog, err := Compile(heavy, lvl) uassert.NoError(t, err) if prog == nil { return 0 } m := NewMachine(prog) used, status := m.Step(nullHost{}, vmkit.Unmetered) uassert.Equal(t, "halted", status.String()) return used } // The rungs. One test each, so `gno test -print-runtime-metrics` prints one // cycle count per rung and the README's table is a transcription rather than // an estimate. They assert that the run completed and how many machine ops it // took: what is being recorded is the metric line. func TestRung0Naive(t *testing.T) { uassert.Equal(t, "", Execute(heavy)) } func TestRung0Naive4x(t *testing.T) { for i := 0; i < 4; i++ { Execute(heavy) } } func TestRung1Jumps(t *testing.T) { uassert.Equal(t, int64(GuestOps+1), runHeavy(t, LevelJumps)) } func TestRung2Fuse(t *testing.T) { uassert.True(t, runHeavy(t, LevelFuse) < GuestOps) } func TestRung3Idioms(t *testing.T) { uassert.True(t, runHeavy(t, LevelIdioms) < GuestOps) } func TestRung3Idioms4x(t *testing.T) { for i := 0; i < 4; i++ { runHeavy(t, LevelIdioms) } } func TestRung5Metered(t *testing.T) { // Same program, same rung, but every op goes through the fuel meter's // budget branch instead of its unmetered one. The delta against // TestRung3Idioms is the price of being a machine that can be stopped. prog, err := Compile(heavy, LevelIdioms) uassert.NoError(t, err) m := NewMachine(prog) _, status := m.Step(nullHost{}, 1<<40) uassert.Equal(t, "halted", status.String()) } // TestLadderOpCounts records the compile-time half of the ladder: how many // machine ops each rung executes for the same 121,201 guest instructions. // These are the numbers the README's table quotes, asserted here so the table // cannot silently go stale. func TestLadderOpCounts(t *testing.T) { jumps := runHeavy(t, LevelJumps) fuse := runHeavy(t, LevelFuse) idioms := runHeavy(t, LevelIdioms) // Rung 1 executes exactly one machine op per guest instruction, plus // the halt: the independent simulator and this machine agree to the op. uassert.Equal(t, int64(121202), jumps) // Fusing runs of + and > collapses the two 200-long runs. uassert.Equal(t, int64(81203), fuse) // Recognizing "[-]" turns the inner clear loop into a single op. uassert.Equal(t, int64(1203), idioms) uassert.True(t, fuse < jumps) uassert.True(t, idioms < fuse) }
  14. #14machine.gno
  15. #15package bf import ( "errors" "gno.land/p/moul/x/vm/vmkit/v0" ) // VMName is the identifier this machine registers under in a // [vmkit.Instance]. const VMName = "bf" // snapMagic and snapVersion tag a snapshot so a machine refuses bytes that // were not written by this machine at this version, instead of decoding them // into a plausible-looking wrong state. const ( snapMagic uint32 = 0x62660001 // "bf" 0001 snapVersion byte = 1 ) // ErrNoProgram is returned when a [Machine] is stepped without a program. var ErrNoProgram = errors.New("bf: machine has no program") // Machine is a compiled Brainfuck program, mid-execution: a // [vmkit.Machine] with a fixed tape, a fuel-metered step loop, input and // output through the host, and a snapshot small enough that pausing is // cheaper than starting over. // // The tape is a fixed-size array rather than a slice: that is rung 4 of the // ladder, and the README reports what it actually bought. type Machine struct { prog *Program tape [TapeSize]byte ptr int pc int inPos int // read cursor into Host.Input, carried across slices status vmkit.Status trap string // hi is the highest tape index written so far, so a snapshot can stop // there instead of carrying 30,000 bytes of zeros. hi int } // NewMachine returns a machine ready to run prog from the start. func NewMachine(prog *Program) *Machine { return &Machine{prog: prog, status: vmkit.Running} } // Load compiles src at [Default] and returns a machine for it. func Load(src string) (*Machine, error) { prog, err := CompileDefault(src) if err != nil { return nil, err } return NewMachine(prog), nil } // Program returns the program the machine is running. func (m *Machine) Program() *Program { return m.prog } // Status returns the machine's current status. func (m *Machine) Status() vmkit.Status { return m.status } // Trap returns the reason the machine trapped, or "". It makes Machine a // [vmkit.Trapper]. func (m *Machine) Trap() string { return m.trap } // Pointer returns the tape pointer. func (m *Machine) Pointer() int { return m.ptr } // PC returns the index of the next op to execute. func (m *Machine) PC() int { return m.pc } // Cell returns the tape byte at i, wrapped into range. func (m *Machine) Cell(i int) byte { return m.tape[wrap(i)] } // Touched returns how many tape cells the program has reached, which is the // length a snapshot has to carry. func (m *Machine) Touched() int { return m.hi + 1 } // Step runs until the program halts, traps, or spends `fuel` units, charging // one unit per op executed and one per cell a scan walks over. Pass // [vmkit.Unmetered] to run to completion. // // Fuel is charged before the op runs, so a machine that stops for lack of // fuel has not half-executed anything: it is exactly at the op it could not // pay for, and resuming re-executes that op and nothing else. // // Three things in here are written the way they are because the alternative // was measured and lost. The README has the table. // // - The fuel counter is two locals, not a [vmkit.Meter]. Calling // Meter.Charge once per instruction costs 86% on top of the whole // dispatch loop and triples the allocation count. Meter stays the type // at the API boundary, where it is called once; it is not a hot-path // type, and no guest VM should treat it as one. // - The program counter and the tape pointer are locals, written back once // by the deferred closure. Reaching through m. for them on every // instruction costs 16%. // - The pc range check happens once, before the loop, not per op. Every // jump target is produced by [Compile] and every restored pc is // validated by [Machine.Restore], so per-op checking buys nothing. func (m *Machine) Step(h vmkit.Host, fuel int64) (int64, vmkit.Status) { if m.prog == nil { m.status, m.trap = vmkit.Trapped, "no program" return 0, m.status } if m.status != vmkit.Running { return 0, m.status } ops := m.prog.ops pc, ptr, hi := m.pc, m.ptr, m.hi // One write-back, on every return path, including a panic. defer func() { m.pc, m.ptr, m.hi = pc, ptr, hi }() if pc < 0 || pc >= len(ops) { m.status, m.trap = vmkit.Trapped, "pc out of range" return 0, m.status } // budget < 0 is vmkit.Unmetered. budget := fuel if budget < 0 { budget = vmkit.Unmetered } var used int64 for { if budget != vmkit.Unmetered && used >= budget { return used, vmkit.Running } used++ switch ops[pc].code { case opAdd: m.tape[ptr] += byte(ops[pc].arg) case opMove: ptr = wrap(ptr + ops[pc].arg) if ptr > hi { hi = ptr } case opSet: m.tape[ptr] = byte(ops[pc].arg) case opAddMul: if v := m.tape[ptr]; v != 0 { t := wrap(ptr + ops[pc].off) m.tape[t] += v * byte(ops[pc].arg) if t > hi { hi = t } } case opScan: // A scan is charged per cell, so it cannot be a way to // buy unbounded work for one unit of fuel. Stopping // mid-scan is safe: the pointer has moved, the pc has // not, so resuming continues the same walk. for m.tape[ptr] != 0 { if budget != vmkit.Unmetered && used >= budget { return used, vmkit.Running } used++ ptr = wrap(ptr + ops[pc].arg) if ptr > hi { hi = ptr } } case opOut: h.Output([]byte{m.tape[ptr]}) case opIn: in := h.Input() if m.inPos < len(in) { m.tape[ptr] = in[m.inPos] m.inPos++ } else { // End of input is a zero cell, the most common // of the three conventions the language never // settled. m.tape[ptr] = 0 } case opJmpZ: if m.tape[ptr] == 0 { pc = ops[pc].arg } case opJmpNZ: if m.tape[ptr] != 0 { pc = ops[pc].arg } case opHalt: m.status = vmkit.Halted return used, m.status default: m.status, m.trap = vmkit.Trapped, "unknown opcode" return used, m.status } pc++ } } // Snapshot serializes the machine. The tape is truncated at the highest cell // the program has reached, which is what makes pausing affordable: a hello // world that touched six cells snapshots six bytes of tape, not 30,000. func (m *Machine) Snapshot() []byte { w := vmkit.NewWriter(m.hi + 48) w.Uint32(snapMagic) w.Byte(snapVersion) w.Int(int64(m.pc)) w.Int(int64(m.ptr)) w.Int(int64(m.inPos)) w.Byte(byte(m.status)) w.String(m.trap) n := m.hi + 1 if n > TapeSize { n = TapeSize } w.Bytes(m.tape[:n]) return w.Out() } // Restore loads a snapshot into a machine that already carries the program // the snapshot was taken from. The program is not in the snapshot: a realm // stores it once, beside the instance, instead of once per pause. func (m *Machine) Restore(b []byte) error { r := vmkit.NewReader(b) if r.Uint32() != snapMagic { return vmkit.ErrBadSnapshot } if r.Byte() != snapVersion { return vmkit.ErrBadSnapshot } pc := int(r.Int()) ptr := int(r.Int()) inPos := int(r.Int()) status := vmkit.Status(r.Byte()) trap := r.String() tape := r.Bytes() if err := r.Err(); err != nil { return err } if len(tape) > TapeSize || ptr < 0 || ptr >= TapeSize || inPos < 0 { return vmkit.ErrBadSnapshot } if m.prog != nil && (pc < 0 || pc > len(m.prog.ops)) { return vmkit.ErrBadSnapshot } m.pc, m.ptr, m.inPos = pc, ptr, inPos m.status, m.trap = status, trap // Zero only what this machine could have written: hi bounds every // write it has made, and a 30,000 element composite literal is not // something to put in a restore path. for i := 0; i <= m.hi && i < TapeSize; i++ { m.tape[i] = 0 } copy(m.tape[:], tape) m.hi = len(tape) - 1 if m.hi < 0 { m.hi = 0 } return nil } // Run compiles src at [Default] and runs it to completion against h, with no // fuel bound. It is the one-shot path: convenient for a test or a small // program, and exactly the thing a realm should not do with untrusted input. func Run(src string, h vmkit.Host) (vmkit.Status, error) { m, err := Load(src) if err != nil { return vmkit.Trapped, err } _, status := m.Step(h, vmkit.Unmetered) return status, nil } // wrap folds a tape index into range, the way the naive interpreter does one // step at a time. func wrap(i int) int { i %= TapeSize if i < 0 { i += TapeSize } return i }
  16. #16machine_test.gno
  17. #17package bf import ( "testing" "gno.land/p/moul/x/vm/vmkit/v0" "gno.land/p/nt/uassert/v0" ) // oneShot runs src to completion and returns what it wrote. func oneShot(t *testing.T, src string) string { t.Helper() h := vmkit.NewTestHost() status, err := Run(src, h) uassert.NoError(t, err) uassert.Equal(t, "halted", status.String()) return h.OutString() } // TestSlicedRunEqualsOneShot is the property that makes a guest program a // contract rather than a function call: chopping the execution into fuel // slices, snapshotting between each, must produce exactly the single run. func TestSlicedRunEqualsOneShot(t *testing.T) { for _, slice := range []int64{1, 2, 7, 64} { prog, err := CompileDefault(hello) uassert.NoError(t, err) h := vmkit.NewTestHost() snap := []byte(nil) status := vmkit.Running slices := 0 for status == vmkit.Running && slices < 10000 { m := NewMachine(prog) // a realm holds bytes, not a machine if snap != nil { uassert.NoError(t, m.Restore(snap)) } _, status = m.Step(h, slice) snap = m.Snapshot() slices++ } uassert.Equal(t, "halted", status.String()) uassert.Equal(t, "Hello World", h.OutString()) } } func TestSlicedRunAgreesAcrossTheCorpus(t *testing.T) { for _, tc := range corpus { prog, err := CompileDefault(tc.src) uassert.NoError(t, err) if prog == nil { continue } h := vmkit.NewTestHost() snap := []byte(nil) status := vmkit.Running n := 0 for status == vmkit.Running && n < 20000 { m := NewMachine(prog) if snap != nil { uassert.NoError(t, m.Restore(snap)) } _, status = m.Step(h, 3) snap = m.Snapshot() n++ } if h.OutString() != tc.want { t.Errorf("%s sliced: got %q, want %q", tc.name, h.OutString(), tc.want) } } } func TestFuelStopsTheMachine(t *testing.T) { prog, err := CompileDefault(hello) uassert.NoError(t, err) m := NewMachine(prog) h := vmkit.NewTestHost() used, status := m.Step(h, 5) uassert.Equal(t, int64(5), used) uassert.Equal(t, "running", status.String()) uassert.Equal(t, "", h.OutString()) // not far enough to print anything } // TestScanIsChargedPerCell pins the thing that would otherwise be a way to // buy unbounded work for one unit: a scan walks one cell per unit of fuel, // and stopping mid-scan resumes the same walk. func TestScanIsChargedPerCell(t *testing.T) { // 20 non-zero cells, then scan left back to the zero at cell 0. src := ">+>+>+>+>+>+>+>+>+>+>+>+>+>+>+>+>+>+>+>+[<]" prog, err := CompileDefault(src) uassert.NoError(t, err) m := NewMachine(prog) h := vmkit.NewTestHost() used, status := m.Step(h, 45) uassert.Equal(t, int64(45), used) uassert.Equal(t, "running", status.String()) uassert.True(t, m.Pointer() > 0) // stopped part way down the scan // Resuming finishes the same walk and lands on the zero cell. _, status = m.Step(h, vmkit.Unmetered) uassert.Equal(t, "halted", status.String()) uassert.Equal(t, 0, m.Pointer()) } func TestSnapshotRoundTrip(t *testing.T) { prog, err := CompileDefault(hello) uassert.NoError(t, err) a := NewMachine(prog) h := vmkit.NewTestHost() a.Step(h, 40) b := NewMachine(prog) uassert.NoError(t, b.Restore(a.Snapshot())) uassert.Equal(t, a.PC(), b.PC()) uassert.Equal(t, a.Pointer(), b.Pointer()) uassert.Equal(t, a.Touched(), b.Touched()) for i := 0; i < a.Touched(); i++ { uassert.Equal(t, uint64(a.Cell(i)), uint64(b.Cell(i))) } uassert.Equal(t, string(a.Snapshot()), string(b.Snapshot())) } // TestSnapshotIsTrimmed is the continuation kill criterion in test form: if a // pause carried the whole 30,000 byte tape, resuming would cost more than // re-running and continuations would be theater. func TestSnapshotIsTrimmed(t *testing.T) { m, err := Load(hello) uassert.NoError(t, err) h := vmkit.NewTestHost() m.Step(h, vmkit.Unmetered) uassert.Equal(t, 5, m.Touched()) // hello world uses five cells snap := m.Snapshot() // 4 magic + 1 version + 3x8 zig-zag ints + 1 status + 4 empty trap // + 4 length + 5 tape. The README quotes this number. uassert.Equal(t, 43, len(snap)) uassert.True(t, len(snap) < TapeSize/100) } func TestRestoreRejectsJunk(t *testing.T) { prog, err := CompileDefault(hello) uassert.NoError(t, err) good := NewMachine(prog) good.Step(vmkit.NewTestHost(), 20) snap := good.Snapshot() cases := []struct { name string b []byte }{ {"empty", []byte{}}, {"truncated", snap[:len(snap)-3]}, {"bad magic", append([]byte{0, 0, 0, 0}, snap[4:]...)}, } for _, tc := range cases { m := NewMachine(prog) if err := m.Restore(tc.b); err == nil { t.Errorf("Restore(%s) should have failed", tc.name) } } // A snapshot with a wrong version byte is refused rather than decoded // into a plausible-looking wrong state. bad := make([]byte, len(snap)) copy(bad, snap) bad[4] = 99 m := NewMachine(prog) uassert.ErrorIs(t, m.Restore(bad), vmkit.ErrBadSnapshot) } func TestRestoreRejectsAnOutOfRangePC(t *testing.T) { small, err := CompileDefault("+") uassert.NoError(t, err) big, err := CompileDefault(hello) uassert.NoError(t, err) m := NewMachine(big) m.Step(vmkit.NewTestHost(), 30) snap := m.Snapshot() // The same snapshot against a two-op program points past the end. other := NewMachine(small) uassert.ErrorIs(t, other.Restore(snap), vmkit.ErrBadSnapshot) } func TestHaltedMachineStaysHalted(t *testing.T) { m, err := Load("+++.") uassert.NoError(t, err) h := vmkit.NewTestHost() m.Step(h, vmkit.Unmetered) uassert.Equal(t, "halted", m.Status().String()) used, status := m.Step(h, vmkit.Unmetered) uassert.Equal(t, int64(0), used) uassert.Equal(t, "halted", status.String()) uassert.Equal(t, "\x03", h.OutString()) // still only one write } func TestLoadRejectsBadSource(t *testing.T) { _, err := Load("[[") uassert.Error(t, err) status, err := Run("]", vmkit.NewTestHost()) uassert.Error(t, err) uassert.Equal(t, "trapped", status.String()) } // TestInstanceIntegration wires the machine through vmkit's realm-facing // plumbing, which is the shape the demo realm uses. func TestInstanceIntegration(t *testing.T) { prog, err := CompileDefault(hello) uassert.NoError(t, err) inst := vmkit.NewInstance("001", address("g1x"), VMName, []byte(hello), vmkit.Unmetered) h := vmkit.NewTestHost() for i := 0; i < 200 && inst.Status == vmkit.Running; i++ { uassert.NoError(t, inst.Run(NewMachine(prog), h, 8)) } uassert.Equal(t, "halted", inst.Status.String()) uassert.Equal(t, "Hello World", h.OutString()) uassert.True(t, inst.Slices > 1) uassert.True(t, inst.FuelUsed > 0) } func TestInstanceHonoursItsBudget(t *testing.T) { prog, err := CompileDefault(hello) uassert.NoError(t, err) inst := vmkit.NewInstance("002", address("g1x"), VMName, []byte(hello), 10) h := vmkit.NewTestHost() uassert.NoError(t, inst.Run(NewMachine(prog), h, vmkit.Unmetered)) uassert.Equal(t, "out of fuel", inst.Status.String()) uassert.Equal(t, int64(10), inst.FuelUsed) uassert.ErrorIs(t, inst.Run(NewMachine(prog), h, vmkit.Unmetered), vmkit.ErrBudgetExhausted) }
  18. #18micro_test.gno
  19. #19package bf import ( "testing" "gno.land/p/moul/x/vm/vmkit/v0" ) // The microarchitecture matrix. // // The rungs in ladder_test.gno are the classic interpreter optimizations: // fewer instructions for the same program. These are the other axis, and on // the GnoVM they matter as much: the same instruction stream, executed by // loops that differ from each other by exactly one line. // // They are deliberately simplified (no host, no snapshot, no scan) so that // the one line is the only difference. They are comparable to each other and // not to the shipped machine, which does more. What the shipped machine took // from them is documented on [Machine.Step]. type vm struct { prog *Program tape [TapeSize]byte ptr int pc int } // V0: cursors as struct fields. No meter, no bounds check, no defer. func (m *vm) runFields() int64 { ops := m.prog.ops var used int64 for { used++ switch ops[m.pc].code { case opAdd: m.tape[m.ptr] += byte(ops[m.pc].arg) case opMove: m.ptr = wrap(m.ptr + ops[m.pc].arg) case opSet: m.tape[m.ptr] = byte(ops[m.pc].arg) case opAddMul: if v := m.tape[m.ptr]; v != 0 { m.tape[wrap(m.ptr+ops[m.pc].off)] += v * byte(ops[m.pc].arg) } case opJmpZ: if m.tape[m.ptr] == 0 { m.pc = ops[m.pc].arg } case opJmpNZ: if m.tape[m.ptr] != 0 { m.pc = ops[m.pc].arg } case opHalt: return used } m.pc++ } } // V1: V0 with the two cursors hoisted into locals. Only change. func (m *vm) runLocals() int64 { ops := m.prog.ops pc, ptr := m.pc, m.ptr var used int64 for { used++ switch ops[pc].code { case opAdd: m.tape[ptr] += byte(ops[pc].arg) case opMove: ptr = wrap(ptr + ops[pc].arg) case opSet: m.tape[ptr] = byte(ops[pc].arg) case opAddMul: if v := m.tape[ptr]; v != 0 { m.tape[wrap(ptr+ops[pc].off)] += v * byte(ops[pc].arg) } case opJmpZ: if m.tape[ptr] == 0 { pc = ops[pc].arg } case opJmpNZ: if m.tape[ptr] != 0 { pc = ops[pc].arg } case opHalt: m.pc, m.ptr = pc, ptr return used } pc++ } } // V2: V1 plus a vmkit.Meter charged once per op. Only change. func (m *vm) runLocalsMeter(fuel int64) int64 { ops := m.prog.ops meter := vmkit.NewMeter(fuel) pc, ptr := m.pc, m.ptr for { if !meter.Charge(1) { m.pc, m.ptr = pc, ptr return meter.Used() } switch ops[pc].code { case opAdd: m.tape[ptr] += byte(ops[pc].arg) case opMove: ptr = wrap(ptr + ops[pc].arg) case opSet: m.tape[ptr] = byte(ops[pc].arg) case opAddMul: if v := m.tape[ptr]; v != 0 { m.tape[wrap(ptr+ops[pc].off)] += v * byte(ops[pc].arg) } case opJmpZ: if m.tape[ptr] == 0 { pc = ops[pc].arg } case opJmpNZ: if m.tape[ptr] != 0 { pc = ops[pc].arg } case opHalt: m.pc, m.ptr = pc, ptr return meter.Used() } pc++ } } // V3: V1 plus an inline fuel counter instead of the Meter object. Isolates // the cost of the method call from the cost of metering at all. func (m *vm) runLocalsInlineFuel(fuel int64) int64 { ops := m.prog.ops pc, ptr := m.pc, m.ptr var used int64 for { if used >= fuel { m.pc, m.ptr = pc, ptr return used } used++ switch ops[pc].code { case opAdd: m.tape[ptr] += byte(ops[pc].arg) case opMove: ptr = wrap(ptr + ops[pc].arg) case opSet: m.tape[ptr] = byte(ops[pc].arg) case opAddMul: if v := m.tape[ptr]; v != 0 { m.tape[wrap(ptr+ops[pc].off)] += v * byte(ops[pc].arg) } case opJmpZ: if m.tape[ptr] == 0 { pc = ops[pc].arg } case opJmpNZ: if m.tape[ptr] != 0 { pc = ops[pc].arg } case opHalt: m.pc, m.ptr = pc, ptr return used } pc++ } } // V4: V3 with the tape as a local slice instead of an array field. Only // change, so this settles the "a fixed array removes a bounds check" claim. func (m *vm) runLocalsInlineFuelSliceTape(fuel int64) int64 { ops := m.prog.ops tape := make([]byte, TapeSize) pc, ptr := m.pc, m.ptr var used int64 for { if used >= fuel { m.pc, m.ptr = pc, ptr return used } used++ switch ops[pc].code { case opAdd: tape[ptr] += byte(ops[pc].arg) case opMove: ptr = wrap(ptr + ops[pc].arg) case opSet: tape[ptr] = byte(ops[pc].arg) case opAddMul: if v := tape[ptr]; v != 0 { tape[wrap(ptr+ops[pc].off)] += v * byte(ops[pc].arg) } case opJmpZ: if tape[ptr] == 0 { pc = ops[pc].arg } case opJmpNZ: if tape[ptr] != 0 { pc = ops[pc].arg } case opHalt: m.pc, m.ptr = pc, ptr return used } pc++ } } func microProg(lvl Level) *Program { p, err := Compile(heavy, lvl) if err != nil { panic(err) } return p } // Rung 1's instruction stream, 121,202 ops, under each loop. func TestMicroJumpsCursorsAsFields(t *testing.T) { (&vm{prog: microProg(LevelJumps)}).runFields() } func TestMicroJumpsCursorsInLocals(t *testing.T) { (&vm{prog: microProg(LevelJumps)}).runLocals() } func TestMicroJumpsMeterPerOp(t *testing.T) { (&vm{prog: microProg(LevelJumps)}).runLocalsMeter(vmkit.Unmetered) } func TestMicroJumpsInlineFuel(t *testing.T) { (&vm{prog: microProg(LevelJumps)}).runLocalsInlineFuel(1 << 40) } // Rung 3's instruction stream, 1,203 ops, under the same loops. func TestMicroJumpsSliceTape(t *testing.T) { (&vm{prog: microProg(LevelJumps)}).runLocalsInlineFuelSliceTape(1 << 40) } func TestMicroIdiomsCursorsAsFields(t *testing.T) { (&vm{prog: microProg(LevelIdioms)}).runFields() } func TestMicroIdiomsCursorsInLocals(t *testing.T) { (&vm{prog: microProg(LevelIdioms)}).runLocals() } func TestMicroIdiomsMeterPerOp(t *testing.T) { (&vm{prog: microProg(LevelIdioms)}).runLocalsMeter(vmkit.Unmetered) } func TestMicroIdiomsInlineFuel(t *testing.T) { (&vm{prog: microProg(LevelIdioms)}).runLocalsInlineFuel(1 << 40) } func TestMicroIdiomsSliceTape(t *testing.T) { (&vm{prog: microProg(LevelIdioms)}).runLocalsInlineFuelSliceTape(1 << 40) }
#6AddPackagegno.land/r/moul/config/v124 arguments
Attached funds
35000000ugnot

Arguments · 24

  1. #1config
  2. #2README.md
  3. #3# `gno.land/r/moul/config` moul's settings, plus the manager list that decides who may change them. One realm the others read, so a value that several contracts share lives in one place and moving it is a transaction rather than a redeploy of each. ## Settings ```go func Set(cur realm, key, value string) // manager only; aborts otherwise func Unset(cur realm, key string) // manager only; aborts on a missing key func Get(key string) string // "" when unset func GetOr(key, fallback string) string // what a consumer should call func Has(key string) bool func Keys() []string func Size() int func SettingsRevision() int func Manifest() string // the whole config in one qeval read ``` A key is 1 to 64 bytes of `[a-z0-9._-]`, dot-namespaced by convention (`mygnoscan.url`). A value is capped at 1024 bytes: this realm holds settings, not content, and storage is paid for and never refunded. Every write emits a `ConfigSet` / `ConfigUnset` event, so the history of a setting is readable from an indexer without a call per key. **The generic API is the point.** A new setting is a new key, which is a transaction. Only a change to the *shape* of this realm needs a version bump, and a bump is expensive here: the path changes, so every realm importing the old one keeps reading the old one until it is itself redeployed. Reach for a key before reaching for a typed accessor. ### Why the settings functions abort instead of returning an error The manager functions below return `error`. A returned error from a realm call leaves the transaction **successful**: the caller sees a green receipt and walks away believing the write landed, while every realm reading that key keeps serving the old value. For a configuration realm that is the one outcome worth ruling out, so `Set` and `Unset` abort. The manager functions predate that reasoning and are frozen on chain at `v0`; the new surface does not inherit it. ## The notice blocks Two strings a realm drops at the top and the bottom of its `Render`, empty by default, so a warning, a changelog line or a bit of news can go on every realm at once or on one of them. ```go func Render(path string) string { return config.TopBlock() + body + config.BottomBlock() } ``` Set them with the ordinary `Set`, which is what keeps this realm's surface from growing a function per idea: ```sh Set block.top "> Chain migration on Tuesday." # every realm Set block.top@r/moul/gns "> v2 shipped, see the changelog" # this one only Unset block.top # back to silence ``` `TopBlock` shows **three** things when they exist, in this order, separated by blank lines: the pause banner, the global message, then this realm's own. Both messages, not one overriding the other: a chain-wide warning and a per-realm changelog are different messages, and dropping either because the other exists is the surprising behaviour. When there is nothing to say it returns `""`, so a realm that concatenates it unconditionally renders byte-for-byte what it rendered before. ## Pausing ```sh Set pause "paused: incident, back in an hour" # everything Set pause@r/moul/gns "readonly" # one realm Unset pause # running again ``` ```go func Post(cur realm, body string) { config.AssertWritable() // aborts while ReadOnly or Paused ... } ``` Three levels (`running`, `readonly`, `paused`, each optionally `: <reason>`), because taking a realm fully offline hides the thing people came to read while most incidents only need the writes stopped. The levels, the fail-closed parse and the precedence rule live in [`p/moul/pausable`](../../../p/moul/pausable); this realm is the storage and the wiring. Two properties worth knowing: - **A global pause cannot be defeated by a per-realm setting.** The two combine with `pausable.Strictest`, so a stale `pause@r/moul/foo` of `running` does not re-open that realm during a global halt. Exempting one realm is therefore not expressible: clear the global and set the others. - **A pause value is validated on write.** `pausable.MustParse` fails closed, so an unvalidated typo would take every realm offline at the next render. `Set` refuses anything the reader could not understand, which turns that into a failed transaction the writer sees immediately. `TopBlock` already carries the pause banner, so guarding writes with `AssertWritable` is enough to also explain the refusal on the page. ## Zero-argument or explicit Every helper comes in two forms: `TopBlock()` names the realm calling in, `TopBlockFor(pkgPath)` names one you pass. The zero-argument form works because these are plain reads with **no `cur realm` parameter**, so gno runs them borrowed, opens no realm frame, and `unsafe.CurrentRealm()` reports the caller rather than this realm. Measured in the test harness on 2026-09-22 and pinned by a test. Use the explicit form from a crossing function, where there is a realm frame and the answer would be that function's realm, or when asking about a realm other than your own. **Do not add a `cur realm` parameter to any of the zero-argument helpers**: it would silently start answering `gno.land/r/moul/config` for every caller. ## Versions: v2 relays to v1, never the other way This realm is public, so its path is permanent and a new API means a new version at a new path. Left alone that fragments everything: a realm importing v1 and a realm importing v2 would read two different member lists and two different pause switches. Delegation fixes it, and it only runs one way. A version can import what already existed when it was written, never what does not exist yet. So **the state stays in the oldest version that has it** and every later version is a thin relay: ``` v3 -> v2 -> v1 (the root: settings, pause, managers, proxies) ``` A realm importing v1, v2 or v3 reads the same state whichever door it came through. `v0` cannot take part: it is already on chain and has no settings store, so it keeps answering for its own member list and nothing else. Writing v2: it holds no state, forwards reads directly, and forwards writes with the address it was called by. ```go func Set(cur realm, key, value string) { config.SetAs(cross(cur), cur.Previous().Address(), key, value) } func Get(key string) string { return config.Get(key) } ``` Then once, from a manager: `AllowProxy gno.land/r/moul/config/v2`. **A proxy is trusted to say who is asking, not to decide whether they may.** `SetAs` still puts the principal through the `Authorizer`, so the member list stays the single answer to "who may change config" for every version at once, and adding a manager works through v2 and v3 with no further deploys. It is not a boundary against the proxy's own code, and does not need to be: the same person deploys both, registration is deliberate, and `RevokeProxy` is immediate. What it buys is that v2 never carries a copy of the member list, so the two can never disagree. `AllowProxy` only accepts `gno.land/r/moul/config/vN`, so a fat-fingered path cannot become a standing write grant to an unrelated realm. ## The explorer accessors ```go func MygnoscanURL() string // the configured base, or the package default func Scanner() mygnoscan.Scanner // a configured link builder func MygnoscanFor(pkgPath string) string // a realm's explorer page func MygnoscanFooter(pkgPath string) string // the markdown line for a Render ``` This is the worked example of the whole idea. A realm renders `config.MygnoscanFooter("gno.land/r/moul/mything")` in its footer; moul points every one of them at a different explorer with: ```sh gnokey maketx call -pkgpath gno.land/r/moul/config/v1 -func Set -args mygnoscan.url -args https://scan.example.com -gas-fee 1000000ugnot -gas-wanted 20000000 -broadcast -chainid gnoland-1 -remote https://rpc.gno.land:443 moul ``` Keys: `mygnoscan.url` (base URL) and `mygnoscan.network` (overrides the `?network=` id, for an instance that names the chain differently; normally unset, and the chain-id decides). Unset, readers fall back to [`p/moul/mygnoscan`](../../../p/moul/mygnoscan)'s `DefaultBase`, so a realm importing this one still renders correctly on a chain where nothing was ever configured. `MygnoscanFor` takes the path explicitly and there is no zero-argument version. `p/moul/mygnoscan` *can* name the calling realm by stack-walking, but the stack seen from inside this realm has this realm on it, so such a helper would confidently return `gno.land/r/moul/config` for every caller. ## Managers ```go func AddManager(cur realm, addr address) error func RemoveManager(cur realm, addr address) error func TransferManagement(cur realm, newAuthority authz.Authority) error func ListManagers(cur realm) []address func HasManager(cur realm, addr address) bool ``` A thin layer over [`p/moul/authz`](../../../p/moul/authz). `init` refuses to run unless the caller is an EOA (`cur.Previous().IsUserCall()`) and seeds the authority with that address; that address is then the only one that can write a setting until it adds another. `AddManager` and `RemoveManager` only work while the authority is a `MemberAuthority`. Once `TransferManagement` hands control to something else (a DAO, a contract), they return an error rather than silently bypassing the new authority, and `Set` follows the new authority from that moment on: it asks the `Authorizer`, not a member list. The realm governs itself. There is no address hardcoded in the settings path, so handing this realm to a DAO hands it the settings too. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/config/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/config/v1/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. #4blocks.gno
  5. #5package config import "strings" // The notice blocks: two strings a realm drops at the top and the bottom of its // Render, empty by default, so moul can put a warning, a changelog line or a // bit of news on every realm at once or on one of them. // // func Render(path string) string { // return config.TopBlock() + body + config.BottomBlock() // } // // Both are set with the ordinary Set, which is what keeps this realm's surface // from growing a function per idea: // // Set block.top "> Chain migration on Tuesday." # every realm // Set block.top@r/moul/gns "> v2 shipped, see the changelog" # this one only const ( // KeyBlockTop is the notice rendered above a realm's content. KeyBlockTop = "block.top" // KeyBlockBottom is the notice rendered below it. KeyBlockBottom = "block.bottom" ) // TopBlock returns the notice for the realm calling in, ready to concatenate // in front of its content. Empty when there is nothing to say, which is the // default and the usual case. // // Three things can appear, in this order, separated by blank lines: // // 1. the pause banner, when this realm or every realm is paused // 2. the global block.top // 3. this realm's own block.top // // The pause banner comes first because it is the one a reader has to see, and // it is included here rather than left to the realm so that guarding writes // with AssertWritable is enough to also explain the refusal on the page. // // Global and scoped are BOTH shown rather than one overriding the other: a // chain-wide warning and a per-realm changelog are different messages, and // dropping either because the other exists is the surprising behaviour. func TopBlock() string { return TopBlockFor(caller()) } // TopBlockFor is TopBlock for a named realm. Use it from a crossing function, // where the caller cannot be read off the stack, or to render another realm's // notice. func TopBlockFor(pkgPath string) string { global, scoped := scopedPair(KeyBlockTop, pkgPath) return joinBlocks(PauseFor(pkgPath).Notice(), global, scoped) } // BottomBlock returns the notice for the realm calling in, ready to // concatenate after its content. // // No pause banner here: one is enough, and the top is where it is read. func BottomBlock() string { return BottomBlockFor(caller()) } // BottomBlockFor is BottomBlock for a named realm. func BottomBlockFor(pkgPath string) string { global, scoped := scopedPair(KeyBlockBottom, pkgPath) return joinBlocks(global, scoped) } // joinBlocks assembles the non-empty parts into one markdown fragment. // // Each part is separated by a blank line, because two markdown blocks with // only a newline between them are one paragraph, and a blockquote followed // directly by text swallows the text into the quote. // // The result ends with a blank line when there is anything at all, so a realm // can write config.TopBlock() + body without a separator of its own and get // the same output whether or not a notice is set. Nothing to say means the // empty string, and the page is byte-for-byte what it was before. func joinBlocks(parts ...string) string { var kept []string for _, p := range parts { if p = strings.TrimSpace(p); p != "" { kept = append(kept, p) } } if len(kept) == 0 { return "" } return strings.Join(kept, "\n\n") + "\n\n" }
  6. #6blocks_test.gno
  7. #7package config import ( "testing" "gno.land/p/nt/uassert/v0" ) const otherRealm = "gno.land/r/moul/gns" func TestKeyGrammar(t *testing.T) { uassert.Equal(t, "pause", KeyFor("pause", "")) uassert.Equal(t, "pause@r/moul/home", KeyFor("pause", "gno.land/r/moul/home")) uassert.Equal(t, "pause@r/moul/home", KeyFor("pause", "r/moul/home")) // A path this cannot scope to falls back to the global key rather than // inventing one nobody can type. uassert.Equal(t, "pause", KeyFor("pause", "not a path")) uassert.Equal(t, "pause", KeyFor("pause", "gno.land/")) name, scope := SplitKey("block.top@r/moul/home") uassert.Equal(t, "block.top", name) uassert.Equal(t, "r/moul/home", scope) name, scope = SplitKey("block.top") uassert.Equal(t, "block.top", name) uassert.Equal(t, "", scope) } func TestScope(t *testing.T) { uassert.Equal(t, "r/moul/home", Scope("gno.land/r/moul/home")) uassert.Equal(t, "r/moul/home", Scope("r/moul/home")) uassert.Equal(t, "p/moul/kit/ui", Scope("gno.land/p/moul/kit/ui")) uassert.Equal(t, "", Scope("")) uassert.Equal(t, "", Scope("gno.land/")) uassert.Equal(t, "", Scope("r/moul/ho me")) uassert.Equal(t, "", Scope("r//moul")) } // TestBlocksDefaultEmpty is the property every realm depends on: a realm that // renders config.TopBlock() unconditionally must be byte-for-byte what it was // before, until moul sets something. func TestBlocksDefaultEmpty(t *testing.T) { resetSettings() uassert.Equal(t, "", TopBlockFor(otherRealm)) uassert.Equal(t, "", BottomBlockFor(otherRealm)) } func TestBlocksGlobalAndScoped(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyBlockTop, "> chain migration on Tuesday") // Global alone reaches every realm, and ends with a blank line so a realm // can concatenate without a separator of its own. uassert.Equal(t, "> chain migration on Tuesday\n\n", TopBlockFor(otherRealm)) uassert.Equal(t, "> chain migration on Tuesday\n\n", TopBlockFor("gno.land/r/moul/home")) testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyFor(KeyBlockTop, otherRealm), "> v2 shipped") // BOTH are shown, global first. A chain-wide warning and a per-realm // changelog are different messages; dropping either would be the // surprising behaviour. uassert.Equal(t, "> chain migration on Tuesday\n\n> v2 shipped\n\n", TopBlockFor(otherRealm)) // And the scoped one reaches only its own realm. uassert.Equal(t, "> chain migration on Tuesday\n\n", TopBlockFor("gno.land/r/moul/home")) } func TestBottomBlock(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyBlockBottom, "built by moul") uassert.Equal(t, "built by moul\n\n", BottomBlockFor(otherRealm)) // The pause banner belongs to the top only: one banner is enough, and the // top is where it gets read. testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyPause, "paused") uassert.Equal(t, "built by moul\n\n", BottomBlockFor(otherRealm)) } // TestTopBlockCarriesPause is the integration that makes guarding writes // enough: a realm that calls AssertWritable and renders TopBlock explains the // refusal on its own page without writing a line of banner code. func TestTopBlockCarriesPause(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyPause, "readonly: migrating storage") uassert.Equal(t, "> **Read-only.** This realm is still readable, but not accepting changes. migrating storage\n\n", TopBlockFor(otherRealm)) // And it comes first, before the messages. testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyBlockTop, "> news") uassert.Equal(t, "> **Read-only.** This realm is still readable, but not accepting changes. migrating storage\n\n> news\n\n", TopBlockFor(otherRealm)) } // TestZeroArgFormsNameTheCaller pins the borrow behaviour the whole // zero-argument API rests on: these are plain reads with no `cur realm`, so // gno opens no realm frame and the realm asking is the realm answered about. // // If this ever fails, every zero-argument helper here has silently started // answering for gno.land/r/moul/config instead, and the …For variants are the // only correct ones. func TestZeroArgFormsNameTheCaller(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyFor(KeyBlockTop, otherRealm), "> only for gns") testing.SetRealm(testing.NewCodeRealm(otherRealm)) uassert.Equal(t, "> only for gns\n\n", TopBlock()) uassert.Equal(t, otherRealm, caller()) testing.SetRealm(testing.NewCodeRealm("gno.land/r/moul/home")) uassert.Equal(t, "", TopBlock(), "another realm sees nothing") uassert.Equal(t, "gno.land/r/moul/home", caller()) }
  8. #8config.gno
  9. #9// Package config is the one realm moul's other realms read their settings // from: a key/value store plus the manager list that decides who may write it. // // # Why a realm and not a constant // // A value shared by several contracts, hardcoded in each, is changed by // redeploying each. On mainnet a public realm cannot even be redeployed at its // own path, so "changing it" means a new version and a new path for every // consumer. Here it is a transaction. // // # Bump rarely, and never for a new setting // // This realm IS public and versioned, so its path moves on every bump. That // makes a bump expensive, which is exactly why the settings API is generic: a // new setting is a new KEY, and a key costs one transaction. Only a change to // the shape of this realm (a new function, a changed signature) is worth a // version. // // # When a bump does happen, the state does not move // // A later version imports this one and relays to it rather than holding // anything of its own, so v1, v2 and v3 all read and write the same settings, // the same pause and the same member list. Delegation only runs backwards in // time, which is why the root is the oldest version that has the state and not // the newest. See proxy.gno. // // # What lives here // // settings.gno the key/value store, and the mygnoscan accessors over it // keys.gno the key grammar: a name, optionally scoped to one realm // blocks.gno the notice a realm renders above and below its content // pause.gno the switch a realm checks before it acts // proxy.gno the relay that lets a later version share this state // config.gno the member list, unchanged from v0 // // # Governance // // init seeds the authority with the deploying EOA. Everything that writes goes // through Authorizer, so transferring authority to a DAO transfers the // settings with it: there is no address hardcoded on the write path. package config import ( "errors" "gno.land/p/moul/authz/v0" ) var Authorizer *authz.Authorizer func init(cur realm) { if !cur.Previous().IsUserCall() { panic("r/moul/config must be initialized by an EOA") } Authorizer = authz.NewWithMembers(cur.Previous().Address()) } // AddManager adds a new address to the list of authorized managers. // This only works if the current authority is a MemberAuthority. // The caller must be authorized by the current authority. func AddManager(cur realm, addr address) error { memberAuth, ok := Authorizer.Authority().(*authz.MemberAuthority) if !ok { return errors.New("current authority is not a MemberAuthority, cannot add manager directly") } return memberAuth.AddMember(0, cur, addr) } // RemoveManager removes an address from the list of authorized managers. // This only works if the current authority is a MemberAuthority. // The caller must be authorized by the current authority. func RemoveManager(cur realm, addr address) error { memberAuth, ok := Authorizer.Authority().(*authz.MemberAuthority) if !ok { return errors.New("current authority is not a MemberAuthority, cannot remove manager directly") } return memberAuth.RemoveMember(0, cur, addr) } // TransferManagement transfers the authority to manage keys to a new authority. // The caller must be authorized by the current authority. func TransferManagement(cur realm, newAuthority authz.Authority) error { if newAuthority == nil { return errors.New("new authority cannot be nil") } return Authorizer.Transfer(0, cur, newAuthority) } // ListManagers returns a slice of all managed keys. func ListManagers(cur realm) []address { var keyList []address memberAuth, ok := Authorizer.Authority().(*authz.MemberAuthority) if !ok { return keyList } tree := memberAuth.Tree() if !ok || tree == nil { return keyList // Return empty list if tree is not as expected or nil } tree.Iterate("", "", func(key string, _ any) bool { keyList = append(keyList, address(key)) return false }) return keyList } func HasManager(cur realm, addr address) bool { memberAuth, ok := Authorizer.Authority().(*authz.MemberAuthority) if !ok { return false // Return false if not a MemberAuthority or doesn't exist } // Use the MemberAuthority's specific RemoveMember method, // which internally performs the authorization check. return memberAuth.Has(addr) }
  10. #10config_test.gno
  11. #11package config import ( "chain" "chain/runtime/unsafe" "errors" "testing" "gno.land/p/moul/authz/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( originAddr = testutils.TestAddress("origin") manager1Addr = testutils.TestAddress("manager1") manager2Addr = testutils.TestAddress("manager2") nonManagerAddr = testutils.TestAddress("nonManager") ) // Helper to reset the Authorizer for each test, simulating initialization. func setupTest(cur realm, t *testing.T) { t.Helper() // Set the initial caller context testing.SetRealm(testing.NewUserRealm(originAddr)) // Initialize the Authorizer with the originAddr as the sole member, // simulating the state after NewWithOrigin() in a real deployment. Authorizer = authz.NewWithAuthority(authz.NewMemberAuthority(originAddr)) // Ensure the origin address is the initial manager uassert.True(t, HasManager(cur, originAddr), "origin should be the initial manager") } func TestAddManager(cur realm, t *testing.T) { setupTest(cur, t) // Origin adds manager1 - Should succeed testing.SetRealm(testing.NewUserRealm(originAddr)) err := AddManager(cross(cur), manager1Addr) uassert.NoError(t, err, "origin adding manager1 should succeed") uassert.True(t, HasManager(cur, manager1Addr), "manager1 should now be a manager") // Non-manager tries to add manager2 - Should fail testing.SetRealm(testing.NewUserRealm(nonManagerAddr)) err = AddManager(cross(cur), manager2Addr) uassert.Error(t, err, "non-manager adding manager2 should fail") uassert.False(t, HasManager(cur, manager2Addr), "manager2 should not have been added") // Manager1 adds manager2 - Should succeed testing.SetRealm(testing.NewUserRealm(manager1Addr)) err = AddManager(cross(cur), manager2Addr) uassert.NoError(t, err, "manager1 adding manager2 should succeed") uassert.True(t, HasManager(cur, manager2Addr), "manager2 should now be a manager") // Transfer authority away from MemberAuthority testing.SetRealm(testing.NewUserRealm(originAddr)) // Origin transfers err = TransferManagement(cross(cur), authz.NewAutoAcceptAuthority()) uassert.NoError(t, err, "transferring authority should succeed") // Try adding after transfer - Should fail (wrong authority type) testing.SetRealm(testing.NewUserRealm(manager1Addr)) err = AddManager(cross(cur), nonManagerAddr) // Try adding someone new uassert.ErrorContains(t, err, "current authority is not a MemberAuthority", "adding manager should fail after transfer") } func TestRemoveManager(cur realm, t *testing.T) { setupTest(cur, t) // Add manager1 first testing.SetRealm(testing.NewUserRealm(originAddr)) err := AddManager(cross(cur), manager1Addr) uassert.NoError(t, err, "setup: failed to add manager1") uassert.True(t, HasManager(cur, manager1Addr), "setup: manager1 should be added") // Non-manager tries to remove manager1 - Should fail testing.SetRealm(testing.NewUserRealm(nonManagerAddr)) err = RemoveManager(cross(cur), manager1Addr) uassert.Error(t, err, "non-manager removing manager1 should fail") uassert.True(t, HasManager(cur, manager1Addr), "manager1 should still be a manager") // Origin removes manager1 - Should succeed testing.SetRealm(testing.NewUserRealm(originAddr)) err = RemoveManager(cross(cur), manager1Addr) uassert.NoError(t, err, "origin removing manager1 should succeed") uassert.False(t, HasManager(cur, manager1Addr), "manager1 should now be removed") // Add manager1 again for next test case testing.SetRealm(testing.NewUserRealm(originAddr)) err = AddManager(cross(cur), manager1Addr) uassert.NoError(t, err, "setup: failed to re-add manager1") // Transfer authority testing.SetRealm(testing.NewUserRealm(originAddr)) err = TransferManagement(cross(cur), authz.NewAutoAcceptAuthority()) uassert.NoError(t, err, "transferring authority should succeed") // Try removing after transfer - Should fail (wrong authority type) testing.SetRealm(testing.NewUserRealm(originAddr)) // Use origin, doesn't matter which user now err = RemoveManager(cross(cur), manager1Addr) uassert.ErrorContains(t, err, "current authority is not a MemberAuthority", "removing manager should fail after transfer") } func TestListManagers(cur realm, t *testing.T) { setupTest(cur, t) initialList := ListManagers(cross(cur)) assertAddrSliceEqual(t, []address{originAddr}, initialList) // Add manager1 and manager2 testing.SetRealm(testing.NewUserRealm(originAddr)) err := AddManager(cross(cur), manager1Addr) uassert.NoError(t, err) err = AddManager(cross(cur), manager2Addr) uassert.NoError(t, err) // List should contain origin, manager1, manager2 list1 := ListManagers(cross(cur)) expected1 := []address{manager2Addr, manager1Addr, originAddr} assertAddrSliceEqual(t, expected1, list1) // Remove manager1 testing.SetRealm(testing.NewUserRealm(originAddr)) // Can be origin or manager2 err = RemoveManager(cross(cur), manager1Addr) uassert.NoError(t, err) // List should contain origin, manager2 list2 := ListManagers(cross(cur)) expected2 := []address{manager2Addr, originAddr} assertAddrSliceEqual(t, expected2, list2) // Transfer authority testing.SetRealm(testing.NewUserRealm(originAddr)) err = TransferManagement(cross(cur), authz.NewAutoAcceptAuthority()) uassert.NoError(t, err) // List should be empty after transfer list3 := ListManagers(cross(cur)) uassert.True(t, len(list3) == 0, "manager list should be empty after transfer") } func TestHasManager(cur realm, t *testing.T) { setupTest(cur, t) // Initially, only origin is manager uassert.True(t, HasManager(cross(cur), originAddr), "origin should initially be a manager") uassert.False(t, HasManager(cross(cur), manager1Addr), "manager1 should not initially be a manager") uassert.False(t, HasManager(cross(cur), nonManagerAddr), "nonManager should not initially be a manager") // Add manager1 testing.SetRealm(testing.NewUserRealm(originAddr)) err := AddManager(cross(cur), manager1Addr) uassert.NoError(t, err) // Check again uassert.True(t, HasManager(cross(cur), originAddr), "origin should still be a manager") uassert.True(t, HasManager(cross(cur), manager1Addr), "manager1 should now be a manager") uassert.False(t, HasManager(cross(cur), nonManagerAddr), "nonManager should still not be a manager") // Transfer authority testing.SetRealm(testing.NewUserRealm(originAddr)) err = TransferManagement(cross(cur), authz.NewAutoAcceptAuthority()) uassert.NoError(t, err) // After transfer, HasManager should always return false for MemberAuthority checks uassert.False(t, HasManager(cross(cur), originAddr), "HasManager should be false after transfer") uassert.False(t, HasManager(cross(cur), manager1Addr), "HasManager should be false after transfer") uassert.False(t, HasManager(cross(cur), nonManagerAddr), "HasManager should be false after transfer") } func TestTransferManagement(cur realm, t *testing.T) { setupTest(cur, t) // Add manager1 testing.SetRealm(testing.NewUserRealm(originAddr)) err := AddManager(cross(cur), manager1Addr) uassert.NoError(t, err) // Create a new authority (MemberAuthority with manager2) newAuthority := authz.NewMemberAuthority(manager2Addr) // Non-manager tries to transfer - Should fail testing.SetRealm(testing.NewUserRealm(nonManagerAddr)) err = TransferManagement(cross(cur), newAuthority) uassert.Error(t, err, "non-manager transfer should fail") _, isMemberAuth := Authorizer.Authority().(*authz.MemberAuthority) uassert.True(t, isMemberAuth, "authority should still be MemberAuthority") // Verify it didn't change // Manager1 tries to transfer - Should succeed testing.SetRealm(testing.NewUserRealm(manager1Addr)) err = TransferManagement(cross(cur), newAuthority) uassert.NoError(t, err, "manager1 transfer should succeed") // Verify current authority is the new one currentAuth := Authorizer.Authority() uassert.True(t, currentAuth == newAuthority, "current authority should be the new one") // Verify origin is no longer a manager under the *new* authority testing.SetRealm(testing.NewUserRealm(manager2Addr)) // Need new manager to check uassert.False(t, HasManager(cross(cur), originAddr), "origin should not be manager under new authority") uassert.False(t, HasManager(cross(cur), manager1Addr), "manager1 should not be manager under new authority") uassert.True(t, HasManager(cross(cur), manager2Addr), "manager2 should be manager under new authority") // Try adding a manager using the old origin - Should fail testing.SetRealm(testing.NewUserRealm(originAddr)) err = AddManager(cross(cur), nonManagerAddr) uassert.Error(t, err, "origin should not be able to add manager after transfer") // Try adding a manager using the new manager (manager2) - Should succeed testing.SetRealm(testing.NewUserRealm(manager2Addr)) err = AddManager(cross(cur), nonManagerAddr) uassert.NoError(t, err, "new manager (manager2) should be able to add managers") uassert.True(t, HasManager(cross(cur), nonManagerAddr), "nonManager should be added by manager2") // Try transferring to nil - Should fail testing.SetRealm(testing.NewUserRealm(manager2Addr)) err = TransferManagement(cross(cur), nil) uassert.ErrorContains(t, err, "new authority cannot be nil", "transferring to nil should fail") } func TestTransferToContractAuthority(cur realm, t *testing.T) { setupTest(cur, t) // Origin is the initial manager contractPath := "gno.land/r/testcontract" contractRealm := testing.NewCodeRealm(contractPath) // Simulate contract realm // Define a simple contract authority handler handlerExecuted := false // Track if the handler itself gets called contractAuth := authz.NewContractAuthority(contractPath, func(title string, action authz.PrivilegedAction) error { // Simulate contract checking the caller *before* executing caller := unsafe.CurrentRealm().Address() expectedContractAddr := chain.PackageAddress(contractPath) if caller != expectedContractAddr { // Fail before marking executed or running action // Note: In a real scenario, this handler might just ignore the call // if the caller isn't right, rather than returning an error, // depending on the desired contract logic. Returning an error // here helps the test verify the handler wasn't improperly called. return errors.New("handler: caller is not the contract") } // Only mark executed and run action if caller is correct handlerExecuted = true return action() }) // Origin transfers management to the contract authority testing.SetRealm(testing.NewUserRealm(originAddr)) err := TransferManagement(cross(cur), contractAuth) uassert.NoError(t, err, "transfer to contract authority failed") uassert.True(t, Authorizer.Authority() == contractAuth, "authority should now be the contract authority") // Now, actions like AddManager/RemoveManager should fail because the current // authority is no longer a MemberAuthority. The contract would need its own // logic executed via Authorizer.DoByCurrent() to manage members if desired. // Try adding a manager (will check authority type) - Should fail testing.SetRealm(testing.NewUserRealm(originAddr)) // Caller doesn't matter for this check err = AddManager(cross(cur), manager1Addr) uassert.ErrorContains(t, err, "current authority is not a MemberAuthority", "AddManager should fail with ContractAuthority") // Simulate an action authorized *by the contract* using Authorizer.Do var contractActionExecuted bool handlerExecuted = false // Reset tracker testing.SetRealm(contractRealm) // Call must originate from the contract now err = Authorizer.DoByCurrent(0, cur, "some_contract_action", func() error { contractActionExecuted = true // Imagine contract logic here return nil }) uassert.NoError(t, err, "contract action via Authorizer.Do failed") uassert.True(t, handlerExecuted, "handler should have been executed by contract call") // Verify handler ran uassert.True(t, contractActionExecuted, "contract action should have been executed") // Simulate an action from a user - Should fail before handler is called var userActionExecuted bool handlerExecuted = false // Reset tracker testing.SetRealm(testing.NewUserRealm(nonManagerAddr)) err = Authorizer.DoByCurrent(0, cur, "some_user_action", func() error { userActionExecuted = true return nil }) // A plain ContractAuthority defaults its proposer to the contract // itself, so a user caller is rejected UPSTREAM (at the proposer) and // the handler is never reached — the handler's own CurrentRealm check // above is now belt-and-braces, not the guard being relied on. uassert.Error(t, err, "user action via Authorizer.Do should fail when contract is authority") uassert.ErrorContains(t, err, "unauthorized", "rejection should come from the contract-identity proposer") uassert.False(t, handlerExecuted, "handler should NOT have been executed by user call") uassert.False(t, userActionExecuted, "user action should not have been executed") } // Helper to check if a slice contains a specific address func containsAddr(list []address, addr address) bool { for _, item := range list { if item == addr { return true } } return false } func assertAddrSliceEqual(t *testing.T, expected, actual []address) { t.Helper() if len(expected) != len(actual) { t.Fatalf("expected slice length %d, got %d. Expected: %v, Got: %v", len(expected), len(actual), expected, actual) } for i := range expected { if expected[i] != actual[i] { t.Fatalf("slices differ at index %d. Expected: %v, Got: %v", i, expected, actual) } } }
  12. #12gnomod.toml
  13. #13module = "gno.land/r/moul/config/v1" gno = "0.9" # public: every other realm of moul's imports this one, and a private realm # cannot be imported at all. That is the whole reason this realm exists, so # `private = true` here would be a contradiction rather than a trade-off. # # The redeploy-in-place that private would buy is covered a different way: a # later version relays to this one instead of replacing it, so the state # survives a version bump without the path ever moving. See proxy.gno.
  14. #14keys.gno
  15. #15package config import ( "strings" "chain/runtime" "chain/runtime/unsafe" ) // The key grammar. // // A key is a NAME, optionally followed by "@" and a SCOPE: // // pause applies to every realm that asks // pause@r/moul/home applies to that realm only // // The scope is a package path with the chain domain stripped, because the // domain is the same for every realm reading this one and repeating it in a // few dozen keys buys nothing but storage. // // Two settings therefore answer every scoped question, and each reader decides // how they combine: pause takes the stricter of the two (pausable.Strictest), // while the notice blocks show both. That choice belongs to the reader and not // here, because "stricter wins" and "show both" are both right, for different // settings. const ( // ScopeSep separates a name from the realm it applies to. "@" and not "." // so a scope can never be mistaken for a longer name, and not ":" because // gno realm render paths already use that. ScopeSep = "@" maxNameLen = 64 maxScopeLen = 128 ) // realmPath is this realm's own path, including its version. Stated once so // the page can link to itself and the proxy check can recognise a sibling // version without either restating the string. const realmPath = "gno.land/r/moul/config/v1" // validName reports whether name is a legal setting name: 1..maxNameLen bytes // of [a-z0-9._-]. The dot is the namespace separator ("block.top"), lowercase // only so a setting has exactly one name, and no whitespace so a name // round-trips through Manifest's tab-separated lines. func validName(name string) bool { if len(name) == 0 || len(name) > maxNameLen { return false } for i := 0; i < len(name); i++ { c := name[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9': case c == '-', c == '_', c == '.': default: return false } } return true } // validScope reports whether scope is shaped like a package path with the // domain stripped: 1..maxScopeLen bytes of [a-z0-9._/-], no empty segment. func validScope(scope string) bool { if len(scope) == 0 || len(scope) > maxScopeLen { return false } lastSlash := true // a leading slash would be an empty first segment for i := 0; i < len(scope); i++ { c := scope[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9': lastSlash = false case c == '-', c == '_', c == '.': lastSlash = false case c == '/': if lastSlash { return false } lastSlash = true default: return false } } return !lastSlash } // validKey reports whether key is a legal name, or a legal name and scope // joined by ScopeSep. func validKey(key string) bool { name, scope := SplitKey(key) if scope == "" { return !strings.Contains(key, ScopeSep) && validName(name) } return validName(name) && validScope(scope) } // SplitKey takes a key apart. An unscoped key returns an empty scope, and so // does a malformed one: callers pair this with validKey rather than trusting // the split. func SplitKey(key string) (name, scope string) { i := strings.Index(key, ScopeSep) if i < 0 { return key, "" } return key[:i], key[i+1:] } // Scope turns a package path into the scope half of a key, stripping the chain // domain: "gno.land/r/moul/home" and "r/moul/home" both give "r/moul/home". // // A path that is not shaped like one comes back empty, and every caller here // treats that as "no scope", falling back to the global setting rather than // inventing a key nobody can type. // // The chain domain is tried first and the literal "gno.land/" second, so a key // written on one chain still resolves on another whose domain differs. Twin of // mygnoscan.TrimDomain, which answers the same question for a URL and is // stricter about the characters, because its output lands inside a link. func Scope(pkgPath string) string { s := strings.TrimPrefix(pkgPath, runtime.ChainDomain()+"/") s = strings.TrimPrefix(s, "gno.land/") s = strings.Trim(s, "/") if !validScope(s) { return "" } return s } // KeyFor builds the scoped key for a setting on one realm, and returns the // bare name when pkgPath names no realm this can scope to. // // This is what a realm should render when it wants to tell a manager which // setting to change, so the command in the page is the command that works. func KeyFor(name, pkgPath string) string { scope := Scope(pkgPath) if scope == "" { return name } return name + ScopeSep + scope } // caller is the package path of the realm that called into this one. // // Every exported function here that uses it is a plain read with no `cur realm` // parameter, so it can only ever be BORROWED: gno runs it without opening a // realm frame, and unsafe.CurrentRealm() therefore reports the borrower. That // is what makes the zero-argument helpers (TopBlock, IsPaused, …) able to name // their caller at all. // // Measured in the test harness on 2026-09-22: a call from a code realm at // gno.land/r/test/caller reports CurrentRealm=gno.land/r/test/caller and // PreviousRealm=gno.land/r/moul/config/v1. // // The moment one of these grows a `cur realm` parameter this stops being true // and starts reporting this realm instead. Do not add one. Anything that needs // a realm frame takes the path explicitly, which is what the …For variants are. func caller() string { return unsafe.CurrentRealm().PkgPath() } // scopedPair reads both halves of a scoped setting: the global one and the one // for pkgPath, either of which may be empty. func scopedPair(name, pkgPath string) (global, scoped string) { global = Get(name) if scope := Scope(pkgPath); scope != "" { scoped = Get(name + ScopeSep + scope) } return global, scoped }
  16. #16pause.gno
  17. #17package config import "gno.land/p/moul/pausable/v0" // The pause switch: one setting that stops every realm reading this one, and // one per realm that stops just that one. // // Set pause "paused: incident, back in an hour" // Set pause@r/moul/gns "readonly" // Unset pause # running again // // A realm guards its writes and leaves its reads alone, which is what makes // "readonly" the level worth having: the page keeps rendering and explains // itself through TopBlock, and nothing new lands. // // func Post(cur realm, body string) { // config.AssertWritable() // … // } // // The levels, the fail-closed parse and the stricter-of-two rule all live in // gno.land/p/moul/pausable; this file is only the wiring to storage. const KeyPause = "pause" // Pause returns the pause state of the realm calling in: the stricter of the // global setting and that realm's own. func Pause() pausable.State { return PauseFor(caller()) } // PauseFor is Pause for a named realm. // // A stale per-realm entry can never re-open a realm during a global pause, // because the two combine with pausable.Strictest rather than one overriding // the other. The cost is that exempting one realm from a global pause is not // expressible: clear the global and set the others. func PauseFor(pkgPath string) pausable.State { global, scoped := scopedPair(KeyPause, pkgPath) return pausable.Strictest(pausable.MustParse(global), pausable.MustParse(scoped)) } // IsPaused reports whether the realm calling in is held back at all, ReadOnly // as well as fully paused. func IsPaused() bool { return Pause().IsPaused() } // IsPausedFor is IsPaused for a named realm. func IsPausedFor(pkgPath string) bool { return PauseFor(pkgPath).IsPaused() } // AssertWritable aborts unless the realm calling in may still change state. // This is the one line a mutating function needs. func AssertWritable() { Pause().AssertWritable() } // AssertWritableFor is AssertWritable for a named realm, for a crossing // function that cannot be read off the stack. func AssertWritableFor(pkgPath string) { PauseFor(pkgPath).AssertWritable() } // AssertReadable aborts unless the realm calling in may still be read. // // Most realms should NOT put this in Render. A page that aborts tells a reader // nothing; TopBlock tells them what happened and when to come back. Reach for // this only where serving stale data is itself the harm. func AssertReadable() { Pause().AssertReadable() } // AssertReadableFor is AssertReadable for a named realm. func AssertReadableFor(pkgPath string) { PauseFor(pkgPath).AssertReadable() }
  18. #18pause_test.gno
  19. #19package config import ( "testing" "gno.land/p/moul/pausable/v0" "gno.land/p/nt/uassert/v0" ) func TestPauseDefaultRunning(t *testing.T) { resetSettings() uassert.False(t, IsPausedFor(otherRealm)) uassert.True(t, PauseFor(otherRealm).AllowsRead()) uassert.True(t, PauseFor(otherRealm).AllowsWrite()) AssertWritableFor(otherRealm) AssertReadableFor(otherRealm) } func TestPauseGlobal(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyPause, "readonly") // Reaches every realm. for _, path := range []string{otherRealm, "gno.land/r/moul/home", "gno.land/r/someone/else"} { uassert.True(t, IsPausedFor(path), path+" should be held back") uassert.True(t, PauseFor(path).AllowsRead(), path+" should still read") uassert.False(t, PauseFor(path).AllowsWrite(), path+" should refuse writes") } uassert.PanicsWithMessage(t, cur, "writes are paused", func() { AssertWritableFor(otherRealm) }) AssertReadableFor(otherRealm) } func TestPauseScoped(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyFor(KeyPause, otherRealm), "paused: incident") uassert.True(t, IsPausedFor(otherRealm)) uassert.False(t, PauseFor(otherRealm).AllowsRead()) uassert.False(t, IsPausedFor("gno.land/r/moul/home"), "another realm is unaffected") uassert.PanicsWithMessage(t, cur, "paused: incident", func() { AssertReadableFor(otherRealm) }) } // TestGlobalPauseCannotBeDefeated is the reason the two combine with // Strictest. A per-realm entry left behind from last month must not re-open a // realm during a global halt. func TestGlobalPauseCannotBeDefeated(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyFor(KeyPause, otherRealm), "running") testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyPause, "paused: chain incident") uassert.False(t, PauseFor(otherRealm).AllowsRead(), "an explicit per-realm 'running' does not survive a global pause") uassert.Equal(t, "chain incident", PauseFor(otherRealm).Reason) // The other direction works: a per-realm pause under a running global. resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyFor(KeyPause, otherRealm), "paused: just this one") uassert.False(t, PauseFor(otherRealm).AllowsRead()) uassert.True(t, PauseFor("gno.land/r/moul/home").AllowsRead()) } // TestPauseValueValidatedOnWrite is the other half of failing closed. Because // pausable.MustParse turns an unreadable value into Paused, an unvalidated // typo here would take every realm offline at the next render; refusing the // write turns that into a failed transaction the writer sees at once. func TestPauseValueValidatedOnWrite(cur realm, t *testing.T) { resetSettings() for _, bad := range []string{"yes", "true", "1", "stop", "read-only"} { testing.SetRealm(testing.NewUserRealm(originAddr)) uassert.AbortsContains(t, cur, "invalid pause value", func() { Set(cross(cur), KeyPause, bad) }) } uassert.Equal(t, 0, Size(), "no bad value was stored") // The scoped key is validated too: the check is on the key's NAME, so it // cannot be sidestepped by scoping it. testing.SetRealm(testing.NewUserRealm(originAddr)) uassert.AbortsContains(t, cur, "invalid pause value", func() { Set(cross(cur), KeyFor(KeyPause, otherRealm), "yes") }) // And every value the reader understands is accepted. for _, good := range []string{"", "running", "readonly", "paused", "paused: why"} { testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyPause, good) uassert.Equal(t, good, Get(KeyPause)) } // A setting that is not pause is not subject to the grammar. testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), "some.key", "yes") uassert.Equal(t, "yes", Get("some.key")) } func TestPauseZeroArgFormsNameTheCaller(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) Set(cross(cur), KeyFor(KeyPause, otherRealm), "paused") testing.SetRealm(testing.NewCodeRealm(otherRealm)) uassert.True(t, IsPaused()) uassert.Equal(t, int(pausable.Paused), int(Pause().Level)) testing.SetRealm(testing.NewCodeRealm("gno.land/r/moul/home")) uassert.False(t, IsPaused(), "another realm is unaffected") AssertWritable() }
  20. #20proxy.gno
  21. #21package config import ( "strings" "chain" "gno.land/p/nt/avl/v0" ) // Version chaining: how v2 and v3 get built without splitting the state. // // # The shape, and why it is this way round // // This realm is public, so its path is permanent and a new API means a new // version at a new path. Left alone, that fragments everything: a realm // importing v1 and a realm importing v2 would read two different member lists // and two different pause switches, and the second one to be written would win // for half the callers. // // Delegation fixes it, and it can only run one way. A version can import the // versions that already existed when it was written; it can never import one // that does not exist yet. So the STATE STAYS IN THE OLDEST version that has // it, and every later version is a thin relay in front: // // v3 -> v2 -> v1 (this one: the root, and where everything lives) // // A realm importing v1, v2 or v3 is then reading the same settings, the same // pause and the same managers, whichever door it came through. That is the // property worth all of this. // // v0 cannot take part: it is already on chain and has no settings store at // all. It keeps answering for its own member list and nothing else. // // # Writing v2 // // v2 imports this package, holds no state of its own, and for each write it // exposes, calls SetAs or UnsetAs with the address it was called by: // // func Set(cur realm, key, value string) { // config.SetAs(cross(cur), cur.Previous().Address(), key, value) // } // // Reads need none of this. They are plain borrowed calls, so v2 just forwards: // // func Get(key string) string { return config.Get(key) } // // Then, once, from a manager's address: // // AllowProxy gno.land/r/moul/config/v2 // // # What a proxy is trusted with, exactly // // A registered proxy is trusted to say WHO is asking, not to decide whether // they may. SetAs still puts the principal through the Authorizer, so the // member list stays the single answer to "who may change config" for every // version at once, and adding a manager here works through v2 and v3 with no // further deploys. // // It is not a security boundary against the proxy's own code: a proxy that // lies about its principal writes whatever it likes. It does not have to be, // because the same person deploys both and registering one is a deliberate, // revocable act. What it buys is that v2 never carries a copy of the member // list, so the two can never disagree. // proxies holds the package paths allowed to relay, keyed by path, valued by // the address that path resolves to. The address is what an incoming call is // actually compared against; the path is stored so Render and ListProxies can // show something a human recognises. var proxies = avl.NewTree() // pkgPath -> address // AllowProxy registers a newer version of this realm as a relay. Manager only. // // It takes a package path rather than an address because a path is what a // manager can check by eye; the address is derived here with the same function // the chain uses, so the two cannot drift. func AllowProxy(cur realm, pkgPath string) { assertProxyPath(pkgPath) assertAuthorized(cur, "config.AllowProxy:"+pkgPath) proxies.Set(pkgPath, chain.PackageAddress(pkgPath)) settingsRev++ chain.Emit("ConfigAllowProxy", "path", pkgPath, "address", chain.PackageAddress(pkgPath).String()) } // RevokeProxy withdraws a relay. Manager only. // // Revoking one that was never registered aborts rather than passing quietly: // at this point in an incident, "done" and "you misspelled it" must not look // the same. func RevokeProxy(cur realm, pkgPath string) { assertProxyPath(pkgPath) assertAuthorized(cur, "config.RevokeProxy:"+pkgPath) if _, removed := proxies.Remove(pkgPath); !removed { panic("no such proxy: " + pkgPath) } settingsRev++ chain.Emit("ConfigRevokeProxy", "path", pkgPath) } // IsProxy reports whether addr is a registered relay. func IsProxy(addr address) bool { if addr == "" { return false } found := false proxies.Iterate("", "", func(_ string, value any) bool { if value.(address) == addr { found = true return true // stop } return false }) return found } // ListProxies returns the registered relay paths, in sorted order. func ListProxies() []string { out := []string{} proxies.Iterate("", "", func(key string, _ any) bool { out = append(out, key) return false }) return out } // SetAs is Set, performed by a registered proxy on behalf of principal. // // Two checks, and both have to hold: the caller is a registered relay, and the // principal it names is authorized. Neither is redundant. Dropping the first // would let any realm claim any principal; dropping the second would make a // registered proxy an unconditional bypass of the member list. func SetAs(cur realm, principal address, key, value string) { assertRelay(cur) assertWritableKey(key) assertValue(key, value) assertPrincipal(principal, "config.Set:"+key) writeSetting(key, value) } // UnsetAs is Unset, performed by a registered proxy on behalf of principal. func UnsetAs(cur realm, principal address, key string) { assertRelay(cur) assertWritableKey(key) assertPrincipal(principal, "config.Unset:"+key) removeSetting(key) } // assertRelay aborts unless the immediate caller is a registered proxy. func assertRelay(cur realm) { from := cur.Previous().Address() if !IsProxy(from) { panic("not a registered proxy: " + from.String()) } } // assertPrincipal puts an address through the Authorizer without it being the // caller, which is what a relay needs and what DoByPrevious cannot express. // // With the default MemberAuthority this is a membership test. With an // authority that inspects the caller instead (a ContractAuthority), the // relayed path asks that authority about the principal, so check what it does // before transferring management while a proxy is registered. func assertPrincipal(principal address, title string) { if principal == "" { panic("unauthorized: no principal") } allowed := false err := Authorizer.Authority().Authorize(principal, title, func() error { allowed = true return nil }) if err != nil { panic("unauthorized: " + err.Error()) } if !allowed { panic("unauthorized") } } // assertProxyPath refuses anything that is not a realm path under this // namespace. A proxy is by definition a later version of this realm, so // nothing else should ever be registered, and the narrow check turns a // fat-fingered AllowProxy into an abort rather than a standing grant to an // unrelated realm. func assertProxyPath(pkgPath string) { const want = "gno.land/r/moul/config/v" if !strings.HasPrefix(pkgPath, want) { panic("a proxy must be a later version of this realm, got " + pkgPath) } rest := strings.TrimPrefix(pkgPath, want) if rest == "" || !isDigits(rest) { panic("a proxy must be a later version of this realm, got " + pkgPath) } } func isDigits(s string) bool { for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return false } } return len(s) > 0 }
  22. #22proxy_test.gno
  23. #23package config import ( "testing" "chain" "gno.land/p/nt/uassert/v0" ) // v2Path is a plausible next version. Nothing is deployed there; the tests // only need the address the path resolves to, which is what an incoming // relayed call is actually compared against. const v2Path = "gno.land/r/moul/config/v2" func v2Addr() address { return chain.PackageAddress(v2Path) } func TestAllowAndRevokeProxy(cur realm, t *testing.T) { resetSettings() uassert.Equal(t, 0, len(ListProxies())) uassert.False(t, IsProxy(v2Addr())) testing.SetRealm(testing.NewUserRealm(originAddr)) AllowProxy(cross(cur), v2Path) uassert.True(t, IsProxy(v2Addr())) uassert.Equal(t, 1, len(ListProxies())) uassert.Equal(t, v2Path, ListProxies()[0]) testing.SetRealm(testing.NewUserRealm(originAddr)) RevokeProxy(cross(cur), v2Path) uassert.False(t, IsProxy(v2Addr())) uassert.Equal(t, 0, len(ListProxies())) } func TestProxyRegistrationIsManagerOnly(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(nonManagerAddr)) uassert.AbortsContains(t, cur, "unauthorized", func() { AllowProxy(cross(cur), v2Path) }) uassert.False(t, IsProxy(v2Addr()), "a rejected registration leaves nothing behind") } // TestProxyPathMustBeASiblingVersion keeps a fat-fingered AllowProxy from // becoming a standing write grant to an unrelated realm. func TestProxyPathMustBeASiblingVersion(cur realm, t *testing.T) { resetSettings() bad := []string{ "gno.land/r/someone/evil", "gno.land/r/moul/home", "gno.land/r/moul/configuration/v2", // prefix-adjacent, not a sibling "gno.land/r/moul/config/v", // no number "gno.land/r/moul/config/vnext", "gno.land/r/moul/config/v2/sub", "", } for _, path := range bad { testing.SetRealm(testing.NewUserRealm(originAddr)) uassert.AbortsContains(t, cur, "must be a later version", func() { AllowProxy(cross(cur), path) }) } uassert.Equal(t, 0, len(ListProxies())) } func TestRevokeUnknownProxyAborts(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) uassert.AbortsWithMessage(t, cur, "no such proxy: "+v2Path, func() { RevokeProxy(cross(cur), v2Path) }) } // TestRelayNeedsRegistration is the first of the relay's two checks: without // it, any realm could claim to be acting for any principal. func TestRelayNeedsRegistration(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewCodeRealm(v2Path)) uassert.AbortsContains(t, cur, "not a registered proxy", func() { SetAs(cross(cur), originAddr, "some.key", "value") }) uassert.Equal(t, 0, Size()) } // TestRelayStillChecksThePrincipal is the second check, and the reason a proxy // is not simply an unconditional bypass: the member list stays the single // answer to "who may change config", for every version at once. func TestRelayStillChecksThePrincipal(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) AllowProxy(cross(cur), v2Path) testing.SetRealm(testing.NewCodeRealm(v2Path)) uassert.AbortsContains(t, cur, "unauthorized", func() { SetAs(cross(cur), nonManagerAddr, "some.key", "value") }) uassert.Equal(t, 0, Size()) // An empty principal is not a wildcard. testing.SetRealm(testing.NewCodeRealm(v2Path)) uassert.AbortsContains(t, cur, "no principal", func() { SetAs(cross(cur), "", "some.key", "value") }) } // TestRelayWritesTheSameState is the property the whole version chain exists // for: a write arriving through a later version lands in exactly the store // that a direct write lands in, so v1 and v2 readers never disagree. func TestRelayWritesTheSameState(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) AllowProxy(cross(cur), v2Path) testing.SetRealm(testing.NewCodeRealm(v2Path)) SetAs(cross(cur), originAddr, KeyMygnoscanURL, "https://scan.example.com") uassert.Equal(t, "https://scan.example.com", Get(KeyMygnoscanURL)) uassert.Equal(t, "https://scan.example.com", MygnoscanURL(), "the typed accessor sees the relayed write too") testing.SetRealm(testing.NewCodeRealm(v2Path)) UnsetAs(cross(cur), originAddr, KeyMygnoscanURL) uassert.False(t, Has(KeyMygnoscanURL)) } // TestRelayValidatesLikeTheDirectPath: a relay is a different door, not a // different set of rules. Key shape and value grammar are checked identically. func TestRelayValidatesLikeTheDirectPath(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) AllowProxy(cross(cur), v2Path) testing.SetRealm(testing.NewCodeRealm(v2Path)) uassert.AbortsContains(t, cur, "invalid key", func() { SetAs(cross(cur), originAddr, "Bad Key", "value") }) testing.SetRealm(testing.NewCodeRealm(v2Path)) uassert.AbortsContains(t, cur, "invalid pause value", func() { SetAs(cross(cur), originAddr, KeyPause, "yes") }) } // TestRevokedProxyCannotWrite: revocation is the emergency brake on the relay // itself, so it has to bite immediately. func TestRevokedProxyCannotWrite(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) AllowProxy(cross(cur), v2Path) testing.SetRealm(testing.NewCodeRealm(v2Path)) SetAs(cross(cur), originAddr, "some.key", "before") testing.SetRealm(testing.NewUserRealm(originAddr)) RevokeProxy(cross(cur), v2Path) testing.SetRealm(testing.NewCodeRealm(v2Path)) uassert.AbortsContains(t, cur, "not a registered proxy", func() { SetAs(cross(cur), originAddr, "some.key", "after") }) uassert.Equal(t, "before", Get("some.key")) } // TestManagerChangesReachTheRelay is what makes one member list serve every // version: adding a manager here works through v2 with no further deploys. func TestManagerChangesReachTheRelay(cur realm, t *testing.T) { resetSettings() testing.SetRealm(testing.NewUserRealm(originAddr)) AllowProxy(cross(cur), v2Path) testing.SetRealm(testing.NewCodeRealm(v2Path)) uassert.AbortsContains(t, cur, "unauthorized", func() { SetAs(cross(cur), manager1Addr, "some.key", "value") }) testing.SetRealm(testing.NewUserRealm(originAddr)) uassert.NoError(t, AddManager(cross(cur), manager1Addr)) testing.SetRealm(testing.NewCodeRealm(v2Path)) SetAs(cross(cur), manager1Addr, "some.key", "value") uassert.Equal(t, "value", Get("some.key")) } // ExampleRender_proxies pins the section that only exists once a later version // is registered, which is the state this realm spends most of its life NOT in. func ExampleRender_proxies() { resetSettings() proxies.Set(v2Path, chain.PackageAddress(v2Path)) print(Render("")) // Output: // # gno.land/r/moul/config // // moul's settings, read by his other realms. rev 0 · 0 setting(s) // // ## Settings // // _none set; every reader is on its built-in default_ // // ## Pause // // _running; no global pause_ // // ## Authority // // `member_authority[g1daexjemfde047h6lta047h6lta047h6ld2pug6]` // // ## Proxies // // Later versions of this realm that may relay a write: // // - `gno.land/r/moul/config/v2` // // --- // // [explorer](https://mygnoscan.moul.p2p.team/realm/r/moul/config/v1) · [source](https://mygnoscan.moul.p2p.team/realm/r/moul/config/v1?tab=source) · [calls](https://mygnoscan.moul.p2p.team/realm/r/moul/config/v1?tab=calls) · [deps](https://mygnoscan.moul.p2p.team/realm/r/moul/config/v1?tab=deps) }
  24. #24settings.gno
Attached funds
6000000ugnot

Arguments · 9

  1. #1asciiart
  2. #2README.md
  3. #3# ASCII Art Generator > ⚠️ **Experimental — generated with no human supervision** by the daily MCP pipeline to exercise gno tooling. Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md). --- An on-chain ASCII art generator that draws patterns (diamond, pyramid, Sierpinski triangle, checkerboard) from short deterministic formulas instead of a hand-authored font table -- the Sierpinski gasket is just `row & col == 0`. Call Generate(pattern, size) to render a piece and add it to a public gallery; browse the gallery or a specific piece via Render. It's a neat, self-contained example of generative art as pure on-chain logic. Built for sapphire (gno 0.9). Live demo (agent address): https://sapphire.testnets.gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/asciiart ## What changed in v1 Two ports against [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/asciiart), which stays live and untouched. ### Rendering Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/asciiart) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `escapeInline` helper. **Real escaping.** The local helper replaced a handful of markdown metacharacters. `ui.Inline` and `ui.Cell` delegate to [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0), which also strips bidi and zero-width characters and folds newlines, so user text cannot reorder or escape the line it sits on. ### Storage State moved from a hand-rolled `avl.Tree` + `nextID` + zero-padding helper to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. `v0` padded ids to width 6. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. `piece` loses its `ID` field, which the store now owns. The gallery page also gets simpler: `v0` reverse-iterated the whole tree with a `shown` counter to stop at eight, where `PageReverse(1, 8)` asks the store for exactly that window. A non-numeric or zero path is now rejected by `ParseID` rather than by `strconv.Atoi` plus a range check. Ids stay plain integers and the rendered output of the storage port is unchanged. Each port changes something `v0` promised, one the rendered output and one the storage layout, so under this repo's versioning rule each is a compatibility change and neither could be an edit to `v0` in place. They land in the **same** new version because `v1` was never published. A version number is a tag on something that exists on a chain, and until it does there is nothing for a second number to avoid disturbing, so the right move is to keep editing the version you have. `gnopm unbump` is what folded the second port back down into this one. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/asciiart/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/asciiart/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. #4asciiart.gno
  5. #5// Package asciiart is a small on-chain ASCII art generator. Instead of a // hand-authored font table, each pattern is produced by a short, pure, // deterministic algorithm -- e.g. the Sierpinski triangle falls out of a // single bitwise AND over row/column indices. Callers pick a pattern and a // size; the realm renders it and keeps a gallery of everyone's generations. package asciiart import ( "strconv" "strings" "chain" "chain/runtime" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/kit/store/v0" ) // maxSize bounds every pattern's size parameter so a single Generate call // can't blow the block gas limit on a pathologically large grid. const maxSize = 32 // galleryPageSize caps how many pieces Render shows on the home page. const galleryPageSize = 8 // piece carries no ID field: the id belongs to the store, which hands it back // on lookup and iteration. type piece struct { Pattern string Size int Author address CreatedAt int64 Art string } // gallery assigns the piece ids. v1 kept its own nextID plus a padID() that // zero-padded to width 6, the narrowest ceiling in this repo: at the millionth // piece the padding stops and the gallery starts rendering out of order. The // store key is 8 fixed bytes with no width to outgrow. var gallery = store.Named("piece") // RenderDiamond draws a filled diamond of half-width n (2n-1 rows total), // each row's stars centered and padded with spaces. func RenderDiamond(n int) string { if n < 1 { panic("diamond size must be >= 1") } var b strings.Builder for i := 0; i < 2*n-1; i++ { d := i if i >= n { d = 2*n - 2 - i } b.WriteString(strings.Repeat(" ", n-1-d)) b.WriteString(strings.Repeat("*", 2*d+1)) if i < 2*n-2 { b.WriteString("\n") } } return b.String() } // RenderPyramid draws a solid triangle n rows tall, widening by two stars // per row and centered with leading spaces. func RenderPyramid(n int) string { if n < 1 { panic("pyramid size must be >= 1") } var b strings.Builder for i := 0; i < n; i++ { b.WriteString(strings.Repeat(" ", n-1-i)) b.WriteString(strings.Repeat("*", 2*i+1)) if i < n-1 { b.WriteString("\n") } } return b.String() } // RenderSierpinski draws a Sierpinski triangle in an n x n grid (n must be a // power of two): cell (row, col) is filled exactly when row&col == 0, the // classic bitwise identity for Pascal's-triangle-mod-2. func RenderSierpinski(n int) string { if n < 1 || n&(n-1) != 0 { panic("sierpinski size must be a power of two (1, 2, 4, 8, 16, 32)") } var b strings.Builder for row := 0; row < n; row++ { for col := 0; col < n; col++ { if row&col == 0 { b.WriteString("#") } else { b.WriteString(" ") } } if row < n-1 { b.WriteString("\n") } } return b.String() } // RenderChecker draws an n x n checkerboard of # and . cells. func RenderChecker(n int) string { if n < 1 { panic("checker size must be >= 1") } var b strings.Builder for row := 0; row < n; row++ { for col := 0; col < n; col++ { if (row+col)%2 == 0 { b.WriteString("#") } else { b.WriteString(".") } } if row < n-1 { b.WriteString("\n") } } return b.String() } func renderPattern(pattern string, size int) string { switch pattern { case "diamond": return RenderDiamond(size) case "pyramid": return RenderPyramid(size) case "sierpinski": return RenderSierpinski(size) case "checker": return RenderChecker(size) default: panic(`unknown pattern: use "diamond", "pyramid", "sierpinski", or "checker"`) } } // Generate renders a pattern, adds it to the on-chain gallery, and returns // the rendered art. func Generate(cur realm, pattern string, size int) string { if size < 1 || size > maxSize { panic("size must be between 1 and " + strconv.Itoa(maxSize)) } art := renderPattern(pattern, size) author := cur.Previous().Address() id := gallery.Add(&piece{ Pattern: pattern, Size: size, Author: author, CreatedAt: runtime.ChainHeight(), Art: art, }) chain.Emit("ArtGenerated", "id", id.String(), "pattern", pattern, "author", author.String()) return art } func renderHome() string { var b strings.Builder b.WriteString("# ASCII Art Generator\n\n") b.WriteString("Four deterministic patterns, no font table required -- each shape " + "falls out of a short formula over row/column indices. Pick a pattern and a " + "size (1-" + strconv.Itoa(maxSize) + ", sierpinski wants a power of two) and " + "`Generate` adds it to the gallery below.\n\n") b.WriteString("## Patterns\n\n") b.WriteString("- `diamond` -- filled diamond, half-width `size`\n") b.WriteString("- `pyramid` -- solid triangle, `size` rows tall\n") b.WriteString("- `sierpinski` -- Sierpinski gasket in an n×n grid " + "(`row & col == 0`), `size` a power of two\n") b.WriteString("- `checker` -- n×n checkerboard\n\n") b.WriteString("Call `Generate(pattern, size)`. View a piece at this realm's path " + "plus its ID (e.g. `.../asciiart:3`).\n\n") b.WriteString("## Gallery (total: " + gallery.LastID().String() + ")\n\n") if gallery.Len() == 0 { b.WriteString("_nothing generated yet -- be the first_\n") return b.String() } // The newest page, straight from the store: no counter, and only that // window is walked. for _, e := range gallery.PageReverse(1, galleryPageSize) { p := e.Value.(*piece) b.WriteString("### #" + e.ID.String() + " -- " + p.Pattern + " (size " + strconv.Itoa(p.Size) + ")\n\n") b.WriteString("by `" + p.Author.String() + "` at block " + strconv.Itoa(int(p.CreatedAt)) + "\n\n") b.WriteString("```\n" + p.Art + "\n```\n\n") } return b.String() } func renderPiece(idStr string) string { safe := ui.Inline(idStr) id, ok := store.ParseID(idStr) if !ok { return "# Piece " + safe + "\n\nNo such piece.\n" } v, ok := gallery.Get(id) if !ok { return "# Piece #" + safe + "\n\nNo such piece.\n" } p := v.(*piece) var b strings.Builder b.WriteString("# Piece #" + id.String() + "\n\n") b.WriteString("- Pattern: " + p.Pattern + "\n") b.WriteString("- Size: " + strconv.Itoa(p.Size) + "\n") b.WriteString("- Author: `" + p.Author.String() + "`\n") b.WriteString("- Block: " + strconv.Itoa(int(p.CreatedAt)) + "\n\n") b.WriteString("```\n" + p.Art + "\n```\n") return b.String() } // Render shows the pattern guide + gallery at "", or one piece by numeric ID. func Render(path string) string { path = strings.TrimPrefix(strings.TrimSpace(path), "/") if path == "" { return renderHome() } return renderPiece(path) }
  6. #6asciiart_test.gno
  7. #7package asciiart import ( "strings" "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/testutils/v0" ) func resetState() { gallery = store.Named("piece") } func TestRenderDiamond(t *testing.T) { got := RenderDiamond(3) want := " *\n ***\n*****\n ***\n *" if got != want { t.Fatalf("RenderDiamond(3) =\n%s\nwant\n%s", got, want) } } func TestRenderPyramid(t *testing.T) { got := RenderPyramid(3) want := " *\n ***\n*****" if got != want { t.Fatalf("RenderPyramid(3) =\n%s\nwant\n%s", got, want) } } func TestRenderSierpinski(t *testing.T) { got := RenderSierpinski(4) want := "####\n# # \n## \n# " if got != want { t.Fatalf("RenderSierpinski(4) =\n%s\nwant\n%s", got, want) } } func TestRenderSierpinskiRejectsNonPowerOfTwo(t *testing.T) { defer func() { if r := recover(); r == nil { t.Fatal("expected panic for a non-power-of-two size") } }() RenderSierpinski(5) } func TestRenderChecker(t *testing.T) { got := RenderChecker(3) want := "#.#\n.#.\n#.#" if got != want { t.Fatalf("RenderChecker(3) =\n%s\nwant\n%s", got, want) } } func TestRenderPatternUnknown(t *testing.T) { defer func() { if r := recover(); r == nil { t.Fatal("expected panic for an unknown pattern") } }() renderPattern("spiral", 3) } // v1 asserted that its own padID() padded to width 6. That is the narrowest // ceiling in this repo: padID(10^6) is 7 characters and sorts before // padID(10^6-1), so from the millionth piece on the gallery would have // rendered out of order. The store key is 8 bytes for every id. func TestPadID(t *testing.T) { if a, b := store.ID(2).Key(), store.ID(10).Key(); !(a < b) { t.Error("id 2 should sort before id 10") } if a, b := store.ID(999999).Key(), store.ID(1000000).Key(); !(a < b) { t.Error("ordering should survive one past v1's width-6 ceiling") } } // Crossing: Generate mutates realm state, so the test needs `cur realm`. func TestGenerateAddsToGallery(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) art := Generate(cross(cur), "checker", 3) if art != RenderChecker(3) { t.Fatal("Generate should return the rendered art") } if gallery.LastID() != 1 { t.Fatalf("LastID = %s, want 1", gallery.LastID()) } // The store's Get returns (value, ok), so a stored nil is distinguishable // from an absent id, which avl/v0's single-value Get cannot express. v, ok := gallery.Get(1) if !ok { t.Fatal("expected piece #1 to exist in the gallery") } p := v.(*piece) if p.Pattern != "checker" || p.Size != 3 || p.Author != alice { t.Fatalf("unexpected piece: %+v", p) } } func TestGenerateRejectsOutOfRangeSize(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) rec := revive(func() { Generate(cross(cur), "diamond", 0) }) if rec == nil { t.Fatal("expected panic for size < 1") } rec = revive(func() { Generate(cross(cur), "diamond", maxSize+1) }) if rec == nil { t.Fatal("expected panic for size > maxSize") } } // Non-crossing: Render is a plain read. func TestRenderHomeEmptyGallery(t *testing.T) { resetState() out := Render("") if !strings.Contains(out, "nothing generated yet") { t.Fatal(`Render("") on an empty gallery should say so`) } } func TestRenderPieceNotFound(t *testing.T) { resetState() out := Render("99") if !strings.Contains(out, "No such piece") { t.Fatal(`Render("99") on a missing piece should say so`) } }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/x/daily/asciiart/v1" gno = "0.9" private = true
#8AddPackagegno.land/r/moul/x/daily/blog/v19 arguments
Attached funds
5000000ugnot

Arguments · 9

  1. #1blog
  2. #2README.md
  3. #3# Personal Blog > ⚠️ **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 multi-author on-chain blog realm for gno.land. Anyone can publish a post; each post records its author (the calling address) and the block height at which it was published. Posts are stored persistently and rendered newest-first, with a dedicated page for each post's full text. **Realm path:** `gno.land/r/REPLACE_ADDR/blog` ## Example calls Publish a post (state-mutating, crossing function): ``` gnokey maketx call -pkgpath "gno.land/r/REPLACE_ADDR/blog" \ -func Publish -args "My first post" -args "Hello, gno.land!" \ -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid sapphire-1 KEY ``` Read views (no transaction needed): - Root — list all posts, newest first: render `gno.land/r/REPLACE_ADDR/blog` - Single post by id: render `gno.land/r/REPLACE_ADDR/blog:/0` `PostCount()` returns the total number of published posts. ## What changed in v1 State moved from a hand-rolled `avl.Tree` + `nextID` + zero-padding helper to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. v0 padded ids to width 12. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. Ids stay plain integers, and `Post` loses its `ID` field, which the store now owns. Two things a reader will notice: - **Post ids now start at 1.** `v0` handed out 0 for the first post; the store never assigns 0, so the zero id stays usable as "absent". - **`/0` is no longer a post path.** It is rejected as invalid rather than looked up, along with anything else that is not a plain decimal. This is a storage change, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/blog) stays live and untouched. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/blog/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/blog/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. #4blog.gno
  5. #5// Package blog is a multi-author on-chain blog realm for gno.land. // // Any caller can Publish a post; posts are stored persistently and rendered // newest-first. Each post records its author (the calling address) and the // block height at which it was published. package blog import ( "strconv" "strings" "chain" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/moul/kit/store/v0" ) // Post is a single blog entry. It carries no id field: the id belongs to the // store, which hands it back on lookup and iteration. type Post struct { Title string Body string Author address Height int64 } // posts holds every published post. The store keys by seqid, so iteration is // in publication order for any number of posts; v0 kept its own nextID plus a // key() that zero-padded to width 12 and stopped ordering past it. var posts = store.Named("post") // Publish creates a new post authored by the caller and returns its id. // It is a state-mutating exported function, so it takes `cur realm`. func Publish(cur realm, title string, body string) int { title = strings.TrimSpace(title) body = strings.TrimSpace(body) if title == "" { panic("blog: title must not be empty") } if body == "" { panic("blog: body must not be empty") } author := unsafe.PreviousRealm().Address() id := posts.Add(&Post{ Title: title, Body: body, Author: author, Height: runtime.ChainHeight(), }) chain.Emit( "PostPublished", "id", id.String(), "author", author.String(), ) return int(id) } // PostCount returns the number of published posts. func PostCount() int { return posts.Len() } // snippet returns a short one-line preview of the body. func snippet(body string) string { // collapse newlines so the preview stays on a single markdown line s := strings.ReplaceAll(body, "\n", " ") s = strings.TrimSpace(s) const max = 140 if len(s) > max { return s[:max] + "…" } return s } // Render lists all posts newest-first at the root, or a single post's full // text at path "/<id>". Render is NOT a crossing function. func Render(path string) string { path = strings.TrimSpace(path) if path == "" || path == "/" { return renderList() } idStr := strings.TrimPrefix(path, "/") id, ok := store.ParseID(idStr) if !ok { return "# Not found\n\nInvalid post path: `" + path + "`\n\n[← back](/r/REPLACE_ADDR/blog)\n" } v, ok := posts.Get(id) if !ok { return "# Not found\n\nNo post with id " + idStr + ".\n\n[← back](/r/REPLACE_ADDR/blog)\n" } return renderPost(v.(*Post)) } func renderList() string { var b strings.Builder b.WriteString("# 📝 Blog\n\n") if posts.Len() == 0 { b.WriteString("_No posts yet. Be the first to `Publish`._\n") return b.String() } b.WriteString(strconv.Itoa(posts.Len()) + " post(s), newest first.\n\n") // Ids ascend with publication, so reverse iteration is newest-first. posts.EachReverse(func(id store.ID, v any) { p := v.(*Post) b.WriteString("## [") b.WriteString(p.Title) b.WriteString("](/r/REPLACE_ADDR/blog:/") b.WriteString(id.String()) b.WriteString(")\n\n") b.WriteString("by `") b.WriteString(p.Author.String()) b.WriteString("` · height ") b.WriteString(strconv.FormatInt(p.Height, 10)) b.WriteString("\n\n") b.WriteString(snippet(p.Body)) b.WriteString("\n\n---\n\n") }) return b.String() } func renderPost(p *Post) string { var b strings.Builder b.WriteString("# ") b.WriteString(p.Title) b.WriteString("\n\n") b.WriteString("by `") b.WriteString(p.Author.String()) b.WriteString("` · height ") b.WriteString(strconv.FormatInt(p.Height, 10)) b.WriteString("\n\n") b.WriteString(p.Body) b.WriteString("\n\n---\n\n[← back to all posts](/r/REPLACE_ADDR/blog)\n") return b.String() }
  6. #6blog_test.gno
  7. #7package blog import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) func TestPublishAndRender(cur realm, t *testing.T) { alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) // Ids start at 1 in v1: the store never assigns 0, so the zero id stays // usable as "absent". v0 handed out 0 for the first post. id0 := Publish(cross(cur), "First Post", "Hello from alice.") uassert.Equal(t, 1, id0) testing.SetRealm(testing.NewUserRealm(bob)) id1 := Publish(cross(cur), "Second Post", "A longer body\nwith a newline from bob.") uassert.Equal(t, 2, id1) uassert.Equal(t, 2, PostCount()) // Root render lists both, newest (bob's) first. list := Render("") uassert.True(t, strings.Contains(list, "First Post"), "list has first post") uassert.True(t, strings.Contains(list, "Second Post"), "list has second post") uassert.True(t, strings.Index(list, "Second Post") < strings.Index(list, "First Post"), "newest post appears first") uassert.True(t, strings.Contains(list, alice.String()), "list shows alice author") // Single-post render shows full body + back link. single := Render("/2") uassert.True(t, strings.Contains(single, "newline from bob"), "full body shown") uassert.True(t, strings.Contains(single, "← back"), "back link present") // Unknown id. uassert.True(t, strings.Contains(Render("/99"), "Not found"), "missing post handled") // Id 0 is never assigned, so the path is rejected rather than looked up. uassert.True(t, strings.Contains(Render("/0"), "Invalid post path"), "id 0 is not a post") uassert.True(t, strings.Contains(Render("/abc"), "Invalid post path"), "a non-numeric path is rejected") } func TestPublishEmptyTitleAborts(cur realm, t *testing.T) { carol := testutils.TestAddress("carol") testing.SetRealm(testing.NewUserRealm(carol)) uassert.AbortsWithMessage(t, cur, "blog: title must not be empty", func() { Publish(cross(cur), " ", "some body") }) uassert.AbortsWithMessage(t, cur, "blog: body must not be empty", func() { Publish(cross(cur), "a title", "") }) }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/x/daily/blog/v1" gno = "0.9" private = true
Attached funds
6000000ugnot

Arguments · 9

  1. #1connect4
  2. #2README.md
  3. #3# Connect Four > ⚠️ **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 two-player Connect Four realm on a 7-wide × 6-tall board. The game creator plays 🔴 and moves first; the named opponent plays 🟡. `Drop` enforces turn order by caller, rejects full and out-of-range columns, and detects a horizontal, vertical, or diagonal 4-in-a-row (or a draw). `Render` draws the board with 🔴🟡· and shows whose turn it is or who won. Realm path: `gno.land/r/REPLACE_ADDR/connect4` ## Example calls ``` # create a game against another address (you are 🔴, returns the game id) NewGame(g1zabc...opponent) # -> 1 # drop discs into columns 0-6 (turns alternate, enforced by caller) Drop(1, 3) # 🔴 drops in column 3 Drop(1, 3) # 🟡 drops in column 3 (called from opponent's key) # view state render: # lists all games render:1 # shows board for game 1 ``` ## What changed in v1 Two ports against [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/connect4), which stays live and untouched. ### Rendering Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/connect4) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `short` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. ### Storage State moved from a hand-rolled `avl.Tree` + `nextID` + zero-padding helper to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. `v0` padded ids to width 12. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. `Game` loses its `ID` field, which the store now owns, so `Drop` emits with the `gameID` it was already given and `renderGame` takes the id alongside the game. `Drop`'s trailing `games.Set(...)` is gone as well: it re-stored a pointer that was already in the tree, and mutating through it persists on its own, which is how the rest of this realm already worked. Ids stay plain integers and the rendered output of the storage port is unchanged. Each port changes something `v0` promised, one the rendered output and one the storage layout, so under this repo's versioning rule each is a compatibility change and neither could be an edit to `v0` in place. They land in the **same** new version because `v1` was never published. A version number is a tag on something that exists on a chain, and until it does there is nothing for a second number to avoid disturbing, so the right move is to keep editing the version you have. `gnopm unbump` is what folded the second port back down into this one. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/connect4/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/connect4/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. #4connect4.gno
  5. #5package connect4 import ( "chain" "chain/runtime/unsafe" "errors" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/kit/store/v0" ) const ( cols = 7 rows = 6 ) // cell values const ( empty = 0 red = 1 // 🔴 game creator (caller of NewGame) yel = 2 // 🟡 opponent ) // Game holds the full state of one Connect Four match. // board is indexed board[row][col]; row 0 is the BOTTOM row. // It carries no ID field: the id belongs to the store, which hands it back on // lookup and iteration. type Game struct { Red address // 🔴 creator, moves first Yellow address // 🟡 opponent Board [rows][cols]int Turn int // whose turn: red or yel Winner int // empty until decided; red/yel = winner Draw bool // true when board full with no winner Finished bool } // games assigns the game ids. v1 kept its own nextID plus an idKey() that // zero-padded to width 12, which stopped ordering the Render list past 10^12. var games = store.Named("game") // NewGame creates a match between the caller (🔴) and opponent (🟡). // Returns the new game id. func NewGame(cur realm, opponent address) int64 { caller := unsafe.PreviousRealm().Address() if opponent == caller { panic("opponent must differ from caller") } if opponent.String() == "" { panic("opponent address is empty") } id := games.Add(&Game{ Red: caller, Yellow: opponent, Turn: red, }) chain.Emit("GameCreated", "id", id.String(), "red", caller.String(), "yellow", opponent.String(), ) return int64(id) } func getGame(id int64) (*Game, error) { v, ok := games.Get(store.ID(id)) if !ok { return nil, errors.New("game not found: " + strconv.FormatInt(id, 10)) } return v.(*Game), nil } // Drop places the caller's disc into the given column (0-6). // Enforces turn order by caller, rejects full columns, and detects // a 4-in-a-row win or a draw. func Drop(cur realm, gameID int64, column int) { if column < 0 || column >= cols { panic("column out of range (0-6)") } g, err := getGame(gameID) if err != nil { panic(err.Error()) } if g.Finished { panic("game already finished") } caller := unsafe.PreviousRealm().Address() var mover int switch caller { case g.Red: mover = red case g.Yellow: mover = yel default: panic("caller is not a player in this game") } if mover != g.Turn { panic("not your turn") } // find lowest empty row in the column placed := -1 for r := 0; r < rows; r++ { if g.Board[r][column] == empty { g.Board[r][column] = mover placed = r break } } if placed == -1 { panic("column is full") } if wins(&g.Board, placed, column, mover) { g.Winner = mover g.Finished = true chain.Emit("GameWon", "id", strconv.FormatInt(gameID, 10), "winner", caller.String(), ) } else if full(&g.Board) { g.Draw = true g.Finished = true chain.Emit("GameDraw", "id", strconv.FormatInt(gameID, 10)) } else { if g.Turn == red { g.Turn = yel } else { g.Turn = red } chain.Emit("DiscDropped", "id", strconv.FormatInt(gameID, 10), "col", strconv.Itoa(column), "player", caller.String(), ) } } func full(b *[rows][cols]int) bool { for c := 0; c < cols; c++ { if b[rows-1][c] == empty { return false } } return true } // wins checks whether the disc just placed at (r,c) for player p completes // a run of 4 in any of the four directions. func wins(b *[rows][cols]int, r, c, p int) bool { // direction pairs: horizontal, vertical, diag /, diag \ dirs := [4][2]int{{0, 1}, {1, 0}, {1, 1}, {1, -1}} for _, d := range dirs { count := 1 count += run(b, r, c, d[0], d[1], p) count += run(b, r, c, -d[0], -d[1], p) if count >= 4 { return true } } return false } func run(b *[rows][cols]int, r, c, dr, dc, p int) int { n := 0 for i := 1; i < 4; i++ { rr := r + dr*i cc := c + dc*i if rr < 0 || rr >= rows || cc < 0 || cc >= cols { break } if b[rr][cc] != p { break } n++ } return n } func glyph(v int) string { switch v { case red: return "🔴" case yel: return "🟡" default: return "·" } } // Render draws the board with 🔴🟡· and shows whose turn / the winner. // path "" lists all games; path "<id>" shows a single board. func Render(path string) string { path = strings.TrimSpace(strings.Trim(path, "/")) if path == "" { return renderList() } id, ok := store.ParseID(path) if !ok { return "# Connect Four\n\nInvalid game id: `" + path + "`\n" } g, err := getGame(int64(id)) if err != nil { return "# Connect Four\n\n" + err.Error() + "\n" } return renderGame(id, g) } func renderList() string { var sb strings.Builder sb.WriteString("# Connect Four\n\n") sb.WriteString("Two-player Connect Four on a 7×6 board. 🔴 is the game creator, 🟡 the opponent.\n\n") if games.Len() == 0 { sb.WriteString("_No games yet. Call `NewGame(opponent)` to start one._\n") return sb.String() } sb.WriteString("## Games\n\n") sb.WriteString("| ID | 🔴 Red | 🟡 Yellow | Status |\n") sb.WriteString("|----|--------|-----------|--------|\n") games.Each(func(id store.ID, v any) { g := v.(*Game) status := "" switch { case g.Winner == red: status = "🔴 won" case g.Winner == yel: status = "🟡 won" case g.Draw: status = "draw" case g.Turn == red: status = "🔴 to move" default: status = "🟡 to move" } sb.WriteString("| [" + id.String() + "](/r:" + id.String() + ") | " + ui.Addr(g.Red) + " | " + ui.Addr(g.Yellow) + " | " + status + " |\n") }) sb.WriteString("\nOpen a game by its id (e.g. path `1`).\n") return sb.String() } func renderGame(id store.ID, g *Game) string { var sb strings.Builder sb.WriteString("# Connect Four — Game " + id.String() + "\n\n") sb.WriteString("- 🔴 Red: `" + g.Red.String() + "`\n") sb.WriteString("- 🟡 Yellow: `" + g.Yellow.String() + "`\n\n") // status line switch { case g.Winner == red: sb.WriteString("**🔴 Red wins!**\n\n") case g.Winner == yel: sb.WriteString("**🟡 Yellow wins!**\n\n") case g.Draw: sb.WriteString("**Draw — board full.**\n\n") case g.Turn == red: sb.WriteString("**Turn: 🔴 Red**\n\n") default: sb.WriteString("**Turn: 🟡 Yellow**\n\n") } // board top-down (row rows-1 at top, row 0 at bottom) sb.WriteString("```\n") for r := rows - 1; r >= 0; r-- { for c := 0; c < cols; c++ { sb.WriteString(glyph(g.Board[r][c])) } sb.WriteString("\n") } // column indices for c := 0; c < cols; c++ { sb.WriteString(strconv.Itoa(c)) } sb.WriteString("\n```\n\n") if !g.Finished { sb.WriteString("Drop a disc: `Drop(" + id.String() + ", <col 0-6>)`\n") } return sb.String() }
  6. #6connect4_test.gno
  7. #7package connect4 import ( "strings" "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( alice = testutils.TestAddress("alice") // 🔴 bob = testutils.TestAddress("bob") // 🟡 carol = testutils.TestAddress("carol") ) func resetState() { games = store.Named("game") } // TestVerticalWin plays 🔴 stacking column 0 four high while 🟡 plays column 1. func TestVerticalWin(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) id := NewGame(cross(cur), bob) uassert.Equal(t, int64(1), id) for i := 0; i < 3; i++ { testing.SetRealm(testing.NewUserRealm(alice)) Drop(cross(cur), id,0) testing.SetRealm(testing.NewUserRealm(bob)) Drop(cross(cur), id,1) } // alice's 4th disc in column 0 -> vertical win testing.SetRealm(testing.NewUserRealm(alice)) Drop(cross(cur), id,0) g, err := getGame(id) uassert.NoError(t, err) uassert.True(t, g.Finished) uassert.Equal(t, red, g.Winner) out := Render("1") uassert.True(t, strings.Contains(out, "🔴 Red wins!")) } // TestTurnOrderAndErrors covers turn enforcement, non-player, full-column and range checks. func TestTurnOrderAndErrors(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) id := NewGame(cross(cur), bob) // bob cannot move first (red starts) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsWithMessage(t, cur,"not your turn", func() { Drop(cross(cur), id,0) }) // stranger cannot play testing.SetRealm(testing.NewUserRealm(carol)) uassert.AbortsWithMessage(t, cur,"caller is not a player in this game", func() { Drop(cross(cur), id,0) }) // out-of-range column testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur,"column out of range (0-6)", func() { Drop(cross(cur), id,7) }) // fill column 3: alice & bob alternate, 6 discs total col := 3 for i := 0; i < 3; i++ { testing.SetRealm(testing.NewUserRealm(alice)) Drop(cross(cur), id,col) testing.SetRealm(testing.NewUserRealm(bob)) Drop(cross(cur), id,col) } // column now full; whoever is on turn overflows it g, _ := getGame(id) var mover address if g.Turn == red { mover = alice } else { mover = bob } testing.SetRealm(testing.NewUserRealm(mover)) uassert.AbortsWithMessage(t, cur,"column is full", func() { Drop(cross(cur), id,col) }) } // TestHorizontalWin: 🔴 across columns 0-3 on the bottom row. func TestHorizontalWin(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) id := NewGame(cross(cur), bob) // alice drops cols 0,1,2 ; bob answers on row above (col 0,1,2) for c := 0; c < 3; c++ { testing.SetRealm(testing.NewUserRealm(alice)) Drop(cross(cur), id,c) testing.SetRealm(testing.NewUserRealm(bob)) Drop(cross(cur), id,c) } // alice completes col 3 on bottom row -> 0,1,2,3 horizontal testing.SetRealm(testing.NewUserRealm(alice)) Drop(cross(cur), id,3) g, err := getGame(id) uassert.NoError(t, err) uassert.True(t, g.Finished) uassert.Equal(t, red, g.Winner) } // TestRenderList checks the listing view renders created games. func TestRenderList(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) NewGame(cross(cur), bob) out := Render("") uassert.True(t, strings.Contains(out, "Connect Four")) uassert.True(t, strings.Contains(out, "to move")) }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/x/daily/connect4/v1" gno = "0.9" private = true
#10AddPackagegno.land/r/moul/x/daily/crowdfund/v111 arguments
Attached funds
6000000ugnot

Arguments · 11

  1. #1crowdfund
  2. #2README.md
  3. #3# Crowdfund > ⚠️ **Experimental — generated with no human supervision** by the daily MCP pipeline (Solidity→Gno port). Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md). --- A gno.land realm port of the classic Solidity **Kickstarter-style crowdfunding** contract. It keeps the same lifecycle — launch a campaign with a goal and a deadline, let backers pledge and unpledge, then either let the creator claim on success or let backers refund on failure — but models everything as plain `uint64` accounting (no real coin transfer), the idiomatic Gno equivalent of the Solidity `mapping(address => uint)` balances. Deadlines use `runtime.ChainHeight()` (block height) instead of `block.timestamp`, `msg.sender` becomes `unsafe.PreviousRealm().Address()`, `require` becomes `panic`, and campaigns live in an ordered `avl.Tree` so `Render` lists them deterministically with a `▓░` progress bar. ## Transactions - `Launch(goal uint64, durationBlocks int) int` — create a campaign (returns id); deadline = current height + duration. - `Pledge(id int, amount uint64)` — back a campaign before its deadline. - `Unpledge(id int, amount uint64)` — withdraw part/all of your pledge before the deadline. - `Claim(id int)` — creator claims funds after the deadline if `pledged >= goal`. - `Refund(id int)` — backer reclaims their pledge after the deadline if the goal was missed. ## Example ``` Launch(1000, 100) // campaign #1, goal 1000, ends 100 blocks from now -> returns 1 Pledge(1, 400) // back campaign #1 with 400 Pledge(1, 700) // total now 1100 (>= goal) Unpledge(1, 100) // change of mind: total back to 1000 // ... after block height passes the deadline ... Claim(1) // creator claims 1000 (goal met) ``` Read-only `Render("")` shows every campaign with its progress bar, goal, pledged total, deadline block and state (Active / Funded / Funded (claimed) / Failed). ## What changed in v1 State moved from a hand-rolled `avl.Tree` + `nextID` + zero-padding helper to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. v0 padded ids to width 12. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. `Campaign` loses its `ID` field, which the store now owns, and the realm's local `mustGet` becomes one line: the store's labelled `MustGet` panics `campaign #7 not found` where `v0` said only `campaign not found`. Each campaign keeps its own address-keyed `pledges` tree, which the store does not cover. Campaign ids already started at 1, so nothing shifts. This is a storage change, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/crowdfund) stays live and untouched. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/crowdfund/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/crowdfund/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. #4crowdfund.gno
  5. #5// Package crowdfund is an accounting-only port of the classic Solidity // Kickstarter-style crowdfunding contract to a gno.land realm. // // A creator Launches a campaign with a funding goal and a duration (in blocks). // Backers Pledge (and may Unpledge before the deadline). After the deadline the // creator can Claim if the goal was met, otherwise backers can Refund. // // No real coin moves: pledges are tracked as plain uint64 accounting, exactly // like the mapping(address => uint) balances of the Solidity original. package crowdfund import ( "strconv" "chain" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/avl/v0" ) // Campaign holds the state of a single crowdfunding campaign. It carries no ID // field: the id belongs to the store, which hands it back on lookup and // iteration. type Campaign struct { Creator address Goal uint64 Deadline int64 // block height after which the campaign is closed Pledged uint64 // running total pledged Claimed bool // creator already claimed the funds pledges *avl.Tree // backer address (string) -> pledged uint64 } // campaigns assigns the campaign ids. v0 kept its own nextID plus a key() that // zero-padded to width 12, which stopped ordering Render past 10^12, and its // own mustGet whose panic did not say which campaign was missing. var campaigns = store.Named("campaign") func mustGet(id int) *Campaign { return campaigns.MustGet(store.ID(id)).(*Campaign) } // Launch creates a new campaign owned by the caller and returns its id. func Launch(cur realm, goal uint64, durationBlocks int) int { if goal == 0 { panic("goal must be > 0") } if durationBlocks <= 0 { panic("duration must be > 0") } caller := unsafe.PreviousRealm().Address() id := campaigns.Add(&Campaign{ Creator: caller, Goal: goal, Deadline: runtime.ChainHeight() + int64(durationBlocks), pledges: avl.NewTree(), }) chain.Emit("Launch", "id", id.String(), "creator", caller.String(), "goal", strconv.FormatUint(goal, 10)) return int(id) } // Pledge backs campaign id with amount (before the deadline). func Pledge(cur realm, id int, amount uint64) { if amount == 0 { panic("amount must be > 0") } c := mustGet(id) if runtime.ChainHeight() > c.Deadline { panic("campaign ended") } caller := unsafe.PreviousRealm().Address() prev := uint64(0) if v := c.pledges.Get(caller.String()); v != nil { prev = v.(uint64) } c.pledges.Set(caller.String(), prev+amount) c.Pledged += amount chain.Emit("Pledge", "id", strconv.Itoa(id), "from", caller.String(), "amount", strconv.FormatUint(amount, 10)) } // Unpledge withdraws amount from the caller's pledge (before the deadline). func Unpledge(cur realm, id int, amount uint64) { if amount == 0 { panic("amount must be > 0") } c := mustGet(id) if runtime.ChainHeight() > c.Deadline { panic("campaign ended") } caller := unsafe.PreviousRealm().Address() v := c.pledges.Get(caller.String()) if v == nil { panic("nothing pledged") } have := v.(uint64) if amount > have { panic("amount exceeds pledge") } if remaining := have - amount; remaining == 0 { c.pledges.Remove(caller.String()) } else { c.pledges.Set(caller.String(), remaining) } c.Pledged -= amount chain.Emit("Unpledge", "id", strconv.Itoa(id), "from", caller.String(), "amount", strconv.FormatUint(amount, 10)) } // Claim lets the creator take the funds if the goal was met after the deadline. func Claim(cur realm, id int) { c := mustGet(id) if runtime.ChainHeight() <= c.Deadline { panic("campaign not ended") } caller := unsafe.PreviousRealm().Address() if caller != c.Creator { panic("only creator can claim") } if c.Pledged < c.Goal { panic("goal not reached") } if c.Claimed { panic("already claimed") } c.Claimed = true chain.Emit("Claim", "id", strconv.Itoa(id), "amount", strconv.FormatUint(c.Pledged, 10)) } // Refund returns the caller's pledge if the campaign failed after the deadline. func Refund(cur realm, id int) { c := mustGet(id) if runtime.ChainHeight() <= c.Deadline { panic("campaign not ended") } if c.Pledged >= c.Goal { panic("campaign succeeded, no refund") } caller := unsafe.PreviousRealm().Address() v := c.pledges.Get(caller.String()) if v == nil { panic("nothing to refund") } amount := v.(uint64) c.pledges.Remove(caller.String()) c.Pledged -= amount chain.Emit("Refund", "id", strconv.Itoa(id), "to", caller.String(), "amount", strconv.FormatUint(amount, 10)) } // --- pure/read-only helpers (also used by Render) --- // pct returns the funded percentage (0..100). func pct(pledged, goal uint64) int { if goal == 0 { return 0 } p := int(pledged * 100 / goal) if p > 100 { p = 100 } return p } // progressBar renders a fixed-width ▓░ bar for pledged/goal. func progressBar(pledged, goal uint64) string { const width = 20 filled := 0 if goal > 0 { filled = int(pledged * width / goal) if filled > width { filled = width } } bar := "" for i := 0; i < width; i++ { if i < filled { bar += "▓" } else { bar += "░" } } return bar } // state describes a campaign relative to a given block height. func state(c *Campaign, height int64) string { if height <= c.Deadline { return "Active" } if c.Pledged >= c.Goal { if c.Claimed { return "Funded (claimed)" } return "Funded" } return "Failed" } // Render shows all campaigns with a progress bar, goal, pledged and state. func Render(path string) string { if campaigns.Len() == 0 { return "# Crowdfund\n\nNo campaigns yet. Call `Launch(goal, durationBlocks)` to start one.\n" } height := runtime.ChainHeight() out := "# Crowdfund\n\n" out += "Current block height: **" + strconv.FormatInt(height, 10) + "**\n\n" campaigns.Each(func(id store.ID, v any) { c := v.(*Campaign) out += "## Campaign #" + id.String() + "\n\n" out += "`" + progressBar(c.Pledged, c.Goal) + "` " + strconv.Itoa(pct(c.Pledged, c.Goal)) + "%\n\n" out += "- State: **" + state(c, height) + "**\n" out += "- Goal: " + strconv.FormatUint(c.Goal, 10) + "\n" out += "- Pledged: " + strconv.FormatUint(c.Pledged, 10) + "\n" out += "- Deadline: block " + strconv.FormatInt(c.Deadline, 10) + "\n" out += "- Creator: `" + c.Creator.String() + "`\n\n" }) return out }
  6. #6crowdfund_test.gno
  7. #7package crowdfund import ( "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/uassert/v0" ) func TestProgressBarAndPct(t *testing.T) { uassert.Equal(t, "░░░░░░░░░░░░░░░░░░░░", progressBar(0, 100)) uassert.Equal(t, "▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░", progressBar(50, 100)) uassert.Equal(t, "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓", progressBar(100, 100)) // over-funded is capped at full width uassert.Equal(t, "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓", progressBar(250, 100)) // zero goal never divides by zero uassert.Equal(t, "░░░░░░░░░░░░░░░░░░░░", progressBar(10, 0)) uassert.Equal(t, 0, pct(0, 100)) uassert.Equal(t, 25, pct(25, 100)) uassert.Equal(t, 100, pct(100, 100)) uassert.Equal(t, 100, pct(500, 100)) // capped uassert.Equal(t, 0, pct(5, 0)) // no goal } func TestState(t *testing.T) { active := &Campaign{Goal: 100, Pledged: 10, Deadline: 100} uassert.Equal(t, "Active", state(active, 50)) uassert.Equal(t, "Active", state(active, 100)) // deadline block still active funded := &Campaign{Goal: 100, Pledged: 150, Deadline: 100} uassert.Equal(t, "Funded", state(funded, 101)) claimed := &Campaign{Goal: 100, Pledged: 150, Deadline: 100, Claimed: true} uassert.Equal(t, "Funded (claimed)", state(claimed, 101)) failed := &Campaign{Goal: 100, Pledged: 50, Deadline: 100} uassert.Equal(t, "Failed", state(failed, 101)) // exactly meeting the goal counts as funded exact := &Campaign{Goal: 100, Pledged: 100, Deadline: 100} uassert.Equal(t, "Funded", state(exact, 200)) } // v0 asserted that its own key() padded to width 12. That rule held only below // the width: key(10^12) is 13 characters and sorts before key(10^12-1), so the // campaign list would have rendered out of order from that id on. The store key // has no width to outgrow. func TestKeyOrdering(t *testing.T) { uassert.True(t, store.ID(2).Key() < store.ID(10).Key()) uassert.True(t, store.ID(9).Key() < store.ID(100).Key()) uassert.True(t, store.ID(999999999999).Key() < store.ID(1000000000000).Key(), "and it keeps holding one past v0's width-12 ceiling") }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/x/daily/crowdfund/v1" gno = "0.9" private = true
  10. #10render_example_test.gno
  11. #11package crowdfund // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # Crowdfund // // No campaigns yet. Call `Launch(goal, durationBlocks)` to start one. }
Attached funds
5000000ugnot

Arguments · 11

  1. #1englishauction
  2. #2README.md
  3. #3# English Auction > ⚠️ **Experimental — generated with no human supervision** by the daily MCP pipeline (Solidity→Gno port). Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md). --- An idiomatic gno.land port of the classic Solidity **English (ascending-bid) auction**. It ports the core mechanics — a seller opens an auction, bidders submit strictly increasing bids, the displaced top bid becomes refundable, and after a block deadline anyone can settle it — into a single realm that hosts many concurrent auctions. Solidity's `msg.sender` maps to `unsafe.PreviousRealm().Address()`, `block.number` to `runtime.ChainHeight()`, `require` to `panic`, and events to `chain.Emit`. There is no real coin transfer: bid amounts and refunds are tracked as plain `uint64` accounting in ordered `avl.Tree`s (mirroring Solidity's `pendingReturns` withdraw pattern). Auctions and pending refunds are keyed so `Render` can iterate deterministically. ## Transactions - `Start(cur, item string, durationBlocks int64) int64` — open an auction (caller becomes the seller); ends at `ChainHeight() + durationBlocks`. Returns the id. - `Bid(cur, id int64, amount uint64)` — bid; must strictly exceed the current highest. The prior top bid becomes refundable. - `Withdraw(cur, id int64) uint64` — reclaim your displaced bids for an auction. - `End(cur, id int64)` — after the deadline, award the item to the top bidder. ## Example calls ``` # open a 100-block auction for a painting -> returns id 1 Start("Vintage painting", 100) # two bidders compete (amounts are accounting units, not real coins) Bid(1, 500) # alice Bid(1, 750) # bob outbids -> alice's 500 is now refundable Withdraw(1) # alice reclaims 500 # once ChainHeight() >= endHeight, anyone settles it End(1) # item awarded to bob at 750 ``` Visit the realm to see the live table of auctions: item, highest bid + bidder, blocks remaining or ended, and the winner. ## What changed in v1 State moved from a hand-rolled `avl.Tree` + `nextID` + zero-padding helper to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. v0 padded ids to width 12. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. `Auction` loses its `ID` field, which the store now owns, and the local `mustGet` becomes one line: the store's labelled `MustGet` panics `englishauction: auction #7 not found` where `v0` said only `englishauction: no such auction`. `pending`, the refund ledger, keeps its own tree and its composite `"<id>:<addr>"` key, now built from the plain decimal id instead of the padded one. It is only ever point-read, never iterated, so it needs a readable key and not an ordered one. Auction ids already started at 1, so nothing shifts. This is a storage change, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/englishauction) stays live and untouched. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/englishauction/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/englishauction/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. #4englishauction.gno
  5. #5// Package englishauction is an idiomatic gno.land port of the classic Solidity // English (ascending-bid) auction. Anyone can Start an auction for a named item // with a duration in blocks; bidders call Bid with a strictly increasing amount. // The previous highest bid becomes refundable (claim it with Withdraw). After // the auction's end height anyone may call End, awarding the item to the highest // bidder. Auctions and pending refunds live in ordered avl trees so Render can // iterate deterministically. package englishauction import ( "strconv" "chain" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/avl/v0" ) // Auction is a single ascending-bid auction. It carries no ID field: the id // belongs to the store, which hands it back on lookup and iteration. type Auction struct { Seller address Item string EndHeight int64 HighestBid uint64 HighestBidder address Ended bool } var ( // auctions assigns the auction ids. v0 kept its own nextID plus a key() // that zero-padded to width 12, which stopped ordering Render past 10^12. auctions = store.Named("englishauction: auction") pending avl.Tree // "<id>:<addr>" -> uint64 refundable amount ) // pendKey formats the refund key for a given auction id and bidder. It uses // the decimal id rather than the store key: pending is only ever point-read, // never iterated, so it needs a readable key and not an ordered one. func pendKey(id int64, bidder address) string { return strconv.FormatInt(id, 10) + ":" + bidder.String() } // Start opens a new auction for `item` running for `durationBlocks` blocks from // the current height. The caller becomes the seller. Returns the auction id. func Start(cur realm, item string, durationBlocks int64) int64 { if item == "" { panic("englishauction: item must not be empty") } if durationBlocks <= 0 { panic("englishauction: duration must be > 0 blocks") } seller := unsafe.PreviousRealm().Address() a := &Auction{ Seller: seller, Item: item, EndHeight: runtime.ChainHeight() + durationBlocks, } id := int64(auctions.Add(a)) chain.Emit("Start", "id", strconv.FormatInt(id, 10), "seller", seller.String(), "item", item, "endHeight", strconv.FormatInt(a.EndHeight, 10), ) return id } // Bid places a bid of `amount` on auction `id`. It must strictly exceed the // current highest bid. The displaced highest bid becomes refundable to its // bidder via Withdraw. func Bid(cur realm, id int64, amount uint64) { a := mustGet(id) if a.Ended || runtime.ChainHeight() >= a.EndHeight { panic("englishauction: auction has ended") } if amount <= a.HighestBid { panic("englishauction: bid must strictly exceed current highest") } bidder := unsafe.PreviousRealm().Address() if bidder == a.Seller { panic("englishauction: seller cannot bid") } // The prior top bid becomes refundable (accumulate in case of repeats). if a.HighestBid > 0 { pk := pendKey(id, a.HighestBidder) pending.Set(pk, pendingOf(id, a.HighestBidder)+a.HighestBid) } a.HighestBid = amount a.HighestBidder = bidder chain.Emit("Bid", "id", strconv.FormatInt(id, 10), "bidder", bidder.String(), "amount", strconv.FormatUint(amount, 10), ) } // Withdraw refunds the caller's accumulated displaced bids for auction `id`. // Returns the amount refunded (0 if nothing pending). func Withdraw(cur realm, id int64) uint64 { mustGet(id) // ensure auction exists caller := unsafe.PreviousRealm().Address() amount := pendingOf(id, caller) if amount == 0 { return 0 } pending.Remove(pendKey(id, caller)) chain.Emit("Withdraw", "id", strconv.FormatInt(id, 10), "bidder", caller.String(), "amount", strconv.FormatUint(amount, 10), ) return amount } // End closes auction `id` once its end height is reached, awarding the item to // the highest bidder (if any). Anyone may call it. func End(cur realm, id int64) { a := mustGet(id) if a.Ended { panic("englishauction: auction already ended") } if runtime.ChainHeight() < a.EndHeight { panic("englishauction: auction not yet over") } a.Ended = true chain.Emit("End", "id", strconv.FormatInt(id, 10), "winner", a.HighestBidder.String(), "amount", strconv.FormatUint(a.HighestBid, 10), ) } // --- read-only helpers (safe from tests and Render) --- // mustGet returns the auction for `id`, panicking if it does not exist. The // store's label puts the id in the message, which v0's fixed string // ("englishauction: no such auction") left out. func mustGet(id int64) *Auction { return auctions.MustGet(store.ID(id)).(*Auction) } // pendingOf returns the refundable amount owed to `bidder` for auction `id`. func pendingOf(id int64, bidder address) uint64 { v := pending.Get(pendKey(id, bidder)) if v == nil { return 0 } return v.(uint64) } // blocksLeft returns how many blocks remain before `a` can be ended (0 if the // end height has been reached). func blocksLeft(a *Auction, height int64) int64 { if height >= a.EndHeight { return 0 } return a.EndHeight - height } // hasWinner reports whether the auction received at least one bid. func hasWinner(a *Auction) bool { return a.HighestBid > 0 } // Render lists every auction with item, top bid + bidder, time left, and winner. func Render(path string) string { out := "# English Auction\n\n" out += "Ascending-bid auctions. Start one, outbid others, and End it after " out += "its block deadline to award the item to the top bidder.\n\n" if auctions.Len() == 0 { out += "_No auctions yet. Call `Start` to open one._\n" return out } height := runtime.ChainHeight() out += "_Current block height: " + strconv.FormatInt(height, 10) + "_\n\n" out += "| ID | Item | Highest Bid | Bidder | Status | Winner |\n" out += "|---:|------|------------:|--------|--------|--------|\n" auctions.Each(func(id store.ID, v any) { a := v.(*Auction) bidStr := "—" bidderStr := "—" if hasWinner(a) { bidStr = strconv.FormatUint(a.HighestBid, 10) bidderStr = "`" + a.HighestBidder.String() + "`" } status := "" winner := "—" switch { case a.Ended: status = "ended" if hasWinner(a) { winner = "`" + a.HighestBidder.String() + "`" } else { winner = "no bids" } case height >= a.EndHeight: status = "awaiting End" default: status = strconv.FormatInt(blocksLeft(a, height), 10) + " blocks left" } out += "| " + id.String() + " | " + a.Item + " | " + bidStr + " | " + bidderStr + " | " + status + " | " + winner + " |\n" }) return out }
  6. #6englishauction_test.gno
  7. #7package englishauction import ( "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/uassert/v0" ) // v0 asserted that its own key() padded to width 12, and built pendKey on top // of it. The auction key is now the store's, which has no width to outgrow; // pending is only point-read, so its key is the plain decimal id. func TestKeyAndPendKey(t *testing.T) { uassert.True(t, store.ID(2).Key() < store.ID(10).Key()) uassert.True(t, store.ID(999999999999).Key() < store.ID(1000000000000).Key(), "one past v0's width-12 ceiling") addr := address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") uassert.Equal(t, "7:"+addr.String(), pendKey(7, addr)) } func TestBlocksLeftAndWinner(t *testing.T) { a := &Auction{Item: "vase", EndHeight: 100} // before the deadline, blocks remain and there is no winner yet. uassert.Equal(t, int64(40), blocksLeft(a, 60)) uassert.Equal(t, int64(1), blocksLeft(a, 99)) uassert.False(t, hasWinner(a)) // at/after the deadline, no blocks remain. uassert.Equal(t, int64(0), blocksLeft(a, 100)) uassert.Equal(t, int64(0), blocksLeft(a, 150)) // a recorded bid makes it a winner. a.HighestBid = 500 a.HighestBidder = address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") uassert.True(t, hasWinner(a)) }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/x/daily/englishauction/v1" gno = "0.9" private = true
  10. #10render_example_test.gno
  11. #11package englishauction // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # English Auction // // Ascending-bid auctions. Start one, outbid others, and End it after its block deadline to award the item to the top bidder. // // _No auctions yet. Call `Start` to open one._ }
#12AddPackagegno.land/r/moul/x/daily/erc721/v111 arguments
Attached funds
5000000ugnot

Arguments · 11

  1. #1erc721
  2. #2README.md
  3. #3# erc721 — NFT (ERC-721 port) > ⚠️ **Experimental — generated with no human supervision** by the daily MCP pipeline (Solidity→Gno port). Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md). --- An idiomatic gno.land port of the Solidity **ERC-721** non-fungible token standard. Each token is a unique, sequentially-minted integer id owned by exactly one address. Ownership, per-owner balances and single-token approvals live in ordered `avl.Tree`s, so `Render` can list every token deterministically. `msg.sender` maps to `unsafe.PreviousRealm().Address()`, `require` to `panic`, and Solidity events to `chain.Emit` (`Mint`, `Transfer`, `Approval`). ## Transactions - `Mint(cur realm, to address) int64` — mint the next token id to `to`; returns the id. - `Transfer(cur realm, to address, id int64)` — move a token you own (or are approved for) to `to`; clears the approval. - `Approve(cur realm, spender address, id int64)` — owner grants `spender` the right to transfer token `id`. ## Read-only - `OwnerOf(id int64) address` — owner of a token (panics if it does not exist). - `BalanceOf(owner address) uint64` — number of tokens held by `owner`. - `TotalSupply() int64` — total tokens in circulation. - `Render(path string)` — Markdown: total supply + a token → owner → approved table. ## Example ``` Mint(cur, g1alice...) // -> tokenID 1 Mint(cur, g1alice...) // -> tokenID 2 Approve(cur, g1bob..., 1) // alice approves bob for token 1 Transfer(cur, g1carol..., 1) // owner alice sends token 1 to carol OwnerOf(1) // -> g1carol... BalanceOf(g1alice...) // -> 1 TotalSupply() // -> 2 ``` ## What changed in v1 State moved from a hand-rolled `avl.Tree` + `nextID` + zero-padding helper to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. `v0` padded token ids to width 12. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. `approvals`, the secondary index on the same ids, is keyed with `store.ID.Key()` so the two trees cannot drift apart. `balances` keeps its own address-keyed tree, which the store does not cover. `minted` is gone: with no burn, the highest id ever assigned *is* the supply, so `LastID()` answers it. Token ids stay plain integers and the rendered output is unchanged. This is a storage change, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/erc721) stays live and untouched. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/erc721/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/erc721/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. #4erc721.gno
  5. #5// Package erc721 is an idiomatic gno.land port of the Solidity ERC-721 // non-fungible token standard. Each token has a unique integer id owned by // exactly one address; ids are minted sequentially. Ownership, per-owner // balances and single-token approvals are kept in ordered avl trees so that // Render can iterate deterministically. package erc721 import ( "strconv" "chain" "chain/runtime/unsafe" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/avl/v0" ) const ( name = "Gno NFT" symbol = "GNFT" ) var ( // owners assigns the token ids. v0 kept its own nextID plus a key() that // zero-padded to width 12; the store keys by seqid, so the iteration order // Render depends on is numeric for every uint64 rather than up to 10^12. owners = store.Named("erc721: token") // tokenID -> address balances avl.Tree // owner address (string) -> uint64 approvals avl.Tree // tokenID (store key) -> approved address ) // Mint creates the next token id and assigns it to `to`. Only sequential // minting is supported (id is returned via the Mint event). func Mint(cur realm, to address) int64 { if !to.IsValid() { panic("erc721: mint to invalid address") } id := int64(owners.Add(to)) balances.Set(to.String(), balanceOf(to)+1) chain.Emit("Mint", "to", to.String(), "tokenID", strconv.FormatInt(id, 10)) return id } // Transfer moves token `id` from the caller to `to`. The caller must own the // token (approvals are cleared on transfer). func Transfer(cur realm, to address, id int64) { if !to.IsValid() { panic("erc721: transfer to invalid address") } caller := unsafe.PreviousRealm().Address() from := ownerOf(id) // panics if the token does not exist if caller != from { // allow the single-token approved operator too if approvedOf(id) != caller { panic("erc721: caller is neither owner nor approved") } } if from == to { panic("erc721: transfer to current owner") } owners.Set(store.ID(id), to) balances.Set(from.String(), balanceOf(from)-1) balances.Set(to.String(), balanceOf(to)+1) approvals.Remove(store.ID(id).Key()) // clear approval on transfer chain.Emit("Transfer", "from", from.String(), "to", to.String(), "tokenID", strconv.FormatInt(id, 10)) } // Approve grants `spender` the right to transfer token `id`. Only the current // owner may approve. func Approve(cur realm, spender address, id int64) { caller := unsafe.PreviousRealm().Address() owner := ownerOf(id) if caller != owner { panic("erc721: approve caller is not owner") } approvals.Set(store.ID(id).Key(), spender) chain.Emit("Approval", "owner", owner.String(), "spender", spender.String(), "tokenID", strconv.FormatInt(id, 10)) } // --- read-only helpers (safe to call from tests and Render) --- // ownerOf returns the owner of token `id`, panicking if it does not exist. // The store's label puts the id in the message, which v0's fixed string // ("erc721: query for nonexistent token") left out. func ownerOf(id int64) address { return owners.MustGet(store.ID(id)).(address) } // approvedOf returns the approved address for token `id`, or the zero address. func approvedOf(id int64) address { v := approvals.Get(store.ID(id).Key()) if v == nil { return address("") } return v.(address) } // balanceOf returns how many tokens `owner` holds. func balanceOf(owner address) uint64 { v := balances.Get(owner.String()) if v == nil { return 0 } return v.(uint64) } // exists reports whether token `id` has been minted (and not since moved away). func exists(id int64) bool { return owners.Has(store.ID(id)) } // totalSupply returns the number of tokens in circulation. There is no burn, // so the highest id ever assigned is the count. func totalSupply() int64 { return int64(owners.LastID()) } // OwnerOf is the exported read-only accessor for ownerOf. func OwnerOf(id int64) address { return ownerOf(id) } // BalanceOf is the exported read-only accessor for balanceOf. func BalanceOf(owner address) uint64 { return balanceOf(owner) } // TotalSupply is the exported read-only accessor for totalSupply. func TotalSupply() int64 { return totalSupply() } // Render displays collection metadata and a token -> owner table. func Render(path string) string { out := "# " + name + " (" + symbol + ")\n\n" out += "**Total supply:** " + strconv.FormatInt(totalSupply(), 10) + "\n\n" if owners.Len() == 0 { out += "_No tokens minted yet._\n" return out } out += "| Token ID | Owner | Approved |\n" out += "|---------:|-------|----------|\n" owners.Each(func(id store.ID, v any) { owner := v.(address) appr := approvedOf(int64(id)) apprStr := "—" if appr != address("") { apprStr = appr.String() } out += "| " + id.String() + " | " + owner.String() + " | " + apprStr + " |\n" }) return out }
  6. #6erc721_test.gno
  7. #7package erc721 import ( "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/uassert/v0" ) // v0 asserted that its own key() padded to width 12. That rule held only below // the width: key(10^12) is 13 characters and sorts before key(10^12-1), so the // token table would have rendered out of order from that id on. The store key // has no width to outgrow, which is what this replaces it with. func TestTokenKeysSortNumericallyPastTheOldWidth(t *testing.T) { uassert.True(t, store.ID(2).Key() < store.ID(10).Key(), "2 sorts before 10") uassert.True(t, store.ID(99).Key() < store.ID(100).Key(), "99 sorts before 100") uassert.True(t, store.ID(999999999999).Key() < store.ID(1000000000000).Key(), "and it keeps holding one past v0's width-12 ceiling") } func TestReadOnlyDefaults(t *testing.T) { // on a fresh realm nothing is minted. uassert.Equal(t, int64(0), totalSupply()) uassert.False(t, exists(1), "token 1 must not exist before minting") var someone address = "g1someoneelsehere000000000000000000000000" uassert.Equal(t, uint64(0), balanceOf(someone)) uassert.Equal(t, "", approvedOf(1).String()) }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/x/daily/erc721/v1" gno = "0.9" private = true
  10. #10render_example_test.gno
  11. #11package erc721 // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # Gno NFT (GNFT) // // **Total supply:** 0 // // _No tokens minted yet._ }
#13AddPackagegno.land/r/moul/x/daily/governor/v111 arguments
Attached funds
5000000ugnot

Arguments · 11

  1. #1governor
  2. #2README.md
  3. #3# Governor > ⚠️ **Experimental — generated with no human supervision** by the daily MCP pipeline (Solidity→Gno port). Not audited. See [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md). --- A simplified on-chain governance realm inspired by OpenZeppelin's `Governor`. It ports the core proposal → vote → execute lifecycle to Gno: `Propose` records a proposal's snapshot height and a deadline of `ChainHeight() + votingPeriod`, `CastVote` gives every address a single equally-weighted vote (1 address = 1 vote, tracked in an avl voter set), and `State` computes `Active` / `Succeeded` / `Defeated` from the deadline versus the current chain height plus whether the `for` tally beats `against`. `Execute` finalizes a `Succeeded` proposal. Unlike the ERC20Votes-weighted original, voting power here is flat (one address, one vote) and there is no timelock — the emphasis is on a clean, deterministic state machine driven by block height. ## Example calls ``` # open a proposal (returns its id) Propose("Fund the docs working group for Q3") # vote: 0=against, 1=for, 2=abstain (one vote per address) CastVote(1, 1) # read the current state: Active | Succeeded | Defeated | Executed State(1) # once past the deadline with for > against, finalize it Execute(1) ``` `Render("")` lists every proposal with its tally bars (`▓░`) and state. ## What changed in v1 State moved from a hand-rolled `avl.Tree` + `nextID` + zero-padding helper to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. v0 padded ids to width 12. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. `Proposal` loses its `ID` field, which the store now owns, and the local `mustGet` becomes one line: the store's labelled `MustGet` panics `governor: proposal #7 not found`, the same information `v0` assembled by hand. The `totalCount` counter is gone too: with no deletion, the highest id ever assigned is the count, so `LastID()` answers it. Each proposal keeps its own address-keyed `votes` tree, which the store does not cover. Proposal ids already started at 1, so nothing shifts. This is a storage change, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/governor) stays live and untouched. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/governor/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/governor/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/governor/v1" gno = "0.9" private = true
  6. #6governor.gno
  7. #7// Package governor is a simplified on-chain governance realm inspired by // OpenZeppelin's Governor. Anyone may open a proposal; every address gets a // single equally-weighted vote (1 address = 1 vote); a proposal succeeds when // its voting deadline has passed and the "for" tally strictly beats "against". package governor import ( "strconv" "strings" "chain" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/avl/v0" ) // votingPeriod is the number of blocks a proposal stays open for voting. const votingPeriod = int64(100) // Vote support values. const ( VoteAgainst = 0 VoteFor = 1 VoteAbstain = 2 ) // Proposal state values. const ( StateActive = "Active" StateSucceeded = "Succeeded" StateDefeated = "Defeated" StateExecuted = "Executed" ) // Proposal is a single governance proposal. It carries no ID field: the id // belongs to the store, which hands it back on lookup and iteration. type Proposal struct { Proposer address Description string SnapshotHeight int64 Deadline int64 For int64 Against int64 Abstain int64 Executed bool votes *avl.Tree // voter address (string) -> support (int) } // proposals assigns the proposal ids. v0 kept its own nextID plus an idKey() // that zero-padded to width 12, which stopped ordering Render past 10^12, and // its own totalCount, which the store's LastID already answers. var proposals = store.Named("governor: proposal") // Propose opens a new proposal and returns its id. The snapshot height and // deadline are recorded from the current chain height. func Propose(cur realm, description string) int64 { if strings.TrimSpace(description) == "" { panic("governor: empty description") } proposer := unsafe.PreviousRealm().Address() h := runtime.ChainHeight() p := &Proposal{ Proposer: proposer, Description: description, SnapshotHeight: h, Deadline: h + votingPeriod, votes: avl.NewTree(), } id := int64(proposals.Add(p)) chain.Emit( "ProposalCreated", "id", strconv.FormatInt(id, 10), "proposer", proposer.String(), "deadline", strconv.FormatInt(p.Deadline, 10), ) return id } // CastVote records one vote for the caller on proposal id. support is // 0=against, 1=for, 2=abstain. One address may vote at most once per proposal, // and voting is only allowed while the proposal is Active. func CastVote(cur realm, id int64, support int) { p := mustGet(id) if computeState(p, runtime.ChainHeight()) != StateActive { panic("governor: voting closed") } if support < VoteAgainst || support > VoteAbstain { panic("governor: invalid support value") } voter := unsafe.PreviousRealm().Address() if p.votes.Has(voter.String()) { panic("governor: already voted") } p.votes.Set(voter.String(), support) switch support { case VoteFor: p.For++ case VoteAgainst: p.Against++ default: p.Abstain++ } chain.Emit( "VoteCast", "id", strconv.FormatInt(id, 10), "voter", voter.String(), "support", strconv.Itoa(support), ) } // Execute marks a Succeeded proposal as executed. It panics unless the // proposal has reached the Succeeded state. func Execute(cur realm, id int64) { p := mustGet(id) if computeState(p, runtime.ChainHeight()) != StateSucceeded { panic("governor: proposal not in Succeeded state") } p.Executed = true chain.Emit("ProposalExecuted", "id", strconv.FormatInt(id, 10)) } // State returns the current lifecycle state of proposal id. func State(id int64) string { return computeState(mustGet(id), runtime.ChainHeight()) } // computeState is the pure state machine: it decides the state of p at chain // height h. Kept free of globals so it is unit-testable. func computeState(p *Proposal, h int64) string { if p.Executed { return StateExecuted } if h < p.Deadline { return StateActive } if p.For > p.Against { return StateSucceeded } return StateDefeated } func mustGet(id int64) *Proposal { return proposals.MustGet(store.ID(id)).(*Proposal) } // bar renders a proportional ▓░ progress bar of fixed width. func bar(value, total int64, width int) string { if width <= 0 { return "" } filled := 0 if total > 0 { filled = int((value*int64(width) + total/2) / total) if filled > width { filled = width } } return strings.Repeat("▓", filled) + strings.Repeat("░", width-filled) } // Render lists all proposals with their tallies and states as Markdown. func Render(path string) string { var b strings.Builder b.WriteString("# 🏛️ Governor\n\n") b.WriteString("Simplified on-chain governance — 1 address = 1 vote, ") b.WriteString("voting period of " + strconv.FormatInt(votingPeriod, 10) + " blocks.\n\n") h := runtime.ChainHeight() b.WriteString("Current chain height: **" + strconv.FormatInt(h, 10) + "**\n\n") if proposals.Len() == 0 { b.WriteString("_No proposals yet. Call `Propose` to create one._\n") return b.String() } b.WriteString("Total proposals: **" + proposals.LastID().String() + "**\n\n") proposals.Each(func(id store.ID, v any) { p := v.(*Proposal) state := computeState(p, h) total := p.For + p.Against + p.Abstain b.WriteString("---\n\n") b.WriteString("## #" + id.String() + " · " + state + "\n\n") b.WriteString("> " + p.Description + "\n\n") b.WriteString("- Proposer: `" + p.Proposer.String() + "`\n") b.WriteString("- Snapshot height: " + strconv.FormatInt(p.SnapshotHeight, 10) + "\n") b.WriteString("- Deadline height: " + strconv.FormatInt(p.Deadline, 10)) if state == StateActive { b.WriteString(" (" + strconv.FormatInt(p.Deadline-h, 10) + " blocks left)") } b.WriteString("\n\n") b.WriteString("| Choice | Votes | Tally |\n") b.WriteString("|---|---|---|\n") b.WriteString("| For | " + strconv.FormatInt(p.For, 10) + " | `" + bar(p.For, total, 20) + "` |\n") b.WriteString("| Against | " + strconv.FormatInt(p.Against, 10) + " | `" + bar(p.Against, total, 20) + "` |\n") b.WriteString("| Abstain | " + strconv.FormatInt(p.Abstain, 10) + " | `" + bar(p.Abstain, total, 20) + "` |\n\n") }) return b.String() }
  8. #8governor_test.gno
  9. #9package governor import ( "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/uassert/v0" ) func TestComputeState(t *testing.T) { tests := []struct { name string p *Proposal h int64 want string }{ { name: "before deadline is active", p: &Proposal{Deadline: 100, For: 0, Against: 0}, h: 50, want: StateActive, }, { name: "at deadline with for>against succeeds", p: &Proposal{Deadline: 100, For: 3, Against: 1}, h: 100, want: StateSucceeded, }, { name: "past deadline tie is defeated", p: &Proposal{Deadline: 100, For: 2, Against: 2}, h: 120, want: StateDefeated, }, { name: "past deadline against wins is defeated", p: &Proposal{Deadline: 100, For: 1, Against: 5}, h: 101, want: StateDefeated, }, { name: "executed overrides everything", p: &Proposal{Deadline: 100, For: 9, Against: 0, Executed: true}, h: 200, want: StateExecuted, }, } for _, tt := range tests { got := computeState(tt.p, tt.h) uassert.Equal(t, tt.want, got, tt.name) } } func TestBar(t *testing.T) { // empty total renders all-empty of exact width uassert.Equal(t, "░░░░░░░░░░", bar(0, 0, 10), "zero total") // full value fills entirely uassert.Equal(t, "▓▓▓▓▓▓▓▓▓▓", bar(10, 10, 10), "full") // half value fills half uassert.Equal(t, "▓▓▓▓▓░░░░░", bar(5, 10, 10), "half") // zero width yields empty string uassert.Equal(t, "", bar(1, 2, 0), "zero width") } // v0 asserted that its own idKey() padded to width 12. That rule held only // below the width: idKey(10^12) is 13 characters and sorts before // idKey(10^12-1), so the proposal list would have rendered out of order from // that id on. The store key has no width to outgrow. func TestIDKey(t *testing.T) { uassert.True(t, store.ID(2).Key() < store.ID(10).Key(), "2 sorts before 10") uassert.True(t, store.ID(9).Key() < store.ID(100).Key(), "9 sorts before 100") uassert.True(t, store.ID(999999999999).Key() < store.ID(1000000000000).Key(), "and it keeps holding one past v0's width-12 ceiling") }
  10. #10render_example_test.gno
  11. #11package governor // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # 🏛️ Governor // // Simplified on-chain governance — 1 address = 1 vote, voting period of 100 blocks. // // Current chain height: **123** // // _No proposals yet. Call `Propose` to create one._ }
#14AddPackagegno.land/r/moul/x/daily/guestbook/v111 arguments
Attached funds
5000000ugnot

Arguments · 11

  1. #1guestbook
  2. #2README.md
  3. #3# Guestbook > ⚠️ **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 public, on-chain guestbook realm for gno.land. Anyone can leave a signed message; every entry records the signer's address, the message, and the block height at which it was signed. The realm's `Render` page shows all entries as a Markdown table, newest-first, with a running total. Built for the **sapphire** testnet (gno 0.9). ## Realm path ``` gno.land/r/REPLACE_ADDR/guestbook ``` (`REPLACE_ADDR` is substituted with the deployer's address at publish time.) ## Public API - `Sign(cur realm, message string)` — crossing tx. Appends an entry attributed to the immediate caller. Panics on an empty or over-long (>280 bytes) message. - `Count() int` — total number of signatures (read-only). - `Render(path string) string` — Markdown table of all entries, newest-first. ## Example calls Sign the guestbook (from an EOA via `gnokey`): ```sh gnokey maketx call \ -pkgpath "gno.land/r/REPLACE_ADDR/guestbook" \ -func Sign \ -args "gm gno.land!" \ -gas-fee 1000000ugnot -gas-wanted 2000000 \ -broadcast -chainid sapphire-1 -remote <rpc> mykey ``` From another realm, invoke it as a crossing call: ```go guestbook.Sign(cross(cur), "hello from my realm") ``` View the rendered guestbook: ```sh gnokey query vm/qrender --data "gno.land/r/REPLACE_ADDR/guestbook:" ``` ## Example render output ```markdown # Guestbook **Total signatures:** 2 | # | Who | Message | Height | |---|-----|---------|--------| | 2 | g1v9jxgu…0gh | hello from my realm | 4210 | | 1 | g1jg8mtu…qf5 | gm gno.land! | 4180 | ``` ## Notes - Caller identity uses the gno 0.9 interrealm convention: `Sign` is a crossing function that checks `cur.IsCurrent()` before deriving the author from `cur.Previous().Address()`. - Block height comes from `chain/runtime.ChainHeight()` — deterministic. - Entries are stored in a `gno.land/p/nt/avl/v0` tree keyed by a zero-padded sequence number so iteration order is deterministic and matches insertion order (reversed for newest-first rendering). ``` ## What changed in v1 Two ports against [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/guestbook), which stays live and untouched. ### Rendering Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/guestbook) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `shortAddr`, `escapeCell` helpers. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. **Real escaping.** The local helper replaced a handful of markdown metacharacters. `ui.Inline` and `ui.Cell` delegate to [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0), which also strips bidi and zero-width characters and folds newlines, so user text cannot reorder or escape the line it sits on. Visible change: addresses now render in monospace. ### Storage State moved from a hand-rolled `avl.Tree` + `nextID` + zero-padding helper to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. `v0` padded ids to width 16, the widest of the six hand-rolled variants in this repo and still a ceiling. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. `Entry` loses its `ID` field, which the store now owns, so `addEntry` returns `(store.ID, *Entry)`. The `count` global is gone too: `Len()` answers it. Ids stay plain integers and the rendered output of the storage port is unchanged. Each port changes something `v0` promised, one the rendered output and one the storage layout, so under this repo's versioning rule each is a compatibility change and neither could be an edit to `v0` in place. They land in the **same** new version because `v1` was never published. A version number is a tag on something that exists on a chain, and until it does there is nothing for a second number to avoid disturbing, so the right move is to keep editing the version you have. `gnopm unbump` is what folded the second port back down into this one. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/guestbook/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/guestbook/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/guestbook/v1" gno = "0.9" private = true
  6. #6guestbook.gno
  7. #7// Package guestbook is a public, on-chain guestbook realm for gno.land. // // Anyone can sign the guestbook with a short message via the Sign crossing // function. Every entry records the caller's address, the message, and the // block height at which it was signed. Render lists all entries newest-first // as a Markdown table. // // v1 renders through [p/moul/kit/ui](/p/moul/kit/ui/v0) instead of the // hand-rolled shortAddr and escapeCell helpers v0 carried. The escaping is the // substantive change: v0 replaced four characters, while ui.Cell also strips // bidi and zero-width characters, which a message can otherwise use to reorder // how the rest of the row reads. package guestbook import ( "strconv" "strings" "chain" "chain/runtime" "gno.land/p/moul/kit/store/v0" "gno.land/p/moul/kit/ui/v0" ) // Entry is a single signed guestbook line. It carries no ID field: the id // belongs to the store, which hands it back on lookup and iteration. type Entry struct { Author string // bech32 address of the signer Message string // the message left by the signer Height int64 // block height at which the entry was signed } const maxMessageLen = 280 // entries assigns the sequence numbers. v1 kept its own count plus a seqKey() // that zero-padded to width 16, the widest of the six hand-rolled variants in // this repo and still a ceiling; the store key has none. var entries = store.Named("guestbook: entry") // Sign appends a new entry to the guestbook attributed to the immediate caller. // // It is a crossing function (gno 0.9 interrealm convention): callers invoke it // as Sign(cross(cur), "hello"). The cur.IsCurrent() guard is the authentication // primitive — without it a stale/forged realm value could spoof the author. func Sign(cur realm, message string) { if !cur.IsCurrent() { panic("guestbook: spoofed realm; Sign must be called via cross(cur)") } author := cur.Previous().Address().String() id, e := addEntry(author, message, runtime.ChainHeight()) chain.Emit("Signed", "id", id.String(), "author", e.Author, "height", strconv.FormatInt(e.Height, 10), ) } // addEntry validates the message and appends an entry. Non-crossing internal // helper so the append/validation logic is unit-testable without a realm frame. // Panics (aborting the tx, reverting state) on an invalid message. func addEntry(author, message string, height int64) (store.ID, *Entry) { msg := strings.TrimSpace(message) if msg == "" { panic("guestbook: message must not be empty") } if len(msg) > maxMessageLen { panic("guestbook: message too long (max " + strconv.Itoa(maxMessageLen) + " bytes)") } e := &Entry{ Author: author, Message: msg, Height: height, } return entries.Add(e), e } // Count returns the total number of signatures. Read-only, non-crossing. func Count() int { return entries.Len() } // Render lists every entry newest-first as a Markdown table plus a total count. func Render(path string) string { if entries.Len() == 0 { return "# Guestbook\n\n" + ui.Empty("No one has signed yet. Be the first!") } t := ui.NewTable("#", "Who", "Message", "Height") // Ids ascend with signing, so reverse iteration is newest-first. entries.EachReverse(func(id store.ID, v any) { e := v.(*Entry) t.Row( id.String(), ui.AddrOf(e.Author), ui.Cell(e.Message), strconv.FormatInt(e.Height, 10), ) }) return "# Guestbook\n\n**Total signatures:** " + strconv.Itoa(entries.Len()) + "\n\n" + t.String() }
  8. #8guestbook_test.gno
  9. #9package guestbook import ( "strings" "testing" "gno.land/p/moul/kit/store/v0" ) // resetState clears package globals between tests (globals persist across // test functions in the same package binary). func resetState() { entries = store.Named("guestbook: entry") } func TestAddEntryAppendsAndCounts(t *testing.T) { resetState() addEntry("g1alice", "hello world", 100) addEntry("g1bob", "second entry", 101) if got := Count(); got != 2 { t.Fatalf("Count() = %d, want 2", got) } out := Render("") if !strings.Contains(out, "**Total signatures:** 2") { t.Errorf("Render missing total; got:\n%s", out) } if !strings.Contains(out, "hello world") { t.Errorf("Render missing first message; got:\n%s", out) } if !strings.Contains(out, "second entry") { t.Errorf("Render missing second message; got:\n%s", out) } if !strings.Contains(out, "100") || !strings.Contains(out, "101") { t.Errorf("Render missing heights; got:\n%s", out) } } func TestRenderNewestFirst(t *testing.T) { resetState() addEntry("g1alice", "AAA oldest", 1) addEntry("g1alice", "BBB newest", 2) out := Render("") iNew := strings.Index(out, "BBB newest") iOld := strings.Index(out, "AAA oldest") if iNew < 0 || iOld < 0 { t.Fatalf("both messages must appear; got:\n%s", out) } if iNew > iOld { t.Errorf("newest entry should render before oldest; got:\n%s", out) } } func TestRenderEmpty(t *testing.T) { resetState() out := Render("") if !strings.Contains(out, "No one has signed yet") { t.Errorf("empty render should invite signing; got:\n%s", out) } } func TestEmptyMessagePanics(t *testing.T) { resetState() defer func() { if r := recover(); r == nil { t.Errorf("signing an empty message should panic") } }() addEntry("g1alice", " ", 1) } // A message must not be able to break out of its table cell. The escaping now // lives in ui.Cell, so this asserts the realm's rendered output rather than a // local helper. func TestRenderEscapesMessage(t *testing.T) { resetState() defer resetState() addEntry("g1manfred47kzduec920z88wfr64ylksmdcedlf5", "a|b", 1) out := Render("") if strings.Contains(out, "| a|b |") { t.Errorf("an unescaped pipe would open a column:\n%s", out) } if !strings.Contains(out, `a\|b`) { t.Errorf("message should appear escaped:\n%s", out) } } // v1 asserted that its own seqKey() ordered numerically. That held only below // its width of 16: seqKey(10^16) is 17 characters and sorts before // seqKey(10^16-1). The store key is 8 bytes for every id, so there is no // ceiling to reach. func TestSeqKeyOrders(t *testing.T) { if a, b := store.ID(2).Key(), store.ID(10).Key(); !(a < b) { t.Error("id 2 should sort before id 10") } if a, b := store.ID(9999999999999999).Key(), store.ID(10000000000000000).Key(); !(a < b) { t.Error("ordering should survive one past v1's width-16 ceiling") } }
  10. #10render_example_test.gno
  11. #11package guestbook // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # Guestbook // // *No one has signed yet. Be the first!* }
#15AddPackagegno.land/r/moul/x/daily/merkledrop/v113 arguments
Attached funds
13000000ugnot

Arguments · 13

  1. #1merkledrop
  2. #2README.md
  3. #3# merkledrop v1 A Merkle-gated airdrop that actually moves GNOT. Successor to [`v0`](../v0), which the daily pipeline generated and which is deployed on mainnet. v0 works, but it is a demonstration rather than a drop, and its proof scheme is safe by accident. Built on [`p/moul/x/merkle/v0`](../../../../../../p/moul/x/merkle/v0). ## What changed, and why **1. Domain-separated leaves.** v0 hashed leaves bare and combined nodes commutatively, the OpenZeppelin scheme: `leaf = sha256(addr|amount)`, `node = sha256(min||max)`. Without a leaf/inner tag an inner-node hash is also a valid leaf hash, so anyone able to present a 64-byte leaf preimage can prove membership of a leaf that was never committed. v0 is **not** exploitable, but only by arithmetic: an inner preimage is exactly 64 bytes, and a v0 leaf preimage is at most 40 (bech32 address) + 1 + 20 (`uint64` has at most 20 digits) = 61 bytes. 61 < 64, so no collision is reachable. Widen the amount, change the address form, add a field, and the forgery goes live with no visible diff at the call site. That is not a property to rely on. v1 tags leaves `0x00` and inner nodes `0x01`, so the two preimage spaces cannot overlap at any length. **2. Proofs bound to a position.** v0's proof was a bare sibling list of any length, folded until it ran out. v1's proof carries its index and the total leaf count; the verifier rebuilds the tree shape from them, so a proof cannot be replayed at another index and one of the wrong length is rejected rather than folded. Depth is capped at `merkle.MaxDepth`, so an untrusted caller does not choose the length of the loop. **3. A settable root and a closing height.** v0's root is a `const`: the drop can never be re-rooted, extended or ended. v1's owner sets root, leaf count and an optional closing height, and can sweep the remainder once it closes. **4. Real coins.** v0 keeps a `uint64` ledger and moves nothing. Its README says so, but it sits on mainnet reading like an airdrop. v1 sends ugnot from the realm's own address through the banker, and **refuses a claim it cannot pay** rather than marking it claimed: `TestUnderfundedDropDoesNotBurnAnAllocation` pins that an underfunded drop does not consume an allocation. ## Leaf encoding The whole interface to an off-chain generator: ``` leaf = "gno.land/r/moul/x/daily/merkledrop/v1|<index>|<address>|<amount>" tree = Tendermint simple tree, leaf tagged 0x00, inner tagged 0x01 ``` The pkgpath is in the preimage, so a proof for this drop cannot be replayed against another realm using the same shape. Reproduce the tree with `p/moul/x/merkle/v0`, or with any Tendermint implementation. ## Using it ``` SetDrop(rootHex, totalLeaves, closesAtHeight) // owner only; 0 = never closes Verify(index, addr, amount, proof) // free check before spending gas Claim(index, amount, proof) // pays out Sweep() // owner, after the drop closes ``` Fund the drop by sending ugnot to `Address()`, the realm's own package address. ## The seeded example drop Deployed with four example allocations so the drop can be exercised without a generator, and so the page is not empty. A **live** drop commits only a root and stores no allocation list: that is the whole point of a Merkle drop. `SetDrop` clears the seeded list. Root: `6964baa99da23b9d8c76774c6f1122b250040e29c1d75823dda15c043dc65755` `DemoProof(index)` hands out the proof for a seeded allocation, and `/r/moul/x/daily/merkledrop/v1:proof/<index>` renders it with its leaf. ## Who the claimer is The claimer is `PreviousRealm().Address()`, the **immediate** caller. Called directly by a user that is the user; called through another realm it is that **realm**. That is not a hole: the leaf binds the address and the proof must match the claimer, so an intermediary can only claim an allocation granted to the intermediary itself. It does mean a wrapper realm cannot claim on a user's behalf, which is deliberate. ## Note on v0 v0's README lists an allocation for `g1manfred47kzduec920z88wfr64ylksmdcedar8`. moul's actual address is `...cedlf5`. v1 uses the real one. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/merkledrop/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/merkledrop/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/merkledrop/v1" gno = "0.9" private = true
  6. #6merkledrop.gno
  7. #7// Package merkledrop is a Merkle-gated airdrop that actually moves GNOT. // // It is the successor to r/moul/x/daily/merkledrop/v0, which was generated by // the daily pipeline and is deployed on mainnet. v0 works, but it is a // demonstration rather than a drop, and its proof scheme is safe by accident. // Everything below is what changed and why. // // # 1. The leaf scheme is domain separated // // v0 hashed leaves bare and combined nodes commutatively, the OpenZeppelin // scheme: leaf = sha256(addr|amount), node = sha256(min||max). Without a // leaf/inner tag an inner-node hash is also a valid leaf hash, so anyone who // can present a 64-byte leaf preimage can prove membership of a leaf that was // never committed. // // v0 is not exploitable, but only by arithmetic: an inner preimage is exactly // 64 bytes, and a v0 leaf preimage is at most 40 (bech32 address) + 1 + 20 // (uint64 has at most 20 digits) = 61 bytes. 61 < 64, so no collision is // reachable. Change the leaf encoding, widen the amount, use a different // address form, and the forgery goes live with no visible diff at the call // site. That is not a property to rely on. // // v1 uses gno.land/p/moul/x/merkle/v0, which is the Tendermint scheme: leaves // are tagged 0x00 and inner nodes 0x01, so the two preimage spaces cannot // overlap at any length. // // # 2. Proofs are bound to a position // // v0's proof was a bare sibling list of any length, folded until it ran out. // v1's proof carries its index and the total leaf count, and the verifier // rebuilds the tree shape from them: a proof cannot be replayed at another // index, and one of the wrong length is rejected rather than folded. The // sibling count is capped at merkle.MaxDepth, so an untrusted caller cannot // choose the length of the loop. // // # 3. The root is settable, and the drop can close // // v0's root is a `const`, so the drop can never be re-rooted, extended or // ended. v1's owner sets the root, the leaf count and an optional closing // height, and can sweep the remainder once it closes. // // # 4. It moves real coins // // v0 keeps a uint64 ledger and moves nothing; its README says so, but it sits // on mainnet reading like an airdrop. v1 sends ugnot from the realm's own // address through the banker, and refuses a claim it cannot pay rather than // marking it claimed. // // # Who the claimer is // // The claimer is PreviousRealm().Address(), the immediate caller. Called // directly by a user that is the user; called through another realm it is that // REALM. This is not a hole, because the leaf binds the address and the proof // must match the claimer, so an intermediary can only claim an allocation // granted to the intermediary itself. It does mean a wrapper realm cannot // claim on a user's behalf, which is deliberate. // // Built on gno.land/p/moul/x/merkle/v0. package merkledrop import ( "errors" "strconv" "strings" "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/moul/x/merkle/v0" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ownable/v0" ) // Denom is the only coin this drop pays in. const Denom = "ugnot" // leafPrefix is part of every leaf preimage, so a proof for one drop cannot be // replayed against another realm that happens to use the same encoding. const leafPrefix = "gno.land/r/moul/x/daily/merkledrop/v1" // Owner may set the drop and sweep it. // // Hardcoded rather than derived from the deployer at init: inside a plain // `func Test(t *testing.T)` the gno test runner reports OriginCaller() as the // EMPTY address, so an owner taken from it is the empty address in every test // and whatever deployed on chain. That divergence is exactly where an // authorization bug hides, so the address is written down instead. const Owner = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5") var ( // Ownable holds the address allowed to set the drop and sweep it. Ownable *ownable.Ownable root []byte // the committed allocation root; nil means no drop total int // leaf count the root commits to closesAt int64 // chain height after which claims are refused; 0 = never claimed avl.Tree // padded index -> claimer address string paid int64 // running total of ugnot sent // demo holds the allocations of the seeded example drop, so Render can // show working proofs. A real drop commits only a root and leaves this // empty: the whole point of a Merkle drop is not storing the allocations. demo []Allocation ) // Allocation is one entry of a drop: who may claim, and how much. type Allocation struct { Address address Amount int64 } var ( ErrNoDrop = errors.New("merkledrop: no drop is configured") ErrClosed = errors.New("merkledrop: the drop has closed") ErrClaimed = errors.New("merkledrop: already claimed") ErrBadProof = errors.New("merkledrop: invalid proof") ErrUnfunded = errors.New("merkledrop: the drop cannot cover this claim") ErrStillOpen = errors.New("merkledrop: the drop has not closed yet") ErrBadRoot = errors.New("merkledrop: root must be 32 bytes of hex") ErrBadTotal = errors.New("merkledrop: total must be positive") ErrBadAmount = errors.New("merkledrop: amount must be positive") ) func init() { Ownable = ownable.NewWithAddress(Owner) seed() } // Leaf returns the exact preimage committed for one allocation. Reproduce it // off chain to rebuild the tree; it is the whole interface between the drop // and its generator. // // leaf = "<pkgpath>|<index>|<address>|<amount>" func Leaf(index int, addr address, amount int64) string { return leafPrefix + "|" + strconv.Itoa(index) + "|" + addr.String() + "|" + strconv.FormatInt(amount, 10) } // SetDrop commits a new allocation root. Owner only. // // totalLeaves is the number of allocations the root commits to; the verifier // needs it to rebuild the tree shape, so a wrong value invalidates every // proof rather than weakening any. closesAtHeight is the last height at which // a claim is accepted, or 0 for a drop that never closes. // // Setting a new root abandons the previous claim ledger: a drop is a // commitment, and replacing it starts a new one. func SetDrop(cur realm, rootHex string, totalLeaves int, closesAtHeight int64) { Ownable.AssertOwnedBy(unsafe.PreviousRealm().Address()) rb, err := parseRoot(rootHex) if err != nil { panic(err.Error()) } if totalLeaves <= 0 { panic(ErrBadTotal.Error()) } root, total, closesAt = rb, totalLeaves, closesAtHeight claimed, paid, demo = avl.Tree{}, 0, nil chain.Emit("DropSet", "root", rootHex, "total", strconv.Itoa(totalLeaves), "closesAt", strconv.FormatInt(closesAtHeight, 10), ) } // Claim proves the caller is allocated amount at index and pays it out. // // proof is the comma-separated hex sibling list from the tree generator, leaf // first. It panics on a closed drop, a double claim, a bad proof, or a drop // that cannot cover the amount; nothing is marked claimed in any of those // cases. func Claim(cur realm, index int, amount int64, proof string) { if len(root) == 0 { panic(ErrNoDrop.Error()) } if IsClosed() { panic(ErrClosed.Error()) } if amount <= 0 { panic(ErrBadAmount.Error()) } if HasClaimed(index) { panic(ErrClaimed.Error()) } claimer := unsafe.PreviousRealm().Address() p, err := merkle.ParseProof(index, total, proof) if err != nil { panic(ErrBadProof.Error()) } if !p.Verify(root, []byte(Leaf(index, claimer, amount))) { panic(ErrBadProof.Error()) } // Check funds BEFORE recording the claim, so an underfunded drop does not // burn an allocation. if Balance() < amount { panic(ErrUnfunded.Error()) } claimed.Set(indexKey(index), claimer.String()) paid += amount bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(Address(), claimer, chain.NewCoins(chain.NewCoin(Denom, amount))) chain.Emit("Claimed", "index", strconv.Itoa(index), "account", claimer.String(), "amount", strconv.FormatInt(amount, 10), ) } // Sweep returns whatever is left to the owner, once the drop has closed. Owner // only. A drop with no closing height can never be swept, which is the point // of setting one. func Sweep(cur realm) { owner := Ownable.Owner() Ownable.AssertOwnedBy(unsafe.PreviousRealm().Address()) if closesAt == 0 || !IsClosed() { panic(ErrStillOpen.Error()) } left := Balance() if left <= 0 { return } bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(Address(), owner, chain.NewCoins(chain.NewCoin(Denom, left))) chain.Emit("Swept", "amount", strconv.FormatInt(left, 10)) } // Address returns the realm's own address, which is where the drop is funded. // Send ugnot here to fund it. func Address() address { return chain.PackageAddress(leafPrefix) } // Balance returns the ugnot the drop still holds. func Balance() int64 { return banker.NewReadonlyBanker().GetCoins(Address()).AmountOf(Denom) } // Root returns the committed root, hex-encoded, or "" when no drop is set. func Root() string { if len(root) == 0 { return "" } return hexOf(root) } // Total returns the number of allocations the root commits to. func Total() int { return total } // ClosesAt returns the last height a claim is accepted, or 0 for never. func ClosesAt() int64 { return closesAt } // IsClosed reports whether the drop is past its closing height. func IsClosed() bool { return closesAt != 0 && runtime.ChainHeight() > closesAt } // Paid returns the ugnot claimed so far. func Paid() int64 { return paid } // Claims returns the number of allocations claimed. func Claims() int { return claimed.Size() } // HasClaimed reports whether the allocation at index was claimed. func HasClaimed(index int) bool { return claimed.Has(indexKey(index)) } // ClaimedBy returns the address that claimed index, or the empty address. func ClaimedBy(index int) address { v := claimed.Get(indexKey(index)) if v == nil { return "" } return address(v.(string)) } // Verify checks an allocation against the committed root without claiming it, // so a recipient can confirm a proof before spending gas on Claim. func Verify(index int, addr address, amount int64, proof string) bool { if len(root) == 0 { return false } p, err := merkle.ParseProof(index, total, proof) if err != nil { return false } return p.Verify(root, []byte(Leaf(index, addr, amount))) } func parseRoot(s string) ([]byte, error) { p, err := merkle.ParseProof(0, 1, strings.TrimSpace(s)) if err != nil || len(p.Siblings) != 1 { return nil, ErrBadRoot } return p.Siblings[0], nil } func hexOf(b []byte) string { const digits = "0123456789abcdef" out := make([]byte, 0, len(b)*2) for _, c := range b { out = append(out, digits[c>>4], digits[c&0x0f]) } return string(out) } // indexKey pads the index so avl iteration is numeric, not lexicographic. // ufmt has no width flags, so the padding is by hand: unpadded keys sort // "0","1","10","11","2" and Render would lose the order past nine entries. func indexKey(i int) string { s := strconv.Itoa(i) for len(s) < 6 { s = "0" + s } return s }
  8. #8merkledrop_test.gno
  9. #9package merkledrop import ( "chain" "chain/banker" "chain/runtime" "testing" "gno.land/p/moul/x/merkle/v0" "gno.land/p/nt/urequire/v0" ) const ( alice = address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // demo index 0, 100 bob = address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj") // demo index 1, 250 mallory = address("g1sqmuwtsrgd6t8y2n8f6z5xkltmpmcd64sx3vgg") ) func fund(n int64) { testing.IssueCoins(Address(), chain.NewCoins(chain.NewCoin(Denom, n))) } func balanceOf(a address) int64 { return banker.NewReadonlyBanker().GetCoins(a).AmountOf(Denom) } // Every seeded allocation must verify against the seeded root, and no other // address or amount may. func TestDemoDropVerifies(t *testing.T) { seed() if Total() != 4 || Root() == "" { t.Fatalf("seed left Total=%d Root=%q", Total(), Root()) } for i, a := range demoAllocations { proof := DemoProof(i) if !Verify(i, a.Address, a.Amount, proof) { t.Errorf("allocation %d does not verify", i) } if Verify(i, mallory, a.Amount, proof) { t.Errorf("allocation %d verified for the wrong address", i) } if Verify(i, a.Address, a.Amount+1, proof) { t.Errorf("allocation %d verified for the wrong amount", i) } if i > 0 && Verify(i-1, a.Address, a.Amount, proof) { t.Errorf("allocation %d verified at the wrong index", i) } } } // The leaf is the whole interface to an off-chain generator, so pin it. func TestLeafEncoding(t *testing.T) { got := Leaf(3, alice, 1234) want := "gno.land/r/moul/x/daily/merkledrop/v1|3|g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5|1234" if got != want { t.Errorf("Leaf = %q, want %q", got, want) } } func TestClaimPays(cur realm, t *testing.T) { seed() fund(1000) before := balanceOf(alice) testing.SetRealm(testing.NewUserRealm(alice)) Claim(cross(cur), 0, 100, DemoProof(0)) if got := balanceOf(alice) - before; got != 100 { t.Errorf("alice received %d ugnot, want 100", got) } if !HasClaimed(0) { t.Error("index 0 not marked claimed") } if ClaimedBy(0) != alice { t.Errorf("ClaimedBy(0) = %s, want alice", ClaimedBy(0)) } if Paid() != 100 || Claims() != 1 { t.Errorf("Paid=%d Claims=%d, want 100 and 1", Paid(), Claims()) } if Balance() != 900 { t.Errorf("drop balance %d, want 900", Balance()) } } func TestClaimRejects(cur realm, t *testing.T) { seed() fund(1000) testing.SetRealm(testing.NewUserRealm(alice)) urequire.AbortsContains(t, cur, "invalid proof", func() { Claim(cross(cur), 0, 999, DemoProof(0)) // wrong amount }) urequire.AbortsContains(t, cur, "invalid proof", func() { Claim(cross(cur), 1, 250, DemoProof(1)) // bob's allocation, alice calling }) urequire.AbortsContains(t, cur, "invalid proof", func() { Claim(cross(cur), 0, 100, "deadbeef") // malformed proof }) urequire.AbortsContains(t, cur, "amount must be positive", func() { Claim(cross(cur), 0, 0, DemoProof(0)) }) testing.SetRealm(testing.NewUserRealm(mallory)) urequire.AbortsContains(t, cur, "invalid proof", func() { Claim(cross(cur), 0, 100, DemoProof(0)) // not in the tree at all }) // Nothing above may have consumed an allocation. if Claims() != 0 || Paid() != 0 { t.Errorf("a rejected claim left state behind: Claims=%d Paid=%d", Claims(), Paid()) } } func TestDoubleClaimRejected(cur realm, t *testing.T) { seed() fund(1000) testing.SetRealm(testing.NewUserRealm(bob)) Claim(cross(cur), 1, 250, DemoProof(1)) urequire.AbortsContains(t, cur, "already claimed", func() { Claim(cross(cur), 1, 250, DemoProof(1)) }) if Claims() != 1 { t.Errorf("Claims = %d, want 1", Claims()) } } // An underfunded drop must refuse rather than mark the allocation claimed and // leave the recipient with nothing to re-claim. func TestUnderfundedDropDoesNotBurnAnAllocation(cur realm, t *testing.T) { seed() fund(10) // less than alice's 100 testing.SetRealm(testing.NewUserRealm(alice)) urequire.AbortsContains(t, cur, "cannot cover", func() { Claim(cross(cur), 0, 100, DemoProof(0)) }) if HasClaimed(0) { t.Error("a refused claim still consumed the allocation") } fund(1000) Claim(cross(cur), 0, 100, DemoProof(0)) if !HasClaimed(0) { t.Error("the claim did not go through once funded") } } func TestSetDropOwnerOnly(cur realm, t *testing.T) { seed() newRoot := merkle.New([][]byte{[]byte("only-leaf")}).RootHex() testing.SetRealm(testing.NewUserRealm(mallory)) urequire.AbortsContains(t, cur, "not owner", func() { SetDrop(cross(cur), newRoot, 1, 0) }) testing.SetRealm(testing.NewUserRealm(Ownable.Owner())) SetDrop(cross(cur), newRoot, 1, 0) if Root() != newRoot || Total() != 1 { t.Errorf("SetDrop left Root=%s Total=%d", Root(), Total()) } if len(demo) != 0 { t.Error("a real drop must not keep the seeded allocation list") } if Claims() != 0 || Paid() != 0 { t.Error("SetDrop did not reset the claim ledger") } } func TestSetDropRejectsBadInput(cur realm, t *testing.T) { seed() testing.SetRealm(testing.NewUserRealm(Ownable.Owner())) good := merkle.New([][]byte{[]byte("x")}).RootHex() urequire.AbortsContains(t, cur, "32 bytes of hex", func() { SetDrop(cross(cur), "zz", 1, 0) }) urequire.AbortsContains(t, cur, "32 bytes of hex", func() { SetDrop(cross(cur), "abcd", 1, 0) }) urequire.AbortsContains(t, cur, "total must be positive", func() { SetDrop(cross(cur), good, 0, 0) }) } func TestClosingAndSweep(cur realm, t *testing.T) { seed() fund(1000) owner := Ownable.Owner() testing.SetRealm(testing.NewUserRealm(owner)) // A drop that never closes can never be swept: that is what setting a // closing height buys. urequire.AbortsContains(t, cur, "not closed yet", func() { Sweep(cross(cur)) }) closeAt := runtime.ChainHeight() + 10 root := merkle.New([][]byte{[]byte(Leaf(0, alice, 100))}).RootHex() SetDrop(cross(cur), root, 1, closeAt) if IsClosed() { t.Fatal("drop closed before its height") } urequire.AbortsContains(t, cur, "not closed yet", func() { Sweep(cross(cur)) }) testing.SkipHeights(11) if !IsClosed() { t.Fatal("drop still open past its closing height") } testing.SetRealm(testing.NewUserRealm(alice)) urequire.AbortsContains(t, cur, "has closed", func() { Claim(cross(cur), 0, 100, "") }) ownerBefore := balanceOf(owner) testing.SetRealm(testing.NewUserRealm(owner)) Sweep(cross(cur)) if Balance() != 0 { t.Errorf("drop balance after sweep = %d, want 0", Balance()) } if balanceOf(owner)-ownerBefore != 1000 { t.Errorf("owner received %d, want 1000", balanceOf(owner)-ownerBefore) } seed() } func TestIndexKeyPadsForOrdering(t *testing.T) { // ufmt has no width flags, so the padding is by hand. Unpadded keys sort // "0","1","10","11","2" and Render loses the order past nine entries. if indexKey(7) != "000007" || indexKey(123456) != "123456" { t.Errorf("indexKey(7)=%q indexKey(123456)=%q", indexKey(7), indexKey(123456)) } if !(indexKey(2) < indexKey(10)) { t.Error("padded keys do not sort numerically") } }
  10. #10render.gno
  11. #11package merkledrop import ( "strconv" "strings" "gno.land/p/moul/x/merkle/v0" "gno.land/p/nt/avl/v0" ) // demoAllocations is the example drop seeded at deploy, so the realm renders // working proofs instead of an empty page. A real drop calls SetDrop with a // root computed off chain and stores no allocations at all. var demoAllocations = []Allocation{ {Address: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", Amount: 100}, {Address: "g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", Amount: 250}, {Address: "g1manfred47kzduec920z88wfr64ylksmdcedlf5", Amount: 500}, {Address: "g1sss9uxef4l6lwc0mxq8n5v0e4vqpml7c39cxq2", Amount: 750}, } // seed installs the example drop. Also called by the example tests: realm // globals persist for a whole test binary and examples run after every Test, // so a pinned Render must start from a known state. func seed() { demo = demoAllocations tree := demoTree() root = tree.Root() total = len(demo) closesAt = 0 claimed, paid = avl.Tree{}, 0 } func demoTree() *merkle.Tree { leaves := make([][]byte, len(demo)) for i, a := range demo { leaves[i] = []byte(Leaf(i, a.Address, a.Amount)) } return merkle.New(leaves) } // DemoProof returns the proof for one seeded allocation, so the drop can be // exercised end to end without an off-chain generator. It is empty once a real // drop replaces the seeded one. func DemoProof(index int) string { if index < 0 || index >= len(demo) { return "" } p, err := demoTree().Proof(index) if err != nil { return "" } return p.Hex() } // Render serves the drop overview at "" and one allocation at "proof/<index>". func Render(path string) string { path = strings.TrimSpace(path) if strings.HasPrefix(path, "proof/") { return renderProof(strings.TrimPrefix(path, "proof/")) } return renderIndex() } func renderIndex() string { var b strings.Builder b.WriteString("# MerkleDrop v1\n\n") b.WriteString("A Merkle root gates an airdrop that pays real ugnot. Successor to\n") b.WriteString("[v0](/r/moul/x/daily/merkledrop/v0), which moved no coins and whose proof scheme was\n") b.WriteString("safe only by accident of encoding lengths.\n\n") b.WriteString("## Drop\n\n") b.WriteString("| Field | Value |\n|---|---|\n") if len(root) == 0 { b.WriteString("| Root | _none configured_ |\n") } else { b.WriteString("| Root | `" + Root() + "` |\n") } b.WriteString("| Allocations | " + strconv.Itoa(total) + " |\n") b.WriteString("| Closes at height | " + closesLabel() + " |\n") b.WriteString("| Claimed | " + strconv.Itoa(Claims()) + " |\n") b.WriteString("| Paid out | " + strconv.FormatInt(Paid(), 10) + " ugnot |\n") b.WriteString("| Funding address | `" + Address().String() + "` |\n\n") b.WriteString("## How it differs from v0\n\n") b.WriteString("1. **Domain separated leaves.** v0 hashed leaves bare and combined nodes\n") b.WriteString(" commutatively, so an inner-node hash was also a valid leaf hash. It was not\n") b.WriteString(" exploitable, but only because a v0 leaf preimage tops out at 61 bytes while an\n") b.WriteString(" inner preimage is always 64. v1 tags leaves `0x00` and inner nodes `0x01`.\n") b.WriteString("2. **Proofs bound to a position.** v0 folded a sibling list of any length. v1\n") b.WriteString(" carries index and total, rebuilds the tree shape, and caps the depth.\n") b.WriteString("3. **A settable root and a closing height.** v0's root was a `const`.\n") b.WriteString("4. **Real coins.** v0 kept a `uint64` ledger and moved nothing.\n\n") b.WriteString("## Leaf encoding\n\n") b.WriteString("Reproduce this off chain to rebuild the tree:\n\n") b.WriteString("```\n") b.WriteString("leaf = \"" + leafPrefix + "|<index>|<address>|<amount>\"\n") b.WriteString("tree = Tendermint simple tree, leaf 0x00, inner 0x01\n") b.WriteString("```\n\n") if len(demo) == 0 { b.WriteString("## Allocations\n\n") b.WriteString("A live drop commits only its root, so there is nothing to list here.\n") return b.String() } b.WriteString("## Example allocations\n\n") b.WriteString("Seeded at deploy so the drop can be exercised without a generator. A live drop\n") b.WriteString("commits only a root and stores none of this.\n\n") b.WriteString("| # | Address | Amount | Claimed |\n|---|---|---|---|\n") for i, a := range demo { b.WriteString("| [" + strconv.Itoa(i) + "](/r/moul/x/daily/merkledrop/v1:proof/" + strconv.Itoa(i) + ") | `" + a.Address.String() + "` | " + strconv.FormatInt(a.Amount, 10) + " | " + yesNo(HasClaimed(i)) + " |\n") } return b.String() } func renderProof(raw string) string { i, err := strconv.Atoi(raw) if err != nil || i < 0 || i >= len(demo) { return "# MerkleDrop v1\n\nNo allocation `" + raw + "`.\n" } a := demo[i] var b strings.Builder b.WriteString("# MerkleDrop v1: allocation " + strconv.Itoa(i) + "\n\n") b.WriteString("| Field | Value |\n|---|---|\n") b.WriteString("| Index | " + strconv.Itoa(i) + " |\n") b.WriteString("| Total | " + strconv.Itoa(total) + " |\n") b.WriteString("| Address | `" + a.Address.String() + "` |\n") b.WriteString("| Amount | " + strconv.FormatInt(a.Amount, 10) + " ugnot |\n") b.WriteString("| Claimed | " + yesNo(HasClaimed(i)) + " |\n\n") b.WriteString("## Leaf\n\n") b.WriteString("```\n" + Leaf(i, a.Address, a.Amount) + "\n```\n\n") b.WriteString("## Proof\n\n") b.WriteString("```\n" + DemoProof(i) + "\n```\n\n") b.WriteString("Only `" + a.Address.String() + "` can spend it: the address is inside the leaf, so\n") b.WriteString("the proof verifies for no one else.\n") return b.String() } func closesLabel() string { if closesAt == 0 { return "never" } return strconv.FormatInt(closesAt, 10) } func yesNo(b bool) string { if b { return "yes" } return "no" }
  12. #12render_example_test.gno
  13. #13package merkledrop // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { seed() print(Render("")) // Output: // # MerkleDrop v1 // // A Merkle root gates an airdrop that pays real ugnot. Successor to // [v0](/r/moul/x/daily/merkledrop/v0), which moved no coins and whose proof scheme was // safe only by accident of encoding lengths. // // ## Drop // // | Field | Value | // |---|---| // | Root | `6964baa99da23b9d8c76774c6f1122b250040e29c1d75823dda15c043dc65755` | // | Allocations | 4 | // | Closes at height | never | // | Claimed | 0 | // | Paid out | 0 ugnot | // | Funding address | `g1jvh5ukk07dvd57fxefcp5aa29xaydxmxs7myyp` | // // ## How it differs from v0 // // 1. **Domain separated leaves.** v0 hashed leaves bare and combined nodes // commutatively, so an inner-node hash was also a valid leaf hash. It was not // exploitable, but only because a v0 leaf preimage tops out at 61 bytes while an // inner preimage is always 64. v1 tags leaves `0x00` and inner nodes `0x01`. // 2. **Proofs bound to a position.** v0 folded a sibling list of any length. v1 // carries index and total, rebuilds the tree shape, and caps the depth. // 3. **A settable root and a closing height.** v0's root was a `const`. // 4. **Real coins.** v0 kept a `uint64` ledger and moved nothing. // // ## Leaf encoding // // Reproduce this off chain to rebuild the tree: // // ``` // leaf = "gno.land/r/moul/x/daily/merkledrop/v1|<index>|<address>|<amount>" // tree = Tendermint simple tree, leaf 0x00, inner 0x01 // ``` // // ## Example allocations // // Seeded at deploy so the drop can be exercised without a generator. A live drop // commits only a root and stores none of this. // // | # | Address | Amount | Claimed | // |---|---|---|---| // | [0](/r/moul/x/daily/merkledrop/v1:proof/0) | `g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5` | 100 | no | // | [1](/r/moul/x/daily/merkledrop/v1:proof/1) | `g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj` | 250 | no | // | [2](/r/moul/x/daily/merkledrop/v1:proof/2) | `g1manfred47kzduec920z88wfr64ylksmdcedlf5` | 500 | no | // | [3](/r/moul/x/daily/merkledrop/v1:proof/3) | `g1sss9uxef4l6lwc0mxq8n5v0e4vqpml7c39cxq2` | 750 | no | } // ExampleRenderProof pins one allocation page, including its live proof. func ExampleRenderProof() { seed() print(Render("proof/2")) // Output: // # MerkleDrop v1: allocation 2 // // | Field | Value | // |---|---| // | Index | 2 | // | Total | 4 | // | Address | `g1manfred47kzduec920z88wfr64ylksmdcedlf5` | // | Amount | 500 ugnot | // | Claimed | no | // // ## Leaf // // ``` // gno.land/r/moul/x/daily/merkledrop/v1|2|g1manfred47kzduec920z88wfr64ylksmdcedlf5|500 // ``` // // ## Proof // // ``` // 337bb23c66f621db38e704fc2d64d27d5fa6308a333fd9d2d69c6ccd9390c056,b498260d909eee2a1b8a88524d5e4d08128aa7d8ca433468379db031976958e1 // ``` // // Only `g1manfred47kzduec920z88wfr64ylksmdcedlf5` can spend it: the address is inside the leaf, so // the proof verifies for no one else. } // ExampleRenderMissingProof pins the not-found page. func ExampleRenderMissingProof() { seed() print(Render("proof/99")) // Output: // # MerkleDrop v1 // // No allocation `99`. }
#16AddPackagegno.land/r/moul/x/daily/splitter/v111 arguments
Attached funds
5000000ugnot

Arguments · 11

  1. #1splitter
  2. #2README.md
  3. #3# Payment Splitter > ⚠️ **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 share-based payment splitter for gno.land — an accounting-only port of the Solidity `PaymentSplitter`. No real coins move: each group tracks payees, their integer shares, and a pooled income figure, and computes what each payee is owed as `amount * share / totalShares`. Register a group with comma-separated payee addresses and matching shares, record income against it, and `Render` shows every group's per-payee owed amounts (integer division; any rounding remainder is displayed separately). **Realm path:** `gno.land/r/REPLACE_ADDR/splitter` ## Example calls ``` # Create a group: alice gets 3 shares, bob gets 1 -> returns group id 0 Register("g1alice...,g1bob...", "3,1") # Record 100 units of income into group 0 RecordIncome(0, 100) # View the ledger (alice owed 75, bob owed 25) gnokey query vm/qrender --data "gno.land/r/REPLACE_ADDR/splitter:" ``` ## What changed in v1 State moved from a hand-rolled `avl.Tree` + `nextID` + zero-padding helper to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. v0 padded ids to width 12. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. `group` loses its `id` field, which the store now owns, and **group ids start at 1** where `v0` handed out 0 for the first group. This is a storage change, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/splitter) stays live and untouched. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/splitter/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/splitter/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/splitter/v1" gno = "0.9" private = true
  6. #6render_example_test.gno
  7. #7package splitter // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # Payment Splitter // // _No groups registered yet._ }
  8. #8splitter.gno
  9. #9// Package splitter is a share-based payment splitter for gno.land. // // It is an accounting-only port of the Solidity PaymentSplitter pattern: // no real coins move. A group is registered with a list of payees and a // matching list of integer shares. Income recorded against a group is // pooled, and each payee is owed a slice of that pool proportional to // their shares: owed = pool * share / totalShares. package splitter import ( "errors" "strconv" "strings" "chain" "chain/runtime/unsafe" "gno.land/p/moul/kit/store/v0" ) // payee is a single share holder inside a group. type payee struct { addr address shares int } // group is one payment-splitting arrangement. It carries no id field: the id // belongs to the store, which hands it back on lookup and iteration. type group struct { owner address payees []payee totalShares int pool int // total income recorded, in abstract units } var ( // groups assigns the group ids. v0 kept its own nextID plus a key() that // zero-padded to width 12, which stopped ordering Render past 10^12. groups = store.Named("splitter: group") errEmpty = errors.New("splitter: no payees") ) // Register creates a new group from comma-separated payees and shares. // payees: "g1abc...,g1def..." (addresses) // shares: "3,1" (positive integers, same count as payees) // Returns the new group id. Panics on malformed input. func Register(cur realm, payees string, shares string) int { caller := unsafe.PreviousRealm().Address() addrParts := splitTrim(payees) shareParts := splitTrim(shares) if len(addrParts) == 0 { panic(errEmpty) } if len(addrParts) != len(shareParts) { panic("splitter: payees and shares count mismatch") } g := &group{owner: caller} for i := range addrParts { if addrParts[i] == "" { panic("splitter: empty payee address") } s, err := strconv.Atoi(shareParts[i]) if err != nil { panic("splitter: bad share value: " + shareParts[i]) } if s <= 0 { panic("splitter: share must be positive") } g.payees = append(g.payees, payee{ addr: address(addrParts[i]), shares: s, }) g.totalShares += s } id := groups.Add(g) chain.Emit( "GroupRegistered", "id", id.String(), "payees", strconv.Itoa(len(g.payees)), "totalShares", strconv.Itoa(g.totalShares), ) return int(id) } // RecordIncome adds amount to the pool of group id. amount must be positive. func RecordIncome(cur realm, id int, amount int) { if amount <= 0 { panic("splitter: amount must be positive") } g := groups.MustGet(store.ID(id)).(*group) g.pool += amount chain.Emit( "IncomeRecorded", "id", strconv.Itoa(id), "amount", strconv.Itoa(amount), "pool", strconv.Itoa(g.pool), ) } // owed returns the amount owed to a payee: pool * shares / totalShares. func (g *group) owed(p payee) int { if g.totalShares == 0 { return 0 } return g.pool * p.shares / g.totalShares } // Render shows all groups, their payees, shares, and computed owed amounts. func Render(path string) string { if groups.Len() == 0 { return "# Payment Splitter\n\n_No groups registered yet._\n" } var b strings.Builder b.WriteString("# Payment Splitter\n\n") b.WriteString("Share-based accounting. `owed = pool * share / totalShares`.\n\n") groups.Each(func(id store.ID, v any) { g := v.(*group) b.WriteString("## Group #" + id.String() + "\n\n") b.WriteString("- Owner: `" + g.owner.String() + "`\n") b.WriteString("- Pool: " + strconv.Itoa(g.pool) + "\n") b.WriteString("- Total shares: " + strconv.Itoa(g.totalShares) + "\n\n") b.WriteString("| Payee | Shares | Owed |\n") b.WriteString("|---|---:|---:|\n") distributed := 0 for _, p := range g.payees { o := g.owed(p) distributed += o b.WriteString("| `" + p.addr.String() + "` | " + strconv.Itoa(p.shares) + " | " + strconv.Itoa(o) + " |\n") } remainder := g.pool - distributed if remainder > 0 { b.WriteString("| _remainder (rounding)_ | | " + strconv.Itoa(remainder) + " |\n") } b.WriteString("\n") }) return b.String() } // splitTrim splits on commas and trims whitespace around each element. func splitTrim(s string) []string { if strings.TrimSpace(s) == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { out = append(out, strings.TrimSpace(p)) } return out }
  10. #10splitter_test.gno
  11. #11package splitter import ( "strings" "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) func reset() { groups = store.Named("splitter: group") } func TestRegisterAndOwed(cur realm, t *testing.T) { // reset package state for a deterministic run reset() owner := testutils.TestAddress("owner") testing.SetRealm(testing.NewUserRealm(owner)) a := testutils.TestAddress("alice").String() b := testutils.TestAddress("bob").String() id := Register(cross(cur), a+","+b, "3,1") // v1 ids start at 1: the store never assigns 0, so the zero id stays // usable as "absent". v0 handed out 0 for the first group. uassert.Equal(t, 1, id, "first group id should be 1") // pool 100, shares 3:1 -> alice 75, bob 25 RecordIncome(cross(cur), id, 100) out := Render("") uassert.True(t, strings.Contains(out, "Group #1"), "render shows group") uassert.True(t, strings.Contains(out, "| 3 | 75 |"), "alice owed 75") uassert.True(t, strings.Contains(out, "| 1 | 25 |"), "bob owed 25") uassert.True(t, strings.Contains(out, "Pool: 100"), "pool shown") } func TestRoundingRemainder(cur realm, t *testing.T) { reset() owner := testutils.TestAddress("owner2") testing.SetRealm(testing.NewUserRealm(owner)) a := testutils.TestAddress("a").String() b := testutils.TestAddress("b").String() c := testutils.TestAddress("c").String() id := Register(cross(cur), a+","+b+","+c, "1,1,1") RecordIncome(cross(cur), id, 100) // 100/3 = 33 each, remainder 1 out := Render("") uassert.True(t, strings.Contains(out, "| 1 | 33 |"), "each owed 33") uassert.True(t, strings.Contains(out, "remainder"), "remainder row shown") uassert.True(t, strings.Contains(out, "| 1 |\n"), "remainder value 1") } func TestBadInputAborts(cur realm, t *testing.T) { reset() owner := testutils.TestAddress("owner3") testing.SetRealm(testing.NewUserRealm(owner)) a := testutils.TestAddress("alice").String() // mismatched payees/shares count aborts uassert.AbortsWithMessage(t, cur, "splitter: payees and shares count mismatch", func() { Register(cross(cur), a, "1,2") }) // non-positive share aborts uassert.AbortsWithMessage(t, cur, "splitter: share must be positive", func() { Register(cross(cur), a, "0") }) }
#17AddPackagegno.land/r/moul/x/daily/tictactoe/v111 arguments
Attached funds
6000000ugnot

Arguments · 11

  1. #1tictactoe
  2. #2README.md
  3. #3# Tic-Tac-Toe > ⚠️ **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 2-player tic-tac-toe realm for gno.land. The game creator plays **X**, the named opponent plays **O**. Moves enforce strict turn order by caller address and reject cells that are already taken. The realm detects wins (rows, columns, diagonals) and draws, and `Render` draws the 3×3 board with `X` / `O` / `·`, whose turn it is, and the final result. Realm path: `gno.land/r/REPLACE_ADDR/tictactoe` ## Example calls ``` # Alice creates a game against Bob — Alice is X, Bob is O. Returns the game id (e.g. 1). NewGame(g1pxk...bob) # Play a cell (0-8), indexed left-to-right, top-to-bottom: # 0 | 1 | 2 # 3 | 4 | 5 # 6 | 7 | 8 Move(1, 0) # X (creator) plays top-left Move(1, 3) # O (opponent) plays middle-left Move(1, 1) # X plays top-middle Move(1, 4) # O plays center Move(1, 2) # X completes the top row and wins # View a single game board: Render("/1") # List all games: Render("") ``` ## What changed in v1 State moved from a hand-rolled `avl.Tree` + `nextID` + `pad()` to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. `v0` padded ids to width 12. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every game list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. `Game` also loses its `id` field, which the store now owns. Ids stay plain integers and the rendered output is byte-identical: `v0`'s `render_test.gno` is carried over unchanged and still passes. This is a storage change, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/tictactoe) stays live and untouched. One message did change: a `Move` against a missing game now panics `game #7 not found` instead of `game not found`, because the store's labelled `MustGet` adds the id every hand-rolled copy of that check omitted. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/tictactoe/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/tictactoe/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/tictactoe/v1" gno = "0.9" private = true
  6. #6render_test.gno
  7. #7package tictactoe import ( "strconv" "strings" "testing" "gno.land/p/nt/uassert/v0" ) // emptyRow is one rendered board row with no marks. The trailing space after // the final pipe is part of renderBoard's output — keep it. const emptyRow = "| · | · | · | \n" // TestRenderGame pins the exact single-game view. The game id is taken from // NewGame rather than hardcoded: realm globals persist across tests, so the // id depends on how many games earlier tests created. func TestRenderGame(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) id := NewGame(cross(cur), bob) want := "# Tic-Tac-Toe — Game #" + strconv.Itoa(id) + "\n\n" + "- X: `" + alice.String() + "`\n" + "- O: `" + bob.String() + "`\n\n" + emptyRow + emptyRow + emptyRow + "\n**Turn: X**\n\n" + "Cell indices:\n\n" + "| 0 | 1 | 2 |\n| 3 | 4 | 5 |\n| 6 | 7 | 8 |\n" uassert.Equal(t, want, Render("/"+strconv.Itoa(id))) } // TestRenderReflectsMoves checks the board and status track game state. func TestRenderReflectsMoves(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) id := NewGame(cross(cur), bob) path := "/" + strconv.Itoa(id) testing.SetRealm(testing.NewUserRealm(alice)) Move(cross(cur), id, 0) out := Render(path) uassert.True(t, strings.Contains(out, "| X | · | · | \n"), "X should appear in cell 0") uassert.True(t, strings.Contains(out, "**Turn: O**"), "turn should pass to O") testing.SetRealm(testing.NewUserRealm(bob)) Move(cross(cur), id, 4) out = Render(path) uassert.True(t, strings.Contains(out, "| X | · | · | \n| · | O | · | \n"), "O should appear in cell 4, below X") uassert.True(t, strings.Contains(out, "**Turn: X**"), "turn should pass back to X") } // TestRenderFinishedStatus checks a decided game reports its winner. func TestRenderFinishedStatus(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) id := NewGame(cross(cur), bob) // X takes the top row. seq := []struct { who address cell int }{{alice, 0}, {bob, 3}, {alice, 1}, {bob, 4}, {alice, 2}} for _, s := range seq { testing.SetRealm(testing.NewUserRealm(s.who)) Move(cross(cur), id, s.cell) } uassert.True(t, strings.Contains(Render("/"+strconv.Itoa(id)), "**Winner: X**"), "should report X as winner") } // TestRenderBadPaths covers the two error branches of Render. func TestRenderBadPaths(cur realm, t *testing.T) { uassert.Equal(t, "# Tic-Tac-Toe\n\nInvalid game id: abc\n", Render("/abc")) uassert.Equal(t, "# Tic-Tac-Toe\n\nGame 999999 not found.\n", Render("/999999")) } // TestRenderList checks the index lists games created in this run. func TestRenderList(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) id := NewGame(cross(cur), bob) out := Render("") uassert.True(t, strings.HasPrefix(out, "# Tic-Tac-Toe\n"), "expected the index header") uassert.True(t, strings.Contains(out, "| # | X | O | Status |"), "expected the table header") uassert.True(t, strings.Contains(out, "| "+strconv.Itoa(id)+" | `"+alice.String()+"`"), "expected the new game listed") }
  8. #8tictactoe.gno
  9. #9package tictactoe import ( "chain" "chain/runtime/unsafe" "errors" "strconv" "strings" "gno.land/p/moul/kit/store/v0" ) // Cell states. const ( empty = 0 markX = 1 markO = 2 ) // Game holds a single tic-tac-toe match. It carries no id field: the id // belongs to the store, which hands it back on lookup and iteration. type Game struct { playerX address // caller of NewGame playerO address // opponent board [9]int // 0 empty, 1 X, 2 O moves int // number of moves played turn int // markX or markO — whose turn it is winner int // 0 none, 1 X, 2 O draw bool finished bool } // games is keyed by store.ID, whose avl key is fixed-width by construction. // v0 kept its own nextID plus a pad() that zero-padded to width 12, which // stopped ordering correctly at the 10^12th game. var games = store.Named("game") // NewGame creates a game. Caller is X, opponent is O. Returns the game id. func NewGame(cur realm, opponent address) int { caller := unsafe.PreviousRealm().Address() if opponent == caller { panic("opponent must differ from caller") } if !opponent.IsValid() { panic("invalid opponent address") } id := int(games.Add(&Game{ playerX: caller, playerO: opponent, turn: markX, })) chain.Emit("GameCreated", "id", strconv.Itoa(id), "x", caller.String(), "o", opponent.String()) return id } // Move plays cell (0-8) for the calling player. Enforces turn order and rejects taken cells. func Move(cur realm, gameID int, cell int) { g := games.MustGet(store.ID(gameID)).(*Game) if g.finished { panic("game already finished") } if cell < 0 || cell > 8 { panic("cell out of range (0-8)") } if g.board[cell] != empty { panic("cell already taken") } caller := unsafe.PreviousRealm().Address() var mark int switch caller { case g.playerX: mark = markX case g.playerO: mark = markO default: panic("caller is not a player in this game") } if mark != g.turn { panic("not your turn") } g.board[cell] = mark g.moves++ if won(g.board, mark) { g.winner = mark g.finished = true chain.Emit("GameWon", "id", strconv.Itoa(gameID), "winner", markName(mark)) } else if g.moves == 9 { g.draw = true g.finished = true chain.Emit("GameDraw", "id", strconv.Itoa(gameID)) } else { if g.turn == markX { g.turn = markO } else { g.turn = markX } chain.Emit("Move", "id", strconv.Itoa(gameID), "cell", strconv.Itoa(cell), "mark", markName(mark)) } } var lines = [8][3]int{ {0, 1, 2}, {3, 4, 5}, {6, 7, 8}, // rows {0, 3, 6}, {1, 4, 7}, {2, 5, 8}, // cols {0, 4, 8}, {2, 4, 6}, // diagonals } func won(b [9]int, mark int) bool { for _, l := range lines { if b[l[0]] == mark && b[l[1]] == mark && b[l[2]] == mark { return true } } return false } func markName(m int) string { switch m { case markX: return "X" case markO: return "O" default: return "-" } } func cellGlyph(m int) string { switch m { case markX: return "X" case markO: return "O" default: return "·" } } // GetGame returns a game by id (read helper for tests / callers). func GetGame(id int) (*Game, error) { v, ok := games.Get(store.ID(id)) if !ok { return nil, errors.New("game not found") } return v.(*Game), nil } func renderBoard(b [9]int) string { var sb strings.Builder for r := 0; r < 3; r++ { sb.WriteString("| ") for c := 0; c < 3; c++ { sb.WriteString(cellGlyph(b[r*3+c])) sb.WriteString(" | ") } sb.WriteString("\n") } return sb.String() } func gameStatus(g *Game) string { if g.finished { if g.draw { return "Draw." } return "Winner: " + markName(g.winner) } return "Turn: " + markName(g.turn) } // Render draws the list of games, or a single game board at path "/<id>". func Render(path string) string { p := strings.TrimPrefix(path, "/") if p == "" { return renderList() } id, err := strconv.Atoi(p) if err != nil { return "# Tic-Tac-Toe\n\nInvalid game id: " + p + "\n" } g, gerr := GetGame(id) if gerr != nil { return "# Tic-Tac-Toe\n\nGame " + p + " not found.\n" } var sb strings.Builder sb.WriteString("# Tic-Tac-Toe — Game #") sb.WriteString(strconv.Itoa(id)) sb.WriteString("\n\n") sb.WriteString("- X: `") sb.WriteString(g.playerX.String()) sb.WriteString("`\n- O: `") sb.WriteString(g.playerO.String()) sb.WriteString("`\n\n") sb.WriteString(renderBoard(g.board)) sb.WriteString("\n**") sb.WriteString(gameStatus(g)) sb.WriteString("**\n\nCell indices:\n\n") sb.WriteString("| 0 | 1 | 2 |\n| 3 | 4 | 5 |\n| 6 | 7 | 8 |\n") return sb.String() } func renderList() string { var sb strings.Builder sb.WriteString("# Tic-Tac-Toe\n\n") sb.WriteString("2-player tic-tac-toe. Call `NewGame(opponent)` to start; view a game at `/<id>`.\n\n") if games.Len() == 0 { sb.WriteString("_No games yet._\n") return sb.String() } sb.WriteString("| # | X | O | Status |\n|---|---|---|--------|\n") games.Each(func(id store.ID, value any) { g := value.(*Game) sb.WriteString("| ") sb.WriteString(id.String()) sb.WriteString(" | `") sb.WriteString(g.playerX.String()) sb.WriteString("` | `") sb.WriteString(g.playerO.String()) sb.WriteString("` | ") sb.WriteString(gameStatus(g)) sb.WriteString(" |\n") }) return sb.String() }
  10. #10tictactoe_test.gno
  11. #11package tictactoe import ( "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") ) // TestXWins: alice (X) beats bob (O) on the top row. func TestXWins(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) id := NewGame(cross(cur), bob) uassert.True(t, id >= 1, "expected a game id") // alice X -> 0 testing.SetRealm(testing.NewUserRealm(alice)) Move(cross(cur), id, 0) // bob O -> 3 testing.SetRealm(testing.NewUserRealm(bob)) Move(cross(cur), id, 3) // alice X -> 1 testing.SetRealm(testing.NewUserRealm(alice)) Move(cross(cur), id, 1) // bob O -> 4 testing.SetRealm(testing.NewUserRealm(bob)) Move(cross(cur), id, 4) // alice X -> 2 wins top row testing.SetRealm(testing.NewUserRealm(alice)) Move(cross(cur), id, 2) g, err := GetGame(id) uassert.NoError(t, err) uassert.True(t, g.finished, "game should be finished") uassert.Equal(t, markX, g.winner) uassert.False(t, g.draw, "should not be a draw") } // TestTurnAndTakenGuards: rejects out-of-turn moves, taken cells, and non-players. func TestTurnAndTakenGuards(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) id := NewGame(cross(cur), bob) // bob (O) cannot move first — it's X's turn. testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsWithMessage(t, cur, "not your turn", func() { Move(cross(cur), id, 0) }) // alice (X) plays 0. testing.SetRealm(testing.NewUserRealm(alice)) Move(cross(cur), id, 0) // alice cannot play twice in a row. testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "not your turn", func() { Move(cross(cur), id, 1) }) // bob cannot take an occupied cell. testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsWithMessage(t, cur, "cell already taken", func() { Move(cross(cur), id, 0) }) // carol is not a player. testing.SetRealm(testing.NewUserRealm(carol)) uassert.AbortsWithMessage(t, cur, "caller is not a player in this game", func() { Move(cross(cur), id, 5) }) } // TestDraw: fill the board with no winner. func TestDraw(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) id := NewGame(cross(cur), bob) // Sequence yielding a full board with no three-in-a-row: // X:0 O:1 X:2 O:4 X:3 O:5 X:7 O:6 X:8 seq := []struct { who address cell int }{ {alice, 0}, {bob, 1}, {alice, 2}, {bob, 4}, {alice, 3}, {bob, 5}, {alice, 7}, {bob, 6}, {alice, 8}, } for _, s := range seq { testing.SetRealm(testing.NewUserRealm(s.who)) Move(cross(cur), id, s.cell) } g, err := GetGame(id) uassert.NoError(t, err) uassert.True(t, g.finished, "game should be finished") uassert.True(t, g.draw, "should be a draw") uassert.Equal(t, 0, g.winner) } // The store's labelled MustGet names the game v0's "game not found" left out. func TestMoveOnAMissingGameNamesIt(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "game #999999 not found", func() { Move(cross(cur), 999999, 0) }) }
Attached funds
5000000ugnot

Arguments · 11

  1. #1timecapsule
  2. #2README.md
  3. #3# Time Capsule Guestbook > ⚠️ **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 public guestbook with a twist: every message is sealed until a future block height, chosen by its author when they leave it. Until then it's just a locked entry showing who sealed it and when it opens; once the chain reaches that height, the message unlocks forever for anyone to read. Realm: `gno.land/r/g12cs4cehujpffpjpywmkqj43m6u5ya53nj69sjz/timecapsule` ## Example calls - `Leave("see you in 100 blocks", 100)` — seal a message that opens 100 blocks from now. Returns the capsule id. Only direct EOA (`MsgCall`) calls can seal one; empty messages, messages over 500 bytes, and delays outside `(0, 1_000_000]` blocks are rejected. - `Render("")` — home page: counts + every already-unlocked message. - `Render("sealed")` — every still-sealed capsule with its unlock height and blocks remaining (message hidden). - `Render("capsule/<id>")` — a single capsule's detail page; shows the message only once unlocked. ## Toolchain status Tested locally against a `gnolang/gno` master checkout (`~/p/gh/gnoland/gno`) using its own `gno` binary as GNOROOT, resolving `/p/nt/*` deps through its `examples/` workspace. All 5 unit tests pass (`gno test -v .`), and `gno lint .` reports no issues. This is newer than sapphire (gno 0.9), but the code only uses APIs documented as current in the project's gno skill (`chain`, `chain/runtime`, `gno.land/p/nt/avl/v0`, `gno.land/p/nt/markdown/sanitize/v0`), so it should be on-chain-valid for sapphire as well. ## What changed in v1 State moved from a hand-rolled `avl.Tree` + `nextID` + zero-padding helper to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. v0 padded ids to width 12. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. `capsule` loses its `id` field, which the store now owns, and **capsule ids start at 1** where `v0` handed out 0 for the first one. `capsule/0` is now rejected as an invalid id rather than looked up. This is a storage change, which under this repo's versioning rule is a compatibility change, hence a new version rather than an edit in place. [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/timecapsule) stays live and untouched. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/timecapsule/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/timecapsule/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/timecapsule/v1" gno = "0.9" private = true
  6. #6render_example_test.gno
  7. #7package timecapsule // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # ⏳ Time Capsule Guestbook // // Leave a message for the future. It stays sealed until its unlock block height, then anyone can read it — call `Leave(message, delayBlocks)`. // // 0 capsule(s) total · 0 unlocked · 0 still sealed. // // ## Unlocked messages // // _None yet. Be the first to leave one that will open later._ }
  8. #8timecapsule.gno
  9. #9// Package timecapsule is a public guestbook where every message is sealed // until a future block height, then permanently unlocked for anyone to read. package timecapsule import ( "strconv" "strings" "chain" "chain/runtime" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/markdown/sanitize/v0" ) // capsule carries no id field: the id belongs to the store, which hands it // back on lookup and iteration. type capsule struct { author address message string createdAt int64 unlockHeight int64 } // capsules assigns the capsule ids. v0 kept its own nextID plus an idKey() // that called strings.Repeat WITHOUT the length guard the other realms had, so // at the 10^12th capsule it did not mis-sort, it panicked: Repeat rejects a // negative count, and the realm would have stopped accepting Leave calls. The // store key is 8 fixed bytes with no width to outgrow. var capsules = store.Named("capsule") const ( maxDelayBlocks int64 = 1_000_000 maxMessageLen int = 500 ) // Leave seals a new message that stays hidden until `delayBlocks` blocks // from now, then becomes publicly readable forever. Returns the capsule id. func Leave(cur realm, message string, delayBlocks int64) int64 { if !cur.Previous().IsUserCall() { panic("only direct user calls can leave a capsule") } message = strings.TrimSpace(message) if message == "" { panic("message must not be empty") } if len(message) > maxMessageLen { panic("message too long") } if delayBlocks <= 0 || delayBlocks > maxDelayBlocks { panic("delayBlocks out of range") } now := runtime.ChainHeight() c := &capsule{ author: cur.Previous().Address(), message: message, createdAt: now, unlockHeight: now + delayBlocks, } id := capsules.Add(c) chain.Emit("CapsuleSealed", "id", id.String(), "unlockHeight", strconv.FormatInt(c.unlockHeight, 10), ) return int64(id) } func Render(path string) string { switch { case path == "": return renderHome() case path == "sealed": return renderSealed() case strings.HasPrefix(path, "capsule/"): return renderCapsule(strings.TrimPrefix(path, "capsule/")) default: return "> [!WARNING]\n> Path not found\n" } } func renderHome() string { now := runtime.ChainHeight() revealed := 0 sealed := 0 capsules.Each(func(_ store.ID, value any) { if value.(*capsule).unlockHeight <= now { revealed++ } else { sealed++ } }) var out strings.Builder out.WriteString("# ⏳ Time Capsule Guestbook\n\n") out.WriteString("Leave a message for the future. It stays sealed until its unlock block height, then anyone can read it — call `Leave(message, delayBlocks)`.\n\n") out.WriteString(strconv.Itoa(revealed+sealed) + " capsule(s) total · " + strconv.Itoa(revealed) + " unlocked · " + strconv.Itoa(sealed) + " still sealed") if sealed > 0 { out.WriteString(" (see [sealed](sealed))") } out.WriteString(".\n\n## Unlocked messages\n\n") if revealed == 0 { out.WriteString("_None yet. Be the first to leave one that will open later._\n") return out.String() } capsules.Each(func(id store.ID, value any) { c := value.(*capsule) if c.unlockHeight <= now { out.WriteString(renderEntry(id, c)) } }) return out.String() } func renderSealed() string { now := runtime.ChainHeight() var out strings.Builder out.WriteString("# Sealed Capsules\n\n") found := false capsules.Each(func(id store.ID, value any) { c := value.(*capsule) if c.unlockHeight > now { found = true out.WriteString("- capsule [#" + id.String() + "](capsule/" + id.String() + ") by `" + c.author.String() + "` — unlocks at block " + strconv.FormatInt(c.unlockHeight, 10) + " (" + strconv.FormatInt(c.unlockHeight-now, 10) + " blocks left)\n") } }) if !found { out.WriteString("_No sealed capsules right now._\n") } return out.String() } func renderCapsule(idStr string) string { id, ok := store.ParseID(idStr) if !ok { return "> [!WARNING]\n> Invalid capsule id\n" } v, ok := capsules.Get(id) if !ok { return "> [!WARNING]\n> Capsule not found\n" } c := v.(*capsule) now := runtime.ChainHeight() var out strings.Builder out.WriteString("# Capsule #" + id.String() + "\n\n") out.WriteString("Sealed by `" + c.author.String() + "` at block " + strconv.FormatInt(c.createdAt, 10) + ".\n\n") if c.unlockHeight > now { out.WriteString("\U0001f512 Still sealed. Unlocks at block " + strconv.FormatInt(c.unlockHeight, 10) + " (" + strconv.FormatInt(c.unlockHeight-now, 10) + " blocks left).\n") return out.String() } out.WriteString("\U0001f513 Unlocked at block " + strconv.FormatInt(c.unlockHeight, 10) + ":\n\n> " + sanitize.InlineText(c.message) + "\n") return out.String() } func renderEntry(id store.ID, c *capsule) string { return "- **#" + id.String() + "** by `" + c.author.String() + "` (sealed at block " + strconv.FormatInt(c.createdAt, 10) + ", opened at block " + strconv.FormatInt(c.unlockHeight, 10) + "):\n > " + sanitize.InlineText(c.message) + "\n\n" }
  10. #10timecapsule_test.gno
  11. #11package timecapsule import ( "strings" "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) func resetState() { capsules = store.Named("capsule") } func TestLeaveAndReveal(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) // v1 ids start at 1: the store never assigns 0, so the zero id stays // usable as "absent". v0 handed out 0 for the first capsule. id := Leave(cross(cur), "hello, future", 5) uassert.Equal(t, int64(1), id) if got := Render(""); !strings.Contains(got, "1 still sealed") { t.Fatalf("expected capsule to still be sealed, got: %s", got) } if got := Render("sealed"); !strings.Contains(got, "capsule/1") { t.Fatalf("expected capsule 1 listed as sealed, got: %s", got) } testing.SkipHeights(5) if got := Render(""); !strings.Contains(got, "hello, future") { t.Fatalf("expected capsule to be revealed, got: %s", got) } if got := Render("capsule/1"); !strings.Contains(got, "hello, future") { t.Fatalf("expected capsule detail to reveal message, got: %s", got) } // Id 0 is never assigned, so the path is rejected rather than looked up. if got := Render("capsule/0"); !strings.Contains(got, "Invalid capsule id") { t.Fatalf("expected capsule/0 to be rejected, got: %s", got) } } func TestLeaveRejectsEmptyMessage(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "message must not be empty", func() { Leave(cross(cur), " ", 5) }) } func TestLeaveRejectsBadDelay(cur realm, t *testing.T) { resetState() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "delayBlocks out of range", func() { Leave(cross(cur), "hi", 0) }) } // Regression: an intermediate realm (not a direct EOA call) must never be // able to seal a capsule as if it were a user. func TestLeaveRejectsCodeRealm(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewCodeRealm("gno.land/r/some/attacker")) uassert.AbortsWithMessage(t, cur, "only direct user calls can leave a capsule", func() { Leave(cross(cur), "hi", 5) }) } func TestRenderNotFound(t *testing.T) { resetState() if got := Render("nope"); !strings.Contains(got, "not found") { t.Fatalf("expected 404-ish message, got: %s", got) } }
#19AddPackagegno.land/r/moul/x/daily/todos/v19 arguments
Attached funds
5000000ugnot

Arguments · 9

  1. #1todos
  2. #2README.md
  3. #3# Shared To-Do Board > ⚠️ **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, shared to-do list realm for the gno.land **sapphire** testnet (gno 0.9). Anyone can add an item to the board; each item records the caller's address as its author, and can be toggled open/done or removed. `Render` shows the board as Markdown checkboxes with live open/done counts, newest item first. - **Realm path:** `gno.land/r/REPLACE_ADDR/todos` (replace `REPLACE_ADDR` with your deploy address) ## Exported transactions | Function | Effect | |---|---| | `Add(text string) int` | Adds an item authored by the caller, returns its id | | `Toggle(id int)` | Flips an item between open and done | | `Remove(id int)` | Deletes an item | All three are gno 0.9 crossing functions (`cur realm` first parameter) and must be invoked with `cross(cur)` from another realm, or directly via `MsgCall`. ## Example calls ```sh # Add an item (returns the new id) gnokey maketx call -pkgpath "gno.land/r/REPLACE_ADDR/todos" \ -func Add -args "buy milk" -gas-fee 1000000ugnot -gas-wanted 2000000 \ -broadcast -chainid sapphire-1 -remote <rpc> mykey # Mark item #3 done (toggle) gnokey maketx call -pkgpath "gno.land/r/REPLACE_ADDR/todos" \ -func Toggle -args 3 -gas-fee 1000000ugnot -gas-wanted 2000000 \ -broadcast -chainid sapphire-1 -remote <rpc> mykey # Remove item #3 gnokey maketx call -pkgpath "gno.land/r/REPLACE_ADDR/todos" \ -func Remove -args 3 -gas-fee 1000000ugnot -gas-wanted 2000000 \ -broadcast -chainid sapphire-1 -remote <rpc> mykey ``` ## Example render output ```markdown # Shared To-Do Board 2 open · 1 done · 3 total - [ ] #3 walk dog (g1abc…) - [x] #2 buy milk (g1abc…) - [ ] #1 write realm (g1def…) ``` ## What changed in v1 Two ports against [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/todos), which stays live and untouched. ### Rendering Identical behaviour to [`v0`](https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/x/daily/todos) apart from rendering, which now goes through [`p/moul/kit/ui`](/p/moul/kit/ui/v0) instead of the local `shortAddr` helper. **One address format.** Eleven realms carried their own `shortAddr` with four different truncation rules, so the same account rendered differently depending on which realm you opened. `ui.Addr` is that decision made once: 8 leading characters, an ellipsis, 4 trailing, in backticks. Visible change: addresses now render in monospace. ### Storage State moved from a hand-rolled `avl.Tree` + `nextID` + zero-padding helper to [`p/moul/kit/store`](/p/moul/kit/store/v0), which keys entries by [`seqid`](/p/nt/seqid/v0) instead of a fixed-width decimal string. `v0` padded ids to width 12. Past that width the padding stops and the tree orders `"1000000000000"` before `"999999999999"`, so every list this realm renders would be wrong from that entry on. `store` keys are 8 big-endian bytes whose order is numeric for every `uint64`, so there is no width left to outgrow. `item` loses its `id` field, which the store now owns. `Render`'s newest-first list also gets shorter: `v0` collected every line into a slice in ascending order and then walked it backwards, where the store iterates descending directly. `Toggle`'s not-found path becomes one line, and `Remove`'s message gains the id in the same wording. Ids stay plain integers and the rendered output of the storage port is unchanged. Each port changes something `v0` promised, one the rendered output and one the storage layout, so under this repo's versioning rule each is a compatibility change and neither could be an edit to `v0` in place. They land in the **same** new version because `v1` was never published. A version number is a tag on something that exists on a chain, and until it does there is nothing for a second number to avoid disturbing, so the right move is to keep editing the version you have. `gnopm unbump` is what folded the second port back down into this one. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/daily/todos/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/todos/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/todos/v1" gno = "0.9" private = true
  6. #6todos.gno
  7. #7// Package todos is a shared, on-chain to-do board realm. // // Anyone can add an item; the caller's address is recorded as the author. // Items can be toggled (open/done) or removed. Render exposes the board as // Markdown checkboxes with open/done counts, newest item first. package todos import ( "strconv" "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/kit/store/v0" ) // item is one entry on the board. `address` is the uverse bech32 type — it is // persistable (unlike a `realm` value), so it is safe to store across txs. // It carries no id field: the id belongs to the store, which hands it back on // lookup and iteration. type item struct { text string done bool author address } // items assigns the item ids. v1 kept its own nextID plus an idKey() that // zero-padded to width 12, which stopped ordering Render past 10^12. var items = store.Named("todos: item") // Add appends a new item authored by the caller and returns its id. // // Crossing function: takes `cur realm` first (gno 0.9 interrealm convention), // authenticates with cur.IsCurrent() before trusting cur.Previous(). func Add(cur realm, text string) int { if !cur.IsCurrent() { panic("spoofed realm") } text = strings.TrimSpace(text) if text == "" { panic("todos: text must not be empty") } author := cur.Previous().Address() return int(items.Add(&item{ text: text, done: false, author: author, })) } // Toggle flips the done state of the item with the given id. func Toggle(cur realm, id int) { if !cur.IsCurrent() { panic("spoofed realm") } it := items.MustGet(store.ID(id)).(*item) it.done = !it.done } // Remove deletes the item with the given id. func Remove(cur realm, id int) { if !cur.IsCurrent() { panic("spoofed realm") } if _, removed := items.Remove(store.ID(id)); !removed { panic("todos: item #" + strconv.Itoa(id) + " not found") } } // counts returns the number of open and done items. func counts() (open, done int) { items.Each(func(_ store.ID, v any) { if v.(*item).done { done++ } else { open++ } }) return open, done } // Render returns the board as Markdown. Newest item (highest id) first. // // Render is NOT a crossing function (no `cur realm` param) — it is read-only // and invoked by gnoweb, not via MsgCall. func Render(path string) string { open, done := counts() var b strings.Builder b.WriteString("# Shared To-Do Board\n\n") b.WriteString(strconv.Itoa(open) + " open · ") b.WriteString(strconv.Itoa(done) + " done · ") b.WriteString(strconv.Itoa(open+done) + " total\n\n") if open+done == 0 { b.WriteString("_No items yet. Add one with `Add(text)`._\n") return b.String() } // The store iterates by id, so newest-first is one call rather than a // collect-then-walk-backwards. items.EachReverse(func(id store.ID, v any) { it := v.(*item) mark := " " if it.done { mark = "x" } b.WriteString("- [" + mark + "] #" + id.String() + " " + it.text + " (" + ui.Addr(it.author) + ")\n") }) return b.String() }
  8. #8todos_test.gno
  9. #9package todos import ( "strings" "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/testutils/v0" ) // reset clears package globals between tests (t.Run/subtests do not reset them). func reset() { items = store.Named("todos: item") } // TestAddToggleRemove exercises the full lifecycle through the crossing API. // Crossing test: takes `cur realm` and calls via cross(cur). func TestAddToggleRemove(cur realm, t *testing.T) { reset() alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) id1 := Add(cross(cur), "buy milk") if id1 != 1 { t.Fatalf("Add first id = %d, want 1", id1) } id2 := Add(cross(cur), "walk dog") if id2 != 2 { t.Fatalf("Add second id = %d, want 2", id2) } open, done := counts() if open != 2 || done != 0 { t.Fatalf("after adds: open=%d done=%d, want 2/0", open, done) } // Toggle #1 done. Toggle(cross(cur), 1) open, done = counts() if open != 1 || done != 1 { t.Fatalf("after toggle: open=%d done=%d, want 1/1", open, done) } // Toggle #1 back to open. Toggle(cross(cur), 1) open, done = counts() if open != 2 || done != 0 { t.Fatalf("after re-toggle: open=%d done=%d, want 2/0", open, done) } // Remove #1. Remove(cross(cur), 1) open, done = counts() if open != 1 || done != 0 { t.Fatalf("after remove: open=%d done=%d, want 1/0", open, done) } if items.Has(store.ID(1)) { t.Fatalf("item #1 still present after Remove") } } // TestRender checks the Markdown output shape and counts. Render is not a // crossing function, so this is a plain test — but the Add calls that seed // state still need a realm, so it also takes `cur realm`. func TestRender(cur realm, t *testing.T) { reset() // Empty board. empty := Render("") if !strings.Contains(empty, "0 open · 0 done · 0 total") { t.Fatalf("empty render missing zero counts:\n%s", empty) } if !strings.Contains(empty, "No items yet") { t.Fatalf("empty render missing placeholder:\n%s", empty) } bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(bob)) Add(cross(cur), "first") Add(cross(cur), "second") Add(cross(cur), "third") Toggle(cross(cur), 2) // mark #2 done out := Render("") // Counts line: 2 open, 1 done, 3 total. if !strings.Contains(out, "2 open · 1 done · 3 total") { t.Fatalf("render counts wrong:\n%s", out) } // Done checkbox for #2. if !strings.Contains(out, "- [x] #2 second") { t.Fatalf("render missing done item #2:\n%s", out) } // Open checkbox for #1. if !strings.Contains(out, "- [ ] #1 first") { t.Fatalf("render missing open item #1:\n%s", out) } // Newest first: #3 must appear before #1. if strings.Index(out, "#3 third") > strings.Index(out, "#1 first") { t.Fatalf("render not newest-first:\n%s", out) } // Author short form present. if !strings.Contains(out, "(`g1") { t.Fatalf("render missing author short address:\n%s", out) } } // TestIDKeyOrdering verifies the store key sorts numerically. v1 asserted the // same thing about its own idKey, which padded to width 12: that held only // below the width, since idKey(10^12) is 13 characters and sorts before // idKey(10^12-1). The store key is 8 bytes for every id, so there is no // ceiling. Non-crossing: plain signature. func TestIDKeyOrdering(t *testing.T) { if store.ID(2).Key() >= store.ID(10).Key() { t.Fatal("id 2 should sort before id 10") } if store.ID(9).Key() >= store.ID(100).Key() { t.Fatal("id 9 should sort before id 100") } if store.ID(999999999999).Key() >= store.ID(1000000000000).Key() { t.Fatal("ordering should survive one past v1's width-12 ceiling") } if len(store.ID(1).Key()) != len(store.ID(1000000000000).Key()) { t.Fatal("every key is the same width") } }
#20AddPackagegno.land/r/moul/x/framelab/probe/v09 arguments
Attached funds
5000000ugnot

Arguments · 9

  1. #1probe
  2. #2README.md
  3. #3# `gno.land/r/moul/x/framelab/probe/v0` The realm half of the `p/moul/x/framelab/v0` probe: shaped exactly like a generated instance realm (state here, logic in the pure package, one-line re-exports forwarding `cur`), and used only to assert which realm frame the pure package runs in. Not useful on chain. It exists so the property the instance-per-realm pattern depends on has a test that fails loudly if a future gno release changes it. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/framelab/probe/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/framelab/probe/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/r/moul/x/framelab/probe/v0" gno = "0.9" private = true
  6. #6probe.gno
  7. #7// Realm probe is shaped exactly like a generated instance realm: it owns the // state, and every exported function is a one-line re-export that forwards // `cur` into the shared pure package. package probe import ( "gno.land/p/moul/x/framelab/v0" "gno.land/p/nt/grc20/v0" ) var ( Token *grc20.Token ledger *grc20.PrivateLedger ) func init(cur realm) { Token, ledger = grc20.NewToken("Probe", "PRB", 6, 0, cur) } // Run is the instance-realm shape under test. func Run(cur realm, from address, amount int64) address { return framelab.Pull(0, cur, Token, from, amount) } // Frame reports what pure-package code sees as its own realm identity. func Frame(cur realm) (string, address, bool) { return framelab.Who(0, cur) }
  8. #8probe_test.gno
  9. #9package probe import ( "chain" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) const selfPath = "gno.land/r/moul/x/framelab/probe/v0" func self() address { return chain.PackageAddress(selfPath) } // The claim: pure-package code called with a forwarded `cur` still runs in the // importing realm's frame, so it reports that realm's path and address and its // `cur` is still accepted as current by grc20's spoof check. func TestPureCodeKeepsImportingRealmFrame(cur realm, t *testing.T) { alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) path, addr, isCurrent := Frame(cross(cur)) uassert.Equal(t, selfPath, path) uassert.Equal(t, self().String(), addr.String()) uassert.True(t, isCurrent) } // The same claim, exercised through the operation that actually matters: a // GRC20 pull issued from inside the pure package must credit the instance // realm's own address. func TestPureCodePullsToImportingRealm(cur realm, t *testing.T) { alice := testutils.TestAddress("alice") ledger.Mint(alice, 1_000) ledger.Approve(alice, self(), 500) testing.SetRealm(testing.NewUserRealm(alice)) bound := Run(cross(cur), alice, 400) uassert.Equal(t, self().String(), bound.String()) uassert.Equal(t, int64(400), Token.BalanceOf(self())) uassert.Equal(t, int64(600), Token.BalanceOf(alice)) uassert.Equal(t, int64(100), Token.Allowance(alice, self())) }
#21AddPackagegno.land/r/moul/x/games/lastwords/v011 arguments
Attached funds
11000000ugnot

Arguments · 11

  1. #1lastwords
  2. #2README.md
  3. #3# `gno.land/r/moul/x/games/lastwords/v0` **One string, on chain, forever.** Pay more than the last writer paid and the slot is yours. When the clock runs out, whatever is in it stays there permanently. ``` Price("famous last words") # what it costs right now, in ugnot Write("famous last words") # send that much with the call Settle() # after the clock runs out, anyone may call it Withdraw() # sweep what you are owed ``` The artefact at the end is the point: this is a bidding war over an epitaph, not over a prize. It is also the smallest thing that exercises the two libraries under it, which is why it exists. **The last writer does not take the pot.** They take the slot, which is what they were bidding for. The pot goes to everyone *else* who wrote, pro rata to what each paid. Winner-takes-all makes the second-to-last writer the mark, everybody can see that, and so nobody joins. **Half of every payment is credited to the writer it displaces**, immediately and whatever happens afterwards. A zero-sum game minus gas gives a second player no reason to exist; being overwritten early has to be survivable. **The clock terminates.** Each write pushes the deadline a fifth of the way toward a hard end fixed when the slot opened, and never past it, and never to less than 300 blocks from now. The last stretch stays long enough for a transaction to land in, so the slot is won by paying rather than by being unreachable. **Nothing is ever pushed.** Dividends and settlement shares are credited to a ledger and swept by `Withdraw`. A realm that pays inside a loop over its roster is a gas bomb, and the more successful the game gets the more certain it is to brick. Three things worth knowing before writing: - **The message is capped at 280 bytes and priced per byte**, because the message *is* the storage cost and an uncapped one is an unbounded write at a constant price. - **`Price` is a query, and an underpaying write is refused, not refunded.** Read it in the same breath as you send. - **`Settle` is permissionless.** Leaving settlement to an interested party is how a pot stays unclaimed. The realm is the wiring; the parts are libraries: [`p/moul/x/games/clock`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/games/clock) owns the deadline and its guards, [`p/moul/x/games/prorata`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/games/prorata) owns the split, and [`p/moul/x/daily/pullpayment`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/daily/pullpayment) owns the credit ledger. The stored message is caller-typed text rendered as markdown on a page that cannot be edited afterwards, so it goes through `ui.Inline` before it is shown and a newline is refused at write time. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/games/lastwords/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/games/lastwords/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/r/moul/x/games/lastwords/v0" gno = "0.9" private = true
  6. #6lastwords.gno
  7. #7// Package lastwords is one string, on chain, forever. // // There is a single message slot. Pay more than the last writer paid and the // slot is yours; when the clock finally runs out, whatever is in it stays there // permanently. The artefact at the end is the point, so the game is a bidding // war over an epitaph rather than over a prize. // // The three rules exist because the obvious version of this game does not end // and does not pay: // // 1. THE CLOCK TERMINATES. Every write pushes the deadline a fifth of the way // to a hard end fixed when the slot opened, and no write can push it past // that. A constant extension per action has no end: the pot grows faster // than the price of the next write, so there is always a rational next // write and the game runs until everyone is bored or broke. // 2. THE LAST WRITER DOES NOT TAKE THE POT. They take the slot, which is what // they were bidding for. The pot is split among everyone ELSE who wrote, // pro rata to what they paid. Winner-takes-all makes the second-to-last // writer the mark and everyone knows it, which is why nobody joins. // 3. HALF OF EVERY PAYMENT GOES TO THE WRITER BEING DISPLACED, immediately. // A zero-sum game minus gas has no reason for a second player. Overwriting // somebody pays them, so being overwritten early is not a loss. // // Nothing is ever pushed: payouts are credited to an internal ledger and swept // by Withdraw. A realm that pays inside a loop over its roster is a gas bomb // that eventually bricks, and the bigger the game gets the more certain that // is. // // The pieces are libraries, and the realm is the wiring: // [p/moul/x/games/clock](/p/moul/x/games/clock/v0) owns the deadline and its // guards, [p/moul/x/games/prorata](/p/moul/x/games/prorata/v0) owns the split, // and [p/moul/x/daily/pullpayment](/p/moul/x/daily/pullpayment/v0) owns the // credit ledger. package lastwords import ( "strconv" "strings" "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/moul/x/daily/pullpayment/v0" "gno.land/p/moul/x/games/clock/v0" "gno.land/p/moul/x/games/prorata/v0" "gno.land/p/nt/avl/v0" ) const denom = "ugnot" // The rules, fixed at deploy. Times are block heights. const ( // Window is how long the slot stays open with nobody writing. Window = int64(2000) // Floor is the minimum a write leaves on the clock, so the last stretch // stays long enough for a transaction to land in. Without it a decaying // extension shrinks to nothing and whoever holds at that moment wins by // being unreachable rather than by paying. Floor = int64(300) // Life is the hard end. No write can push the deadline past start+Life. Life = int64(100000) // BumpPct is how much of the gap to the hard end a write buys. BumpPct = int64(20) // DividendPct is the share of a payment credited to the writer being // displaced; the rest goes to the pot. DividendPct = int64(50) // MinWrite is the base price of the slot, in ugnot. MinWrite = int64(1000000) // PerByte is charged on top, in ugnot per byte of the message. The chain's // own storage deposit is 100 ugnot per byte, so this is that plus a premium // for occupying the only slot there is. PerByte = int64(1000) // MaxLen caps the message in bytes, because the message IS the storage // cost and an uncapped one is an unbounded write priced at a constant. MaxLen = 280 ) var ( clk *clock.Clock ledger = pullpayment.New() message string author address paid int64 // what the current holder paid pot int64 // what settlement will split writers avl.Tree // address string -> int64 total paid order []string // writers in first-write order, so the split is deterministic writes int64 settled bool ) func init() { open(runtime.ChainHeight()) } // open resets the game to a fresh slot at height h. It is the only place the // clock is built, so tests and init cannot disagree about the rules. func open(h int64) { c, err := clock.New(h, Window, Floor, Life) if err != nil { panic("lastwords: " + err.Error()) } clk = c ledger = pullpayment.New() message = "" author = "" paid = 0 pot = 0 writers = avl.Tree{} order = nil writes = 0 settled = false } // Price returns what msg costs to write right now, in ugnot: a base, plus a // charge per byte, and always at least one ugnot more than the current holder // paid. Query it before sending; an underpaying write is refused, not refunded. func Price(msg string) int64 { price := MinWrite + int64(len(msg))*PerByte if next := paid + 1; next > price { price = next } return price } // Write puts msg in the slot and makes the caller its holder. The caller must // send at least Price(msg) ugnot with the transaction. // // Half of the payment is credited to the writer being displaced and the rest // joins the pot. The deadline is pushed a fifth of the way toward the hard end, // never past it, and never to less than Floor from now. // // Only a direct user transaction may write, so the payment envelope cannot be // spoofed by an ephemeral MsgRun realm. func Write(cur realm, msg string) { if !cur.Previous().IsUserCall() { panic("lastwords: write must be a direct user transaction") } h := runtime.ChainHeight() if clk.Expired(h) { panic("lastwords: the slot is closed, these are somebody's last words now") } if msg == "" { panic("lastwords: message must not be empty") } if len(msg) > MaxLen { panic("lastwords: message must be at most " + strconv.Itoa(MaxLen) + " bytes") } if strings.ContainsAny(msg, "\n\r") { panic("lastwords: message must be a single line") } price := Price(msg) sent := unsafe.OriginSend().AmountOf(denom) if sent < price { panic("lastwords: sent " + strconv.FormatInt(sent, 10) + " ugnot, price is " + strconv.FormatInt(price, 10)) } caller := cur.Previous().Address() // The displaced writer is paid first, out of the payment that displaced // them. Credited, never sent: see the package doc. dividend := int64(0) if author != "" { dividend = sent * DividendPct / 100 if err := ledger.Credit(author.String(), dividend); err != nil { panic("lastwords: " + err.Error()) } } pot += sent - dividend key := caller.String() if _, seen := writers.Get(key).(int64); !seen { order = append(order, key) } writers.Set(key, totalPaid(key)+sent) message = msg author = caller paid = sent writes++ deadline, err := clk.BumpShare(h, BumpPct) if err != nil { panic("lastwords: " + err.Error()) } chain.Emit("Write", "author", key, "paid", strconv.FormatInt(sent, 10), "dividend", strconv.FormatInt(dividend, 10), "deadline", strconv.FormatInt(deadline, 10), ) } // Settle closes the game once the clock has run out, splitting the pot among // every writer EXCEPT the one holding the slot, pro rata to what each paid. // // Anyone may call it: leaving settlement to an interested party is how a pot // stays unclaimed. It moves no coins, it credits the ledger; Withdraw is what // pays. If the holder is the only writer there is nobody else to split with, // so the pot returns to them. func Settle(cur realm) { h := runtime.ChainHeight() if !clk.Expired(h) { panic("lastwords: still open until height " + strconv.FormatInt(clk.Deadline(), 10)) } if settled { panic("lastwords: already settled") } settled = true if pot == 0 { return } holder := author.String() payees := []string{} weights := []int64{} for _, k := range order { if k == holder { continue } payees = append(payees, k) weights = append(weights, totalPaid(k)) } if len(payees) == 0 { // The holder wrote every word there is. Nobody to share with. payees = []string{holder} weights = []int64{1} } shares, err := prorata.Split(pot, weights) if err != nil { panic("lastwords: " + err.Error()) } // A zero share is dropped: CreditMany refuses a non-positive amount, and // crediting nothing is not a payment anyway. dst := []string{} amounts := []int64{} for i, s := range shares { if s > 0 { dst = append(dst, payees[i]) amounts = append(amounts, s) } } if len(dst) > 0 { if err := ledger.CreditMany(dst, amounts); err != nil { panic("lastwords: " + err.Error()) } } pot = 0 chain.Emit("Settle", "holder", holder, "writes", strconv.FormatInt(writes, 10), "payees", strconv.Itoa(len(dst)), ) } // Withdraw pays the caller everything credited to them and returns the amount. // Dividends are claimable while the game runs; settlement shares only after // Settle. func Withdraw(cur realm) int64 { caller := cur.Previous().Address() amount, err := ledger.Withdraw(caller.String()) if err != nil { panic("lastwords: " + err.Error()) } bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(cur.Address(), caller, chain.NewCoins(chain.NewCoin(denom, amount))) chain.Emit("Withdraw", "payee", caller.String(), "amount", strconv.FormatInt(amount, 10)) return amount } // Message returns the slot's current contents, raw. Render is what escapes it. func Message() string { return message } // Author returns who holds the slot. func Author() address { return author } // Pot returns what settlement will split. func Pot() int64 { return pot } // Deadline returns the height the slot closes at. func Deadline() int64 { return clk.Deadline() } // Owed returns what an address can Withdraw right now. func Owed(addr address) int64 { return ledger.Balance(addr.String()) } // totalPaid returns what an address has paid in total, zero if it never wrote. func totalPaid(key string) int64 { v, ok := writers.Get(key).(int64) if !ok { return 0 } return v } func gnot(ugnot int64) string { whole := ugnot / 1000000 frac := ugnot % 1000000 s := strconv.FormatInt(whole, 10) if frac == 0 { return s } f := strconv.FormatInt(frac, 10) for len(f) < 6 { f = "0" + f } for len(f) > 1 && f[len(f)-1] == '0' { f = f[:len(f)-1] } return s + "." + f }
  8. #8lastwords_test.gno
  9. #9package lastwords import ( "strings" "testing" "chain" "chain/banker" "chain/runtime" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var realmAddr = chain.PackageAddress("gno.land/r/moul/x/games/lastwords/v0") var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") ) // reset reopens the slot at the current height. Heights are relative in tests // (SkipHeights moves forward from wherever the last test left off and there is // no absolute setter), so nothing here asserts an absolute height. func reset() { open(runtime.ChainHeight()) } // write sends amount ugnot alongside the call, the way a real transaction // would: the runtime deposits it at the realm's address, then OriginSend // reports it to the crossing function. func write(cur realm, who address, amount int64, msg string) { testing.IssueCoins(realmAddr, chain.Coins{{denom, amount}}) testing.SetOriginSend(chain.Coins{{denom, amount}}) testing.SetRealm(testing.NewUserRealm(who)) Write(cross(cur), msg) } func TestWriteTakesTheSlot(cur realm, t *testing.T) { reset() uassert.Equal(t, "", Message()) write(cur, alice, Price("hello"), "hello") uassert.Equal(t, "hello", Message()) uassert.Equal(t, alice.String(), Author().String()) uassert.Equal(t, int64(0), ledger.TotalOwed(), "nobody was displaced yet") uassert.Equal(t, MinWrite+5*PerByte, Pot(), "the whole first payment is the pot") } // TestDisplacedWriterIsPaid is rule 3: overwriting somebody pays them, so a // second player has a reason to exist. func TestDisplacedWriterIsPaid(cur realm, t *testing.T) { reset() write(cur, alice, Price("alice was here"), "alice was here") potAfterFirst := Pot() paidByBob := Price("no, bob was") write(cur, bob, paidByBob, "no, bob was") dividend := paidByBob * DividendPct / 100 uassert.Equal(t, dividend, Owed(alice), "half of bob's payment, credited") uassert.Equal(t, potAfterFirst+paidByBob-dividend, Pot()) uassert.Equal(t, int64(0), Owed(bob), "the holder is owed nothing yet") } func TestPriceRisesWithTheHolderAndTheLength(cur realm, t *testing.T) { reset() uassert.Equal(t, MinWrite, Price(""), "the base price with an empty slot") uassert.Equal(t, MinWrite+10*PerByte, Price("0123456789"), "ten bytes cost ten byte-prices") first := Price("x") write(cur, alice, first, "x") uassert.Equal(t, first+1, Price(""), "outbidding the holder is the floor") } func TestWriteRejectsBadInput(cur realm, t *testing.T) { reset() testing.SetOriginSend(chain.Coins{{denom, MinWrite}}) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "must not be empty", func() { Write(cross(cur), "") }) long := strings.Repeat("x", MaxLen+1) testing.SetOriginSend(chain.Coins{{denom, Price(long)}}) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "at most", func() { Write(cross(cur), long) }) testing.SetOriginSend(chain.Coins{{denom, Price("a\nb")}}) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "single line", func() { Write(cross(cur), "a\nb") }) testing.SetOriginSend(chain.Coins{{denom, MinWrite - 1}}) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "price is", func() { Write(cross(cur), "cheapskate") }) uassert.Equal(t, "", Message(), "none of that took the slot") } // TestEveryWriteExtendsButNeverPastTheHardEnd is rule 1, the terminator. A // constant extension per write is how a pot stays open forever. func TestEveryWriteExtendsButNeverPastTheHardEnd(cur realm, t *testing.T) { reset() start := runtime.ChainHeight() hardEnd := start + Life uassert.Equal(t, start+Window, Deadline(), "it opens with the window") prev := Deadline() for i := 0; i < 30; i++ { testing.SkipHeights(100) if runtime.ChainHeight() >= Deadline() { break } write(cur, alice, Price("again"), "again") uassert.True(t, Deadline() >= prev, "a write never shortens the clock") uassert.True(t, Deadline() <= hardEnd, "and never passes the hard end") prev = Deadline() } uassert.True(t, Deadline() <= hardEnd) uassert.True(t, Deadline() > start+Window, "the writes did buy time") } func TestClosedSlotRefusesAWrite(cur realm, t *testing.T) { reset() write(cur, alice, Price("famous last words"), "famous last words") testing.SkipHeights(Life + 1) testing.SetOriginSend(chain.Coins{{denom, Price("too late")}}) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "slot is closed", func() { Write(cross(cur), "too late") }) uassert.Equal(t, "famous last words", Message(), "the last words stay last") } // TestSettleSplitsAmongEveryoneButTheHolder is rule 2, and it is the whole // reason this is not a winner-takes-all pot. func TestSettleSplitsAmongEveryoneButTheHolder(cur realm, t *testing.T) { reset() write(cur, alice, Price("a"), "a") testing.SkipHeights(10) write(cur, bob, Price("b"), "b") testing.SkipHeights(10) write(cur, carol, Price("c"), "c") beforeAlice, beforeBob := Owed(alice), Owed(bob) pot := Pot() uassert.True(t, pot > 0) testing.SkipHeights(Life + 1) testing.SetRealm(testing.NewUserRealm(bob)) // anyone may settle Settle(cross(cur)) uassert.Equal(t, int64(0), Pot(), "the pot is fully distributed") uassert.Equal(t, int64(0), Owed(carol), "the holder takes the slot, not the pot") uassert.True(t, Owed(alice) > beforeAlice, "alice got a share") uassert.True(t, Owed(bob) > beforeBob, "bob got a share") split := (Owed(alice) - beforeAlice) + (Owed(bob) - beforeBob) uassert.Equal(t, pot, split, "and the shares add up to exactly the pot") } func TestSettleWithOneWriterReturnsThePot(cur realm, t *testing.T) { reset() write(cur, alice, Price("alone"), "alone") pot := Pot() testing.SkipHeights(Life + 1) testing.SetRealm(testing.NewUserRealm(alice)) Settle(cross(cur)) uassert.Equal(t, pot, Owed(alice), "nobody else to share with") } func TestSettleRefusesEarlyAndTwice(cur realm, t *testing.T) { reset() write(cur, alice, Price("tick"), "tick") testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "still open", func() { Settle(cross(cur)) }) testing.SkipHeights(Life + 1) testing.SetRealm(testing.NewUserRealm(bob)) Settle(cross(cur)) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "already settled", func() { Settle(cross(cur)) }) } // TestWithdrawPaysRealCoins checks the half nothing else can: the ledger and // the realm's balance agree. func TestWithdrawPaysRealCoins(cur realm, t *testing.T) { reset() write(cur, alice, Price("first"), "first") testing.SkipHeights(10) write(cur, bob, Price("second"), "second") owed := Owed(alice) uassert.True(t, owed > 0) ro := banker.NewReadonlyBanker() before := ro.GetCoins(alice).AmountOf(denom) testing.SetRealm(testing.NewUserRealm(alice)) got := Withdraw(cross(cur)) uassert.Equal(t, owed, got) uassert.Equal(t, int64(0), Owed(alice), "the credit is zeroed before the coins move") after := banker.NewReadonlyBanker().GetCoins(alice).AmountOf(denom) uassert.Equal(t, before+owed, after, "the coins actually arrived") testing.SetRealm(testing.NewUserRealm(carol)) uassert.AbortsContains(t, cur, "nothing to withdraw", func() { Withdraw(cross(cur)) }) } // TestRenderEscapesTheMessage is the one bug a live deploy makes permanent: the // realm's entire product is a string somebody else typed. func TestRenderEscapesTheMessage(cur realm, t *testing.T) { reset() write(cur, alice, Price("[click](https://evil.example)"), "[click](https://evil.example)") out := Render("") uassert.False(t, strings.Contains(out, "](https://evil.example)"), "no live link") uassert.False(t, strings.Contains(out, "[click]"), "the link syntax is dead") uassert.True(t, strings.Contains(out, "click"), "the text is still readable") uassert.True(t, strings.Contains(out, "Last Words")) } func TestRenderCoversEveryPhase(cur realm, t *testing.T) { reset() uassert.True(t, strings.Contains(Render(""), "The slot is empty")) write(cur, alice, Price("open for business"), "open for business") out := Render("") uassert.True(t, strings.Contains(out, "open for business")) uassert.True(t, strings.Contains(out, "Open for another")) testing.SkipHeights(Life + 1) out = Render("") uassert.True(t, strings.Contains(out, "**Closed** at height")) uassert.True(t, strings.Contains(out, "may call `Settle`")) testing.SetRealm(testing.NewUserRealm(bob)) Settle(cross(cur)) out = Render("") uassert.True(t, strings.Contains(out, "Settled")) uassert.True(t, strings.Contains(out, "Owed")) } func TestGnotFormatting(t *testing.T) { uassert.Equal(t, "0", gnot(0)) uassert.Equal(t, "1", gnot(1000000)) uassert.Equal(t, "1.5", gnot(1500000)) uassert.Equal(t, "0.000001", gnot(1)) uassert.Equal(t, "12.345678", gnot(12345678)) }
  10. #10render.gno
  11. #11package lastwords import ( "strconv" "strings" "chain/runtime" "gno.land/p/moul/kit/ui/v0" ) // Render shows the slot, the clock and the money. The message is caller-typed // text, so it goes through ui.Inline: a stored string that renders as markdown // is a phishing link on a page nobody can edit, and this realm's whole product // is a string somebody else wrote. func Render(path string) string { h := runtime.ChainHeight() var b strings.Builder b.WriteString("# Last Words\n\n") if message == "" { b.WriteString("_The slot is empty. Whoever writes last, writes forever._\n\n") } else { b.WriteString("> " + ui.Inline(message) + "\n\n") b.WriteString("by " + ui.AddrOf(author.String()) + ", for " + gnot(paid) + " GNOT\n\n") } closed := clk.Expired(h) b.WriteString("## The clock\n\n") if closed { b.WriteString("**Closed** at height " + strconv.FormatInt(clk.Deadline(), 10) + ". ") if settled { b.WriteString("Settled: the pot has been credited, call `Withdraw` to sweep yours.\n\n") } else { b.WriteString("Anyone may call `Settle` to split the pot.\n\n") } } else { b.WriteString("Open for another **" + strconv.FormatInt(clk.Remaining(h), 10) + "** blocks, until height " + strconv.FormatInt(clk.Deadline(), 10) + ".\n\n") if clk.Final() { b.WriteString("This is the **last stretch**: the deadline has reached the hard end at " + strconv.FormatInt(clk.HardEnd(), 10) + " and no write can move it again.\n\n") } else { b.WriteString("Hard end at height " + strconv.FormatInt(clk.HardEnd(), 10) + ". A write buys " + strconv.FormatInt(BumpPct, 10) + "% of the gap to it, never less than " + strconv.FormatInt(Floor, 10) + " blocks.\n\n") } } b.WriteString("## The money\n\n") b.WriteString("| | |\n|---|---|\n") b.WriteString("| Pot | " + gnot(pot) + " GNOT |\n") b.WriteString("| Price of the next write | " + gnot(Price("")) + " GNOT, plus " + strconv.FormatInt(PerByte, 10) + " ugnot per byte |\n") b.WriteString("| Writes | " + strconv.FormatInt(writes, 10) + " |\n") b.WriteString("| Writers | " + strconv.Itoa(len(order)) + " |\n") b.WriteString("| Owed, unswept | " + gnot(ledger.TotalOwed()) + " GNOT |\n\n") b.WriteString("The holder does **not** take the pot. It goes to everyone else who wrote, ") b.WriteString("pro rata to what they paid. Half of every payment is credited to the writer ") b.WriteString("it displaced, immediately.\n\n") if !ledger.IsEmpty() { b.WriteString("## Owed\n\n| address | ugnot |\n|---|---|\n") shown := 0 ledger.Iterate(func(payee string, amount int64) bool { b.WriteString("| " + ui.AddrOf(payee) + " | " + strconv.FormatInt(amount, 10) + " |\n") shown++ return shown >= 20 }) b.WriteString("\nCall `Withdraw` to sweep yours.\n") } return b.String() }
#22AddPackagegno.land/r/moul/x/grc20wrapdemo/v011 arguments
Attached funds
12000000ugnot

Arguments · 11

  1. #1grc20wrapdemo
  2. #2README.md
  3. #3# `gno.land/r/moul/x/grc20wrapdemo/v0` **A permissionless wrapper factory for GRC20 tokens.** Point it at any token registered in [`r/nt/grc20reg`](https://gno.land/r/nt/grc20reg/v0) and it issues a new one backed by it, with a personality you pick. Point it at two and it issues a meta-token backed by both. No allowlist, no owner, and no authority over anybody's balance: every deposit is pulled with an allowance the holder granted. ## Five minutes, start to finish ``` # 1. play money maketx call -pkgpath gno.land/r/moul/x/grc20faucet/v0 -func Claim # 2. let this realm pull your RED (the address is printed by Home(), and on the # realm's own page) maketx call -pkgpath gno.land/r/moul/x/grc20faucet/v0 -func Approve \ -args RED -args <Home()> -args 1000000 # 3. create a wrapper over RED, and deposit into it maketx call -pkgpath gno.land/r/moul/x/grc20wrapdemo/v0 -func NewWrapper \ -args gno.land/r/moul/x/grc20faucet/v0.RED -args pool -args pRED maketx call -pkgpath gno.land/r/moul/x/grc20wrapdemo/v0 -func Deposit \ -args pRED -args 500000 ``` Step 2 is the one people skip. Without it every `Deposit` fails with `insufficient allowance`, which is the system working. ## Modes | mode | what the wrapped token does | |---|---| | `plain` | 1:1 custody receipt | | `kilo` | 1 underlying unit becomes 1000 wrapped, +3 decimals | | `soulbound` | wrap and unwrap freely, never transferable | | `pool` | shares in the escrow; `Donate` pays every holder at once | | `sticky` | `pool` plus a 1% exit fee, left behind for whoever stays | And `NewFusion(keyA, perA, keyB, perB, symbol)`, which bundles two registered tokens at a fixed proportion into one meta-token, `Fuse` in and `Defuse` out. ## The parts worth reading the code for **`pool` is the entire yield-bearing-token pattern in two lines of arithmetic.** `Donate` adds escrow and mints nothing, so every outstanding share is worth more, permanently and for everyone at once. A fee sink, a staking reward and an airdrop to holders are the same operation seen from three angles. **`soulbound` binds users, not realms.** `MsgCall` cannot build the `realm` argument grc20's tellers need, so a signing account can only move the token through this realm's `Transfer`, where the veto lives. Another realm holding it can always move its own balance. Non-transferable here means "no user can pass it on", and the exit is always open: unwrap, and the underlying moves freely again. **Wrapping a wrapper works, and nothing special was needed to make it work.** Every token this realm issues is itself registered in `grc20reg`, so its key feeds straight back into `NewWrapper`. A pool over a plain wrap of RED is a good way to see how thin the abstraction is. **Two wrappers over the same underlying share one escrow account** (this realm's address), and are kept apart only by each vault's own `Held()` counter. `Escrow` reports that counter; the render page reports whether the account still covers it. Built on [`p/moul/x/grc20wrap`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/grc20wrap/v0), which is where the wrapping actually happens. This realm is the chain wiring and a catalogue. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/grc20wrapdemo/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/grc20wrapdemo/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/r/moul/x/grc20wrapdemo/v0" gno = "0.9" # public: Wrap and Basket register the tokens they mint with r/nt/grc20reg, # so this realm's own objects have to be reachable from that one. Same # measured failure as r/moul/x/grc20faucet when the flag is flipped.
  6. #6render.gno
  7. #7package grc20wrapdemo import ( "gno.land/p/nt/ufmt/v0" ) func Render(path string) string { if path == "" { return renderIndex() } return renderOne(path) } func renderIndex() string { s := "# GRC20 wrapper factory\n\n" s += "Wrap any token registered in [grc20reg](/r/nt/grc20reg/v0) into a new one " s += "with a different personality, or fuse two into one. Permissionless: " s += "no allowlist, no owner, no authority over anybody's balance.\n\n" s += ufmt.Sprintf("Escrow account to approve: `%s`\n\n", home.String()) s += "## Modes\n\n" s += "| mode | what the wrapped token does |\n" s += "|---|---|\n" s += "| `plain` | 1:1 custody receipt |\n" s += "| `kilo` | 1 underlying unit becomes 1000 wrapped, +3 decimals |\n" s += "| `soulbound` | wrap and unwrap freely, never transferable |\n" s += "| `pool` | shares in the escrow; `Donate` pays every holder at once |\n" s += "| `sticky` | `pool` plus a 1% exit fee, left behind for whoever stays |\n" s += "| `fusion` | one meta-token backed by a fixed bundle of two others |\n" s += "\n## Issued here\n\n" if len(created) == 0 { s += "Nothing yet. Be the first.\n" } else { s += "| symbol | mode | backing | supply | registry key |\n" s += "|---|---|---|---|---|\n" for _, sym := range created { e := must(sym) s += ufmt.Sprintf("| [%s](/r/moul/x/grc20wrapdemo/v0:%s) | %s | %s | %d | `%s` |\n", e.symbol, e.symbol, e.mode, e.backing(), e.token().TotalSupply(), e.key) } } s += "\n## Try it\n\n" s += "Claim play money from [the faucet](/r/moul/x/grc20faucet/v0), let this realm\n" s += "spend it, then wrap:\n\n" s += "```\n" s += "# 1. get RED and BLUE\n" s += "maketx call -pkgpath gno.land/r/moul/x/grc20faucet/v0 -func Claim\n\n" s += "# 2. let this realm pull your RED\n" s += "maketx call -pkgpath gno.land/r/moul/x/grc20faucet/v0 -func Approve \\\n" s += ufmt.Sprintf(" -args RED -args %s -args 1000000\n\n", home.String()) s += "# 3. create a pool wrapper over RED, then deposit into it\n" s += "maketx call -pkgpath gno.land/r/moul/x/grc20wrapdemo/v0 -func NewWrapper \\\n" s += " -args gno.land/r/moul/x/grc20faucet/v0.RED -args pool -args pRED\n" s += "maketx call -pkgpath gno.land/r/moul/x/grc20wrapdemo/v0 -func Deposit \\\n" s += " -args pRED -args 500000\n" s += "```\n\n" s += "Built on [p/moul/x/grc20wrap](/p/moul/x/grc20wrap/v0).\n" return s } func renderOne(symbol string) string { e := byName.Get(symbol) if e == nil { return "# 404\n\nThis realm has not issued `" + symbol + "`.\n" } en := e.(*entry) s := ufmt.Sprintf("# %s\n\n", en.symbol) if en.vault != nil { s += en.vault.Summary() } else { s += en.basket.Summary() } s += ufmt.Sprintf("\n- mode: `%s`\n", en.mode) s += ufmt.Sprintf("- registry key: `%s`\n", en.key) s += ufmt.Sprintf("- created by: `%s` at block %d\n", en.creator.String(), en.height) s += "\n[back to the index](/r/moul/x/grc20wrapdemo/v0)\n" return s } // backing names what an entry is redeemable for, for the index table. func (e *entry) backing() string { if e.vault != nil { return e.vault.Underlying().GetSymbol() } out := "" for i := 0; i < e.basket.Legs(); i++ { tok, per, _ := e.basket.Leg(i) if i > 0 { out += " + " } out += ufmt.Sprintf("%dx%s", per, tok.GetSymbol()) } return out }
  8. #8wrapdemo.gno
  9. #9// Package grc20wrapdemo is a permissionless wrapper factory for GRC20 tokens. // // Point it at any token registered in gno.land/r/nt/grc20reg/v0 and it issues a // new one backed by it, with a personality you pick from a list. Point it at two // and it issues a meta-token backed by both. Nothing is whitelisted, nothing is // owned, and the realm never gains authority over anybody's balance: every // deposit is pulled with an allowance the holder granted to this realm's address. // // The five wrapper modes, all from gno.land/p/moul/x/grc20wrap/v0: // // plain 1:1 custody. A receipt. The boring one that proves the rest works. // kilo 1 underlying unit becomes 1000 wrapped ones, +3 decimals. // soulbound wrap and unwrap freely, but the wrapped token never changes hands. // pool shares in the escrow: Donate raises what every share redeems for. // sticky pool, plus a 1% exit fee that stays behind for whoever holds on. // // And one fusion mode: NewFusion bundles two registered tokens at a fixed // proportion into a single meta-token, redeemable back into both. // // # Wrapping a wrapper // // Every token this realm issues is itself registered in grc20reg, so its key can // be fed straight back into NewWrapper or NewFusion. A pool over a kilo over a // plain wrap of RED is legal, works, and is a good way to see how thin the // abstraction really is. // // # The one thing a caller must do first // // Approve this realm's address on the underlying token, through THAT token's own // realm. Home() prints the address. Without it every Deposit fails with // "insufficient allowance", which is the system working. // // Play money to try it on: gno.land/r/moul/x/grc20faucet/v0. package grc20wrapdemo import ( "chain/runtime" "gno.land/p/moul/x/grc20wrap/v0" "gno.land/p/nt/avl/v0" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/seqid/v0" "gno.land/p/nt/ufmt/v0" "gno.land/r/nt/grc20reg/v0" ) // entry is one issued token: either a wrapper over a single underlying, or a // fusion over several. Exactly one of vault/basket is set. type entry struct { symbol string mode string key string // grc20reg key of the issued token vault *grc20wrap.Vault basket *grc20wrap.Basket creator address height int64 } var ( ids seqid.ID byName = avl.NewTree() // symbol -> *entry created []string // symbols, in creation order, for a stable Render home address // this realm's address: the escrow account ) func init(cur realm) { home = cur.Address() } // Home is the address to approve on an underlying token before depositing. It // is where every escrow this realm holds actually sits. func Home() address { return home } // NewWrapper issues a token wrapping `tokenKey`, a key in grc20reg, under the // given mode, and registers it. It returns the new token's own registry key. // // `symbol` is yours to choose, must be unique in this realm, and follows the // GRC20 rules: 1 to 11 characters of [A-Za-z0-9_-]. func NewWrapper(cur realm, tokenKey, mode, symbol string) string { who := caller(cur) under := grc20reg.MustGet(tokenKey) requireFree(symbol) pol, extraDecimals := modePolicy(mode) decimals := under.GetDecimals() + extraDecimals if decimals > 18 { panic(ufmt.Sprintf("%s already has %d decimals; mode %q would need %d, over the GRC20 limit of 18", under.GetSymbol(), under.GetDecimals(), mode, decimals)) } name := ufmt.Sprintf("%s (%s wrap of %s)", symbol, mode, under.GetSymbol()) v := grc20wrap.NewVault(under, pol, name, symbol, decimals, ids.Next(), cur) key := grc20reg.Register(cross(cur), v.Token(), "") record(&entry{ symbol: symbol, mode: mode, key: key, vault: v, creator: who, height: runtime.ChainHeight(), }) return key } // NewFusion issues a meta-token backed by two registered tokens at a fixed // proportion: one smallest unit of the meta-token is always worth `perA` // smallest units of A plus `perB` of B. It returns the new token's registry key. // // The proportion never changes, and the meta-token is minted and burned only // against the real thing, so it cannot drift from its backing or be arbitraged. // What it can do is make the pair one transferable object. func NewFusion(cur realm, keyA string, perA int64, keyB string, perB int64, symbol string) string { who := caller(cur) a := grc20reg.MustGet(keyA) b := grc20reg.MustGet(keyB) requireFree(symbol) decimals := a.GetDecimals() if b.GetDecimals() > decimals { decimals = b.GetDecimals() } name := ufmt.Sprintf("%s (%s+%s fusion)", symbol, a.GetSymbol(), b.GetSymbol()) bk := grc20wrap.NewBasket( []*grc20.Token{a, b}, []int64{perA, perB}, name, symbol, decimals, ids.Next(), cur, ) key := grc20reg.Register(cross(cur), bk.Token(), "") record(&entry{ symbol: symbol, mode: "fusion", key: key, basket: bk, creator: who, height: runtime.ChainHeight(), }) return key } // Deposit escrows `amount` of the underlying and mints `symbol` to the caller. // It returns how much was minted, which is not `amount` unless the mode is // plain. Approve Home() on the underlying first. func Deposit(cur realm, symbol string, amount int64) int64 { who := caller(cur) out, err := mustVault(symbol).Wrap(0, cur, who, amount) checkErr(err) return out } // Withdraw burns `amount` of `symbol` and returns the underlying to the caller. // It returns how much came back. func Withdraw(cur realm, symbol string, amount int64) int64 { who := caller(cur) out, err := mustVault(symbol).Unwrap(0, cur, who, amount) checkErr(err) return out } // Donate escrows `amount` of the underlying for `symbol` and mints nothing. // // Under a pool or sticky mode this is a gift to every current holder at once, // and it is irreversible: there is no share to redeem it with. Under any other // mode it is a gift to nobody, since the rate ignores the escrow. func Donate(cur realm, symbol string, amount int64) { who := caller(cur) checkErr(mustVault(symbol).Donate(0, cur, who, amount)) } // Fuse escrows every leg of `symbol` and mints `units` of it to the caller. // Approve Home() on BOTH legs first. func Fuse(cur realm, symbol string, units int64) { who := caller(cur) checkErr(mustBasket(symbol).Fuse(0, cur, who, units)) } // Defuse burns `units` of `symbol` and returns every leg to the caller. func Defuse(cur realm, symbol string, units int64) { who := caller(cur) checkErr(mustBasket(symbol).Defuse(0, cur, who, units)) } // Transfer moves the caller's own units of `symbol`. A soulbound wrapper // refuses here, and nowhere else. func Transfer(cur realm, symbol string, to address, amount int64) { who := caller(cur) e := must(symbol) if e.vault != nil { checkErr(e.vault.Move(who, to, amount)) return } checkErr(e.basket.Move(who, to, amount)) } // Approve lets `spender` draw `amount` of `symbol` from the caller's balance. func Approve(cur realm, symbol string, spender address, amount int64) { who := caller(cur) e := must(symbol) if e.vault != nil { checkErr(e.vault.Allow(who, spender, amount)) return } checkErr(e.basket.Allow(who, spender, amount)) } // TransferFrom spends an allowance the caller was granted on `symbol`. func TransferFrom(cur realm, symbol string, from, to address, amount int64) { who := caller(cur) e := must(symbol) if e.vault != nil { checkErr(e.vault.MoveFrom(who, from, to, amount)) return } checkErr(e.basket.MoveFrom(who, from, to, amount)) } // Key returns the grc20reg key of the token this realm issued as `symbol`. func Key(symbol string) string { return must(symbol).key } // TotalSupply returns how much of `symbol` is outstanding. func TotalSupply(symbol string) int64 { return must(symbol).token().TotalSupply() } // BalanceOf returns `owner`'s balance of `symbol`. func BalanceOf(symbol string, owner address) int64 { return must(symbol).token().BalanceOf(owner) } // Allowance returns what `owner` let `spender` draw of `symbol`. func Allowance(symbol string, owner, spender address) int64 { return must(symbol).token().Allowance(owner, spender) } // Escrow returns the underlying escrowed behind a wrapper. func Escrow(symbol string) int64 { return mustVault(symbol).Held() } // Legs returns how many components a fusion has. func Legs(symbol string) int { return mustBasket(symbol).Legs() } // Leg describes the i-th component of a fusion: its symbol, the units escrowed // per meta unit, and the units escrowed so far. func Leg(symbol string, i int) (string, int64, int64) { tok, per, held := mustBasket(symbol).Leg(i) return tok.GetSymbol(), per, held } // Count is how many tokens this realm has issued. func Count() int { return len(created) } // internals // // modePolicy maps a mode name to its policy and to the extra decimals the // wrapped token needs to represent the same value. func modePolicy(mode string) (grc20wrap.Policy, int) { switch mode { case "plain": return grc20wrap.OneToOne{}, 0 case "kilo": return grc20wrap.Ratio{Num: 1_000, Den: 1}, 3 case "soulbound": return grc20wrap.Soulbound{}, 0 case "pool": return grc20wrap.Pool{}, 0 case "sticky": return grc20wrap.Fee{Base: grc20wrap.Pool{}, UnwrapBPS: 100}, 0 } panic("unknown mode " + mode + " (plain, kilo, soulbound, pool, sticky)") } func (e *entry) token() *grc20.Token { if e.vault != nil { return e.vault.Token() } return e.basket.Token() } func record(e *entry) { byName.Set(e.symbol, e) created = append(created, e.symbol) } func requireFree(symbol string) { if byName.Has(symbol) { panic("symbol " + symbol + " is already issued by this realm") } } func must(symbol string) *entry { e := byName.Get(symbol) if e == nil { panic("this realm has not issued " + symbol) } return e.(*entry) } func mustVault(symbol string) *grc20wrap.Vault { e := must(symbol) if e.vault == nil { panic(symbol + " is a fusion; use Fuse and Defuse") } return e.vault } func mustBasket(symbol string) *grc20wrap.Basket { e := must(symbol) if e.basket == nil { panic(symbol + " is a wrapper; use Deposit and Withdraw") } return e.basket } // caller is the account or realm that crossed into this one. Every deposit is // escrowed from, and every mint credited to, exactly this address. func caller(cur realm) address { if !cur.IsCurrent() { panic("grc20wrapdemo: stale realm token") } return cur.Previous().Address() } func checkErr(err error) { if err != nil { panic(err) } }
  10. #10wrapdemo_test.gno
  11. #11package grc20wrapdemo import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" faucet "gno.land/r/moul/x/grc20faucet/v0" ) // Realm state carries over between test functions, but the block height does // not: every test starts from the same height. The faucet's per-account // cooldown would therefore fire on the second test to reuse an account, so each // test claims with its own. TestFaucetCooldown covers the cooldown itself. func fund(cur realm, t *testing.T, name string) address { t.Helper() who := testutils.TestAddress(name) testing.SetRealm(testing.NewUserRealm(who)) faucet.Claim(cross(cur)) faucet.Approve(cross(cur), "RED", Home(), faucet.ClaimAmount) faucet.Approve(cross(cur), "BLUE", Home(), faucet.ClaimAmount) return who } // testing.SetRealm only governs crossing calls made from the frame that called // it: put it in a helper with no crossing call of its own and it is silently // ignored, and the caller stays whoever it was. fund() gets away with it // because it crosses into the faucet right after; switching accounts mid-test // has to be written inline, which is why there is no act() helper here. // The plain mode is the baseline: it proves custody actually crosses a realm // boundary. The RED never belonged to this realm, and it can only ever take // what the faucet's Approve handed over. func TestPlainWrapperRoundTrip(cur realm, t *testing.T) { alice := fund(cur, t, "w-alice") before := faucet.BalanceOf("RED", alice) key := NewWrapper(cross(cur), faucet.RedKey, "plain", "wRED") uassert.True(t, strings.HasSuffix(key, ".wRED"), "registered under its own symbol: "+key) minted := Deposit(cross(cur), "wRED", 400_000) uassert.Equal(t, int64(400_000), minted) uassert.Equal(t, int64(400_000), BalanceOf("wRED", alice)) uassert.Equal(t, int64(400_000), Escrow("wRED")) uassert.Equal(t, before-400_000, faucet.BalanceOf("RED", alice)) // The escrow really sits at this realm's address, in the faucet's ledger. uassert.Equal(t, int64(400_000), faucet.BalanceOf("RED", Home())) back := Withdraw(cross(cur), "wRED", 150_000) uassert.Equal(t, int64(150_000), back) uassert.Equal(t, int64(250_000), TotalSupply("wRED")) uassert.Equal(t, before-250_000, faucet.BalanceOf("RED", alice)) } // Depositing without an allowance is the default state of the world, and it // fails. This realm holds no standing authority over anybody's balance. func TestDepositNeedsAnAllowance(cur realm, t *testing.T) { fund(cur, t, "noallow") NewWrapper(cross(cur), faucet.RedKey, "plain", "noallow") // Revoke what fund() granted. faucet.Approve(cross(cur), "RED", Home(), 0) uassert.AbortsContains(t, cur, "insufficient allowance", func() { Deposit(cross(cur), "noallow", 1) }) uassert.Equal(t, int64(0), TotalSupply("noallow")) } // kilo re-denominates a token whose own realm will never redeploy for it. func TestKiloWrapperAddsThreeDecimals(cur realm, t *testing.T) { fund(cur, t, "kilo") NewWrapper(cross(cur), faucet.RedKey, "kilo", "kRED") minted := Deposit(cross(cur), "kRED", 1_000) uassert.Equal(t, int64(1_000_000), minted) uassert.Equal(t, int64(1_000), Escrow("kRED")) back := Withdraw(cross(cur), "kRED", 999_000) uassert.Equal(t, int64(999), back) // 999 wrapped units cannot buy back a whole underlying one, so the // redemption is refused rather than burning them for nothing. uassert.AbortsContains(t, cur, "rounds to zero", func() { Withdraw(cross(cur), "kRED", 999) }) } func TestSoulboundWrapperRefusesTransfer(cur realm, t *testing.T) { fund(cur, t, "soul") bob := testutils.TestAddress("soul-bob") NewWrapper(cross(cur), faucet.RedKey, "soulbound", "sRED") Deposit(cross(cur), "sRED", 300_000) uassert.AbortsContains(t, cur, "soulbound", func() { Transfer(cross(cur), "sRED", bob, 1) }) uassert.Equal(t, int64(0), BalanceOf("sRED", bob)) // The exit stays open: unwrap, and the RED moves as freely as ever. back := Withdraw(cross(cur), "sRED", 300_000) uassert.Equal(t, int64(300_000), back) uassert.Equal(t, int64(0), TotalSupply("sRED")) } // Pool: a donation is shared by whoever is holding, and the donor can never // take it back out. func TestPoolWrapperSharesDonations(cur realm, t *testing.T) { alice := fund(cur, t, "pool-a") NewWrapper(cross(cur), faucet.RedKey, "pool", "pRED") uassert.Equal(t, int64(100_000), Deposit(cross(cur), "pRED", 100_000)) bob := fund(cur, t, "pool-b") Donate(cross(cur), "pRED", 100_000) uassert.Equal(t, int64(200_000), Escrow("pRED")) uassert.Equal(t, int64(100_000), TotalSupply("pRED")) uassert.Equal(t, int64(0), BalanceOf("pRED", bob)) // a donation mints nothing // Buying in afterwards costs twice as much per share. uassert.Equal(t, int64(50_000), Deposit(cross(cur), "pRED", 100_000)) // alice walks away with double what she put in. testing.SetRealm(testing.NewUserRealm(alice)) redBefore := faucet.BalanceOf("RED", alice) uassert.Equal(t, int64(200_000), Withdraw(cross(cur), "pRED", 100_000)) uassert.Equal(t, redBefore+200_000, faucet.BalanceOf("RED", alice)) // And bob gets back exactly the 100_000 he deposited: his own donation // was to the holders, and by then that was alice. testing.SetRealm(testing.NewUserRealm(bob)) uassert.Equal(t, int64(100_000), Withdraw(cross(cur), "pRED", 50_000)) uassert.Equal(t, int64(0), Escrow("pRED")) } // Sticky: the 1% exit fee is collected by nobody, it is left in the pool, so // whoever stays longest ends up with it. func TestStickyWrapperChargesOnTheWayOut(cur realm, t *testing.T) { alice := fund(cur, t, "stick-a") NewWrapper(cross(cur), faucet.RedKey, "sticky", "fRED") Deposit(cross(cur), "fRED", 500_000) bob := fund(cur, t, "stick-b") Deposit(cross(cur), "fRED", 500_000) uassert.Equal(t, int64(1_000_000), TotalSupply("fRED")) testing.SetRealm(testing.NewUserRealm(alice)) uassert.Equal(t, int64(495_000), Withdraw(cross(cur), "fRED", 500_000)) // The 5_000 alice paid on the way out now backs bob's shares. uassert.Equal(t, int64(505_000), Escrow("fRED")) testing.SetRealm(testing.NewUserRealm(bob)) uassert.Equal(t, int64(499_950), Withdraw(cross(cur), "fRED", 500_000)) } // Fusion: one token backed by a fixed bundle of two, and nothing else. func TestFusionOfRedAndBlue(cur realm, t *testing.T) { alice := fund(cur, t, "fuse-a") bob := testutils.TestAddress("fuse-b") key := NewFusion(cross(cur), faucet.RedKey, 1, faucet.BlueKey, 2, "PURPLE") uassert.True(t, strings.HasSuffix(key, ".PURPLE"), key) uassert.Equal(t, 2, Legs("PURPLE")) redBefore := faucet.BalanceOf("RED", alice) blueBefore := faucet.BalanceOf("BLUE", alice) Fuse(cross(cur), "PURPLE", 100_000) uassert.Equal(t, int64(100_000), BalanceOf("PURPLE", alice)) uassert.Equal(t, redBefore-100_000, faucet.BalanceOf("RED", alice)) uassert.Equal(t, blueBefore-200_000, faucet.BalanceOf("BLUE", alice)) sym, per, held := Leg("PURPLE", 1) uassert.Equal(t, "BLUE", sym) uassert.Equal(t, int64(2), per) uassert.Equal(t, int64(200_000), held) // PURPLE moves as one object, and whoever ends up with it can split it // back into both legs without ever having touched the faucet. Transfer(cross(cur), "PURPLE", bob, 40_000) testing.SetRealm(testing.NewUserRealm(bob)) Defuse(cross(cur), "PURPLE", 40_000) uassert.Equal(t, int64(40_000), faucet.BalanceOf("RED", bob)) uassert.Equal(t, int64(80_000), faucet.BalanceOf("BLUE", bob)) uassert.Equal(t, int64(60_000), TotalSupply("PURPLE")) } // Every token issued here is registered too, so it can be wrapped again. func TestWrappingAWrapper(cur realm, t *testing.T) { fund(cur, t, "nest") NewWrapper(cross(cur), faucet.RedKey, "plain", "nRED") Deposit(cross(cur), "nRED", 800_000) // nRED lives in THIS realm, so the allowance is granted through this // realm's own Approve. Same shape, one level up. NewWrapper(cross(cur), Key("nRED"), "pool", "nnRED") Approve(cross(cur), "nRED", Home(), 800_000) uassert.Equal(t, int64(600_000), Deposit(cross(cur), "nnRED", 600_000)) // The outer wrapper escrows the inner token, not the RED. uassert.Equal(t, int64(600_000), Escrow("nnRED")) uassert.Equal(t, int64(800_000), Escrow("nRED")) uassert.Equal(t, int64(600_000), BalanceOf("nRED", Home())) // And it unwinds the whole way back down. uassert.Equal(t, int64(600_000), Withdraw(cross(cur), "nnRED", 600_000)) uassert.Equal(t, int64(800_000), Withdraw(cross(cur), "nRED", 800_000)) uassert.Equal(t, int64(0), TotalSupply("nRED")) } func TestAllowanceOnAWrappedToken(cur realm, t *testing.T) { alice := fund(cur, t, "allow-a") bob := testutils.TestAddress("allow-b") NewWrapper(cross(cur), faucet.RedKey, "plain", "aRED") Deposit(cross(cur), "aRED", 500_000) Approve(cross(cur), "aRED", bob, 120_000) uassert.Equal(t, int64(120_000), Allowance("aRED", alice, bob)) testing.SetRealm(testing.NewUserRealm(bob)) TransferFrom(cross(cur), "aRED", alice, bob, 120_000) uassert.Equal(t, int64(120_000), BalanceOf("aRED", bob)) uassert.AbortsContains(t, cur, "insufficient allowance", func() { TransferFrom(cross(cur), "aRED", alice, bob, 1) }) } func TestFaucetCooldown(cur realm, t *testing.T) { who := fund(cur, t, "cooldown") uassert.AbortsContains(t, cur, "too soon", func() { faucet.Claim(cross(cur)) }) next, ok := faucet.NextClaim(who) uassert.True(t, ok, "the account has claimed once") testing.SkipHeights(faucet.ClaimEvery + 1) faucet.Claim(cross(cur)) uassert.Equal(t, 2*faucet.ClaimAmount, faucet.BalanceOf("RED", who)) uassert.True(t, next > 0, "a cooldown was recorded") _, ok = faucet.NextClaim(testutils.TestAddress("never")) uassert.False(t, ok, "an account that never claimed has no cooldown") } func TestRejectsBadInput(cur realm, t *testing.T) { fund(cur, t, "bad") NewWrapper(cross(cur), faucet.RedKey, "plain", "xRED") NewFusion(cross(cur), faucet.RedKey, 1, faucet.BlueKey, 1, "xMIX") uassert.AbortsContains(t, cur, "unknown mode", func() { NewWrapper(cross(cur), faucet.RedKey, "moon", "xMOON") }) uassert.AbortsContains(t, cur, "already issued by this realm", func() { NewWrapper(cross(cur), faucet.RedKey, "plain", "xRED") }) uassert.AbortsContains(t, cur, "unknown token", func() { NewWrapper(cross(cur), "gno.land/r/nope/nope.NOPE", "plain", "xNOPE") }) uassert.AbortsContains(t, cur, "is a fusion", func() { Deposit(cross(cur), "xMIX", 1) }) uassert.AbortsContains(t, cur, "is a wrapper", func() { Fuse(cross(cur), "xRED", 1) }) uassert.AbortsContains(t, cur, "has not issued", func() { Deposit(cross(cur), "GHOST", 1) }) uassert.AbortsContains(t, cur, "unknown symbol", func() { faucet.Transfer(cross(cur), "GREEN", Home(), 1) }) } func TestRender(cur realm, t *testing.T) { fund(cur, t, "render") NewWrapper(cross(cur), faucet.RedKey, "pool", "rRED") Deposit(cross(cur), "rRED", 250_000) index := Render("") uassert.True(t, strings.Contains(index, "# GRC20 wrapper factory"), "index heading") uassert.True(t, strings.Contains(index, Home().String()), "index shows the escrow address") uassert.True(t, strings.Contains(index, "| [rRED](/r/moul/x/grc20wrapdemo/v0:rRED) | pool | RED |"), "index lists the wrapper:\n"+index) detail := Render("rRED") uassert.True(t, strings.Contains(detail, "escrowed underlying: 250000"), detail) uassert.True(t, strings.Contains(detail, "- mode: `pool`"), detail) uassert.True(t, strings.Contains(detail, "solvent: yes"), detail) uassert.True(t, strings.Contains(Render("NOPE"), "404"), "unknown symbol renders a 404") }
#23AddPackagegno.land/r/moul/x/pairreg/v09 arguments
Attached funds
5000000ugnot

Arguments · 9

  1. #1pairreg
  2. #2README.md
  3. #3# `gno.land/r/moul/x/pairreg/v0` The registry every AMM pair instance announces itself to, and the unified frontend over all of them. Third artifact of the instance-per-realm pattern; the logic is in [`p/moul/x/pair/v0`](../../../../p/moul/x/pair/v0). - **Registration is one line in an instance's `init`**, so a single `addpkg` both creates a pair and lists it. - **The key is the caller's own package path**, taken from `cur.Previous().PkgPath()`, so an instance cannot claim a path that is not its own. - **Entries hold a live `*pair.Pair`**, not an address, so `Render` shows every instance's real reserves from one `vm/qrender`. No indexer, no multicall. - **Permissionless**, and spam is self funded: the registering transaction pays for the storage it adds. ## What it cannot tell you Nothing on chain can read a package's source, so the registry cannot check that an instance runs the shared template. Reserves shown in the index are **claimed**. Several instances may exist for the same couple, on purpose: there is no canonical pair and no factory to enforce one. Verify an instance by diffing its source (`vm/qfile`) against freshly generated output before funding it. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/pairreg/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/pairreg/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/r/moul/x/pairreg/v0" gno = "0.9" # public: every pair instance imports it to announce itself, and holds the # *pair.Pair it registers here. A private realm cannot be imported at all.
  6. #6pairreg.gno
  7. #7// Package pairreg is the registry every AMM pair instance announces itself to, // and the unified frontend over all of them. // // It is the third artifact of the instance-per-realm pattern: one shared // p/moul/x/pair/v0 holding the logic, N tiny realms holding one pair each, and // this realm knowing all of them. // // # Why registration cannot be faked, and code cannot be trusted // // Register keys an instance by cur.Previous().PkgPath(), which the runtime // supplies, so an instance cannot claim a path that is not its own. That is // the whole on-chain guarantee. It is NOT a guarantee about the code at that // path: gno has no way to read another package's source from inside a realm, // so nothing here can check that an instance really runs the shared template. // The mirror image of Ethereum's CREATE2, which proves the code and says // nothing legible about who deployed it. // // Consequences a reader of the index must keep in mind: // // - Reserves shown here are CLAIMED. They are read live from the instance's // own struct, which is honest for an instance running the template and // arbitrary for one that does not. // - Several instances may exist for the same token couple. That is allowed // on purpose; there is no canonical pair and no factory to enforce one. // - Approving tokens to an instance risks exactly what was approved. Read // the instance's source (vm/qfile on its path) before funding it. // // # Why the registry can read live state at all // // Instances register a *pair.Pair pointer, not an address, the way grc20reg // registers a *grc20.Token. One vm/qrender here therefore renders the real // state of every instance, with no indexer and no multicall. It is safe // because pair.Pair holds only concrete types: an interface or a func field // would let a hostile instance hand foreign code to this realm's frame. package pairreg import ( "chain" "chain/runtime" "strings" "gno.land/p/moul/x/pair/v0" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ufmt/v0" ) // Entry is one registered instance. type Entry struct { Path string // the instance realm's package path, supplied by the runtime Pair *pair.Pair // live pointer into the instance's own storage Height int64 // chain height at registration } // entries maps instance path -> *Entry. couples maps a pair ID // ("keyA~keyB") -> int, how many instances claim that couple. var ( entries avl.Tree couples avl.Tree ) // Register records the calling realm as an instance. Call it from the // instance's init, so that deploying and listing are one transaction: // // func init(cur realm) { // p = pair.New(keyA, keyB, grc20reg.MustGet(keyA), grc20reg.MustGet(keyB)) // pairreg.Register(cross(cur), p) // } // // Permissionless by design: anyone may deploy an instance under their own // address namespace and land here. Spam is self funded, since the registering // transaction pays for the storage it adds. func Register(cur realm, p *pair.Pair) { caller := cur.Previous() path := caller.PkgPath() if path == "" { panic("pairreg: only a realm can register") } if p == nil { panic("pairreg: nil pair") } if entries.Has(path) { panic("pairreg: already registered: " + path) } entries.Set(path, &Entry{Path: path, Pair: p, Height: runtime.ChainHeight()}) couples.Set(p.ID(), countFor(p.ID())+1) chain.Emit("RegisterPair", "path", path, "pair", p.ID(), ) } // Get returns the entry for an instance path, or nil. func Get(path string) *Entry { v := entries.Get(path) if v == nil { return nil } return v.(*Entry) } // Size returns how many instances are registered. func Size() int { return entries.Size() } // Couples returns how many distinct token couples are represented. func Couples() int { return couples.Size() } // InstancesFor returns the instance paths claiming a given couple id. func InstancesFor(id string) []string { out := []string{} entries.Iterate("", "", func(_ string, value any) bool { e := value.(*Entry) if e.Pair.ID() == id { out = append(out, e.Path) } return false }) return out } // Render is the unified frontend: the index of every instance with its live // state, or one instance's own page when path is an instance path. func Render(path string) string { if path != "" { e := Get(path) if e == nil { return "# 404\n\nNo instance registered at `" + path + "`.\n" } out := e.Pair.Render("") out += "\n---\n\n" out += ufmt.Sprintf("Instance: [`%s`](%s) · registered at height %d · %d instance(s) claim this couple.\n", e.Path, webPath(e.Path), e.Height, countFor(e.Pair.ID())) return out } out := "# AMM pairs\n\n" out += "One realm per token couple, all running [p/moul/x/pair/v0](/p/moul/x/pair/v0). " out += "Anyone can deploy one under their own namespace and it lands here.\n\n" if entries.Size() == 0 { out += "_No instance yet._\n" return out } out += ufmt.Sprintf("%d instance(s), %d couple(s).\n\n", entries.Size(), couples.Size()) out += "**Reserves below are claimed by each instance, not verified.** " out += "Nothing on chain can check that an instance runs the shared template; read its source before funding it.\n\n" out += "| pair | reserves | LP shares | providers | instance |\n" out += "|---|---|---|---|---|\n" entries.Iterate("", "", func(key string, value any) bool { e := value.(*Entry) symA, symB := e.Pair.Symbols() resA, resB := e.Pair.Reserves() out += ufmt.Sprintf("| [%s/%s](/r/moul/x/pairreg/v0:%s) | %d %s / %d %s | %d | %d | [`%s`](%s) |\n", symA, symB, key, resA, symA, resB, symB, e.Pair.TotalShares(), e.Pair.Providers(), shortPath(e.Path), webPath(e.Path)) return false }) return out } // // Internals. // func countFor(id string) int { v := couples.Get(id) if v == nil { return 0 } return v.(int) } // webPath turns a package path into a gnoweb link. func webPath(pkgPath string) string { if i := strings.Index(pkgPath, "/"); i >= 0 { return pkgPath[i:] } return "/" + pkgPath } // shortPath drops the chain domain, which is the same on every row. func shortPath(pkgPath string) string { if i := strings.Index(pkgPath, "/"); i >= 0 { return pkgPath[i+1:] } return pkgPath }
  8. #8pairreg_test.gno
  9. #9package pairreg import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) // Loaded on its own, the registry has no instances: the index says so rather // than rendering an empty table. func TestRenderEmpty(t *testing.T) { out := Render("") uassert.True(t, strings.Contains(out, "# AMM pairs")) uassert.True(t, strings.Contains(out, "No instance yet")) uassert.Equal(t, 0, Size()) uassert.Equal(t, 0, Couples()) } func TestRenderUnknownInstance(t *testing.T) { out := Render("gno.land/r/nope/v0") uassert.True(t, strings.Contains(out, "# 404")) uassert.True(t, Get("gno.land/r/nope/v0") == nil) uassert.Equal(t, 0, len(InstancesFor("a~b"))) } // A gnoweb link is the package path minus the chain domain. func TestWebPath(t *testing.T) { uassert.Equal(t, "/r/moul/x/pairs/aaabbb/v0", webPath("gno.land/r/moul/x/pairs/aaabbb/v0")) uassert.Equal(t, "r/moul/x/pairs/aaabbb/v0", shortPath("gno.land/r/moul/x/pairs/aaabbb/v0")) }
#24AddPackagegno.land/r/moul/x/wesh/v013 arguments
Attached funds
20000000ugnot

Arguments · 13

  1. #1wesh
  2. #2README.md
  3. #3# `gno.land/r/moul/x/wesh/v0` **An on-chain directory and device sigchain for Wesh protocol (weshnet / Berty) identities**, built on [`p/moul/x/wesh`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/wesh/v0). ``` gnokey maketx call -pkgpath gno.land/r/moul/x/wesh/v0 -func Register \ -args alice -args <accountPK hex> -args <seed hex> -args "Alice" -args <sig hex> ``` `Render` then emits a link a Berty client can actually open: ``` https://berty.tech/id#contact/oZBLFpzghxrATkepWvDPNX9pHYqi6BWgP45x…/name=Alice ``` ## What it is for Wesh is peer-to-peer and end-to-end encrypted, and it has three gaps that only an authenticated, ordered, publicly auditable record can close: 1. **Resolution.** A Berty identity travels out of band and cannot be looked up. Here a name resolves to a contact, and the realm renders the real link. 2. **Rotation with a paper trail.** Resetting the rendezvous seed silently kills every link ever shared. Here every rotation is numbered, signed and kept, so a stale link is recognisably stale rather than merely dead. 3. **Device revocation.** Wesh cannot revoke a device at all. This realm hosts the missing log: hash-chained, account-signed, and verified on chain with `crypto/ed25519` before an entry is accepted. ## API | call | what it does | |---|---| | `Register(cur, name, accountPK, seed, displayName, sig)` | claim a name, publishing the rendezvous seed | | `RegisterCommitted(cur, name, accountPK, commitment, displayName, sig)` | claim a name, publishing only `H(seed ‖ salt)` | | `Rotate(cur, payload, sig)` | publish a new seed/commitment at `revision+1` | | `AppendDevice(cur, prev, op, devicePK, sig)` | append `add` or `revoke` to the sigchain | | `SetDisplayName(cur, name)` · `Release(cur)` | update the label · free the name | | `Resolve` · `Link` · `RendezvousPointAt` · `DeviceStatus` · `SigchainHead` · `NameOf` · `Count` | read-only, for gnoweb and other realms | ## Authentication Every state-changing call carries an **ed25519 signature made with the Wesh account key**, over a canonical statement naming the kind, the chain id, the caller's gno address, and a monotonic revision or sequence number. Each field is there to stop one attack: - without the **kind**, a binding signature could be replayed as a device one; - without the **chain id**, a testnet binding replays on mainnet; - without the **gno address**, anyone could publish someone else's account key next to a seed they control and harvest the contact requests; - without the **revision / sequence**, a superseded statement could roll a rotation back or fork the sigchain. All five are covered by a negative test apiece. ## What it never stores No group secret, no device chain key, no message key, no ciphertext: the library it is built on has no type that can hold one. And it does not pretend to *enforce* revocation inside weshnet: a revoked device's ratchets are already distributed. What revocation buys here is that it becomes public, ordered and attributable, which is strictly more than weshnet has today. ## Publishing is opt-in exposure A published seed lets anyone derive the account's rotating rendezvous point. That is the right trade for a support line, a shop or a public channel, and the wrong one for a private account, which is what `RegisterCommitted` is for. **Library:** [`p/moul/x/wesh`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/wesh/v0). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/wesh/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/wesh/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/r/moul/x/wesh/v0" gno = "0.9" private = true
  6. #6render.gno
  7. #7package wesh import ( "chain/runtime" "encoding/hex" "strconv" "strings" "time" "gno.land/p/moul/x/wesh/v0" ) // Render serves the directory index at "" and one identity card at "<name>". // // The index deliberately does not compute a Berty link per row: base58 over a // 70-byte payload is big-integer work, and it belongs on the page that a human // actually asked for. func Render(path string) string { path = strings.Trim(path, "/") if path == "" { return renderIndex() } return renderIdentity(path) } func renderIndex() string { var b strings.Builder b.WriteString("# Wesh directory\n\n") b.WriteString("Resolvable, self-signed [Wesh protocol](https://berty.tech/docs/protocol/) ") b.WriteString("identities, on top of [`p/moul/x/wesh`](/p/moul/x/wesh/v0).\n\n") b.WriteString("A Berty identity normally travels out of band as a QR code, cannot be ") b.WriteString("looked up, cannot announce a seed rotation, and can never revoke a device. ") b.WriteString("This realm addresses those three, and stores no secret of any kind.\n\n") if byName.Size() == 0 { b.WriteString("_No identity registered yet._\n") return b.String() } b.WriteString("| name | owner | rev | mode | devices |\n") b.WriteString("|---|---|---|---|---|\n") byName.Iterate("", "", func(key string, value any) bool { id := value.(*identity) b.WriteString("| [" + id.name + "](/r/moul/x/wesh/v0:" + id.name + ") ") b.WriteString("| `" + id.owner.String() + "` ") b.WriteString("| " + strconv.Itoa(id.revision) + " ") b.WriteString("| " + modeLabel(id) + " ") b.WriteString("| " + strconv.Itoa(activeDevices(id)) + " active / " + strconv.Itoa(len(id.devices)) + " entries |\n") return false }) n := byName.Size() if n == 1 { b.WriteString("\n1 identity.\n") } else { b.WriteString("\n" + strconv.Itoa(n) + " identities.\n") } return b.String() } func renderIdentity(name string) string { id := lookup(name) if id == nil { return "# Not found\n\nNo identity named `" + name + "`. [Back to the directory](/r/moul/x/wesh/v0).\n" } var b strings.Builder b.WriteString("# " + id.name + "\n\n") if id.displayName != "" { b.WriteString("_" + id.displayName + "_\n\n") } b.WriteString("| field | value |\n|---|---|\n") b.WriteString("| owner | `" + id.owner.String() + "` |\n") b.WriteString("| account key | `" + hex.EncodeToString(id.accountPK) + "` |\n") b.WriteString("| mode | " + modeLabel(id) + " |\n") b.WriteString("| revision | " + strconv.Itoa(id.revision) + " |\n") b.WriteString("| registered | block " + strconv.FormatInt(id.history[0].height, 10) + " |\n") b.WriteString("\n## Contact\n\n") if id.committed { b.WriteString("This identity publishes a **commitment**, `") b.WriteString(hex.EncodeToString(id.payload)) b.WriteString("`, not its rendezvous seed.\n\n") b.WriteString("The chain attests that this account key claimed that commitment from ") b.WriteString("that gno address. The seed itself is shared out of band, and whoever ") b.WriteString("receives it can check it here with `H(seed || salt)` before using it. ") b.WriteString("The rendezvous point stays off chain.\n") } else { link, err := contactOf(id).WebLink() if err != nil { b.WriteString("_Contact is unrenderable: " + err.Error() + "_\n") } else { b.WriteString("Scan or open this in Berty to send a contact request:\n\n") b.WriteString("```\n" + link + "\n```\n\n") b.WriteString("| field | value |\n|---|---|\n") b.WriteString("| rendezvous seed | `" + hex.EncodeToString(id.payload) + "` |\n") now := time.Now().Unix() period := wesh.RoundPeriod(now, wesh.DefaultRotationInterval) point, _ := contactOf(id).RendezvousPointAt(now, wesh.DefaultRotationInterval) b.WriteString("| rendezvous point, period " + strconv.FormatInt(period, 10) + " | `" + hex.EncodeToString(point) + "` |\n") b.WriteString("\nThe point is `HMAC-SHA256(accountPK || seed, be64(period))`, derived ") b.WriteString("exactly as weshnet derives it, and it rotates every 24h. Anyone can ") b.WriteString("recompute it and check the DHT, so this entry is verifiable rather ") b.WriteString("than merely asserted.\n") } } b.WriteString("\n## Devices\n\n") if len(id.devices) == 0 { b.WriteString("No sigchain entry yet. Head is the genesis digest `") b.WriteString(hex.EncodeToString(id.head) + "`.\n") } else { b.WriteString("Every entry below was signed by the account key and verified on chain, ") b.WriteString("and chains to the digest of the one before it.\n\n") b.WriteString("| seq | op | device | block | digest |\n|---|---|---|---|---|\n") for _, e := range id.devices { b.WriteString("| " + strconv.Itoa(e.seq) + " ") b.WriteString("| " + e.op + " ") b.WriteString("| `" + short(hex.EncodeToString(e.devicePK)) + "` ") b.WriteString("| " + strconv.FormatInt(e.height, 10) + " ") b.WriteString("| `" + short(hex.EncodeToString(e.digest)) + "` |\n") } b.WriteString("\nHead: `" + hex.EncodeToString(id.head) + "`\n") } if len(id.history) > 1 { b.WriteString("\n## Rotation history\n\n") b.WriteString("Weshnet has no way to announce that a seed was reset, so every link ") b.WriteString("ever shared dies silently. These are the superseded values, kept so a ") b.WriteString("stale link can be recognised as stale.\n\n") b.WriteString("| rev | value | block |\n|---|---|---|\n") for _, r := range id.history { mark := "" if r.revision == id.revision { mark = " (current)" } b.WriteString("| " + strconv.Itoa(r.revision) + mark + " ") b.WriteString("| `" + short(hex.EncodeToString(r.payload)) + "` ") b.WriteString("| " + strconv.FormatInt(r.height, 10) + " |\n") } } b.WriteString("\n[Back to the directory](/r/moul/x/wesh/v0) · chain `" + runtime.ChainID() + "`\n") return b.String() } func modeLabel(id *identity) string { if id.committed { return "committed" } return "published" } // activeDevices counts the distinct devices whose latest sigchain entry is an // add. A device can be added, revoked and added again, so only the last entry // for each key decides. func activeDevices(id *identity) int { seen := []string{} for _, e := range id.devices { key := string(e.devicePK) known := false for _, s := range seen { if s == key { known = true break } } if !known { seen = append(seen, key) } } n := 0 for _, k := range seen { if deviceIsActive(id, []byte(k)) { n++ } } return n } // short abbreviates a long hex string for table cells. func short(s string) string { if len(s) <= 16 { return s } return s[:8] + "…" + s[len(s)-8:] }
  8. #8render_example_test.gno
  9. #9package wesh import ( "encoding/hex" "gno.land/p/moul/x/wesh/v0" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) // seedDemo puts the realm into a fixed state for the examples below. // // It writes the globals directly instead of going through the crossing API: // realm globals persist for the whole test binary and examples run after every // Test, so an example that did not reset would pin whatever the last test left // behind. Heights are literals for the same reason. func seedDemo() { byName = avl.Tree{} byOwner = avl.Tree{} byAccount = avl.Tree{} pk, _ := hex.DecodeString("2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12") seed1, _ := hex.DecodeString("abababababababababababababababababababababababababababababababab") seed2, _ := hex.DecodeString("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") dev1, _ := hex.DecodeString("0707070707070707070707070707070707070707070707070707070707070707") dev2, _ := hex.DecodeString("0909090909090909090909090909090909090909090909090909090909090909") d1, _ := hex.DecodeString("bc51799b5d012dc7ce806b4c63474b1e2db515e7d1622ecd5900b23e1d6a9519") d2, _ := hex.DecodeString("b0da03f83e5349e92bc524714862daa850750f756ca6fd47264333fbf2175c15") d3, _ := hex.DecodeString("ef0c1d0614420c7d660f8416b18651206118e49e8f46ac0360bef61ae6b9100d") alice := &identity{ name: "alice", owner: testutils.TestAddress("alice"), accountPK: pk, payload: seed2, revision: 2, displayName: "Alice, support line", history: []rotation{ {revision: 1, payload: seed1, height: 100}, {revision: 2, payload: seed2, height: 140}, }, devices: []deviceEntry{ {seq: 0, op: wesh.OpAdd, devicePK: dev1, digest: d1, height: 110}, {seq: 1, op: wesh.OpAdd, devicePK: dev2, digest: d2, height: 120}, {seq: 2, op: wesh.OpRevoke, devicePK: dev1, digest: d3, height: 130}, }, head: d3, } byName.Set(alice.name, alice) byOwner.Set(alice.owner.String(), alice.name) byAccount.Set(hex.EncodeToString(pk), alice.name) } // ExampleRender pins the directory index. func ExampleRender() { seedDemo() print(Render("")) // Output: // # Wesh directory // // Resolvable, self-signed [Wesh protocol](https://berty.tech/docs/protocol/) identities, on top of [`p/moul/x/wesh`](/p/moul/x/wesh/v0). // // A Berty identity normally travels out of band as a QR code, cannot be looked up, cannot announce a seed rotation, and can never revoke a device. This realm addresses those three, and stores no secret of any kind. // // | name | owner | rev | mode | devices | // |---|---|---|---|---| // | [alice](/r/moul/x/wesh/v0:alice) | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | 2 | published | 1 active / 3 entries | // // 1 identity. } // ExampleRender_identity pins one identity card: the Berty link, today's // rendezvous point, the device sigchain and the rotation history. func ExampleRender_identity() { seedDemo() print(Render("alice")) // Output: // # alice // // _Alice, support line_ // // | field | value | // |---|---| // | owner | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | // | account key | `2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12` | // | mode | published | // | revision | 2 | // | registered | block 100 | // // ## Contact // // Scan or open this in Berty to send a contact request: // // ``` // https://berty.tech/id#contact/oZBLGHCDNRB847rT1zHe1xnon58fpWTUsuNntmAo9TK42aKzt14A1V5W67QoN5YzMouuVcucUiBWjN9d2qzy6gUNM4Jio4M/name=Alice%2C+support+line // ``` // // | field | value | // |---|---| // | rendezvous seed | `eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee` | // | rendezvous point, period 1234483200 | `08ab5591fbd91f0251a7efe4856fc736a02d1a2252139483ddc8ca85f4616e07` | // // The point is `HMAC-SHA256(accountPK || seed, be64(period))`, derived exactly as weshnet derives it, and it rotates every 24h. Anyone can recompute it and check the DHT, so this entry is verifiable rather than merely asserted. // // ## Devices // // Every entry below was signed by the account key and verified on chain, and chains to the digest of the one before it. // // | seq | op | device | block | digest | // |---|---|---|---|---| // | 0 | add | `07070707…07070707` | 110 | `bc51799b…1d6a9519` | // | 1 | add | `09090909…09090909` | 120 | `b0da03f8…f2175c15` | // | 2 | revoke | `07070707…07070707` | 130 | `ef0c1d06…e6b9100d` | // // Head: `ef0c1d0614420c7d660f8416b18651206118e49e8f46ac0360bef61ae6b9100d` // // ## Rotation history // // Weshnet has no way to announce that a seed was reset, so every link ever shared dies silently. These are the superseded values, kept so a stale link can be recognised as stale. // // | rev | value | block | // |---|---|---| // | 1 | `abababab…abababab` | 100 | // | 2 (current) | `eeeeeeee…eeeeeeee` | 140 | // // [Back to the directory](/r/moul/x/wesh/v0) · chain `dev` } // ExampleRender_unknown pins the miss. func ExampleRender_unknown() { seedDemo() print(Render("nobody")) // Output: // # Not found // // No identity named `nobody`. [Back to the directory](/r/moul/x/wesh/v0). }
  10. #10wesh.gno
  11. #11// Package wesh is an on-chain directory and device sigchain for Wesh protocol // (weshnet / Berty) identities. // // It exists to give weshnet the three things a peer-to-peer network cannot // give itself, and which a chain is uniquely good at: // // 1. Resolution. A Berty identity travels out of band as a QR code or a // https://berty.tech/id# link, and there is no way to look one up. Here a // name resolves to a contact, and Render emits the real, scannable link. // 2. Rotation with a paper trail. Resetting the public rendezvous seed // silently kills every link ever shared; there is no revocation channel. // Here every rotation is a numbered, timestamped, signed entry, and the // superseded seeds stay visible, so a stale link resolves to a redirect // instead of to nothing. // 3. Device revocation. The Wesh protocol documentation states plainly that a // device, once linked to an account, can never be revoked: the account // metadata log is append-only and no authority can void an entry. This // realm hosts the log that was missing: a hash-chained, account-signed // roster whose every entry the chain verifies with ed25519 before // accepting it. // // # What this realm does not do, and will not // // It never stores a secret. Group secrets, device chain keys, message keys and // ciphertexts stay off chain, permanently. The library this realm is built on, // [p/moul/x/wesh](/p/moul/x/wesh/v0), has no type that can hold one. // // It also does not pretend to enforce revocation inside weshnet. A revoked // device's ratchets are already distributed and forward secrecy is a local // property; no chain can reach into a group and forget them. What revocation // buys here is that it becomes public, ordered, and attributable to the // account key, which is strictly more than weshnet has today. // // # Publishing is a deliberate act // // A published rendezvous seed lets anyone derive the account's rotating // rendezvous point and watch the DHT for it. That is the same exposure as // printing your Berty QR code on a billboard, and it is the right trade only // for an identity that wants to be found: a support line, a shop, a public // channel. // // [RegisterCommitted] is the alternative. It publishes H(seed ‖ salt) instead, // so the chain attests that a seed handed over out of band really belongs to // the named account, without broadcasting where that account listens. // // # Authentication // // Every state-changing call carries an ed25519 signature made with the Wesh // account private key over a canonical statement that names this chain, the // caller's gno address, and a monotonic revision or sequence number. Without // it, anyone could publish anyone else's account key next to a seed of their // choosing and harvest the contact requests that followed. package wesh import ( "chain/runtime" "encoding/hex" "strings" "time" "gno.land/p/moul/x/wesh/v0" "gno.land/p/nt/avl/v0" ) const ( // MinNameLen and MaxNameLen bound a directory handle. MinNameLen = 3 MaxNameLen = 32 // MaxDevices bounds one account's sigchain so iteration and Render stay // affordable. weshnet accounts hold a handful of devices, not hundreds. MaxDevices = 64 // MaxRotations bounds the retained rotation history. Older entries are // dropped from the front: the point of the history is to let a stale link // be recognised, not to keep a permanent archive. MaxRotations = 32 ) // rotation is one seed (or commitment) an identity has published. type rotation struct { revision int payload []byte height int64 } // deviceEntry is one verified sigchain statement. type deviceEntry struct { seq int op string devicePK []byte digest []byte height int64 } // identity is a published Wesh identity bound to one gno address. // // payload holds the public rendezvous seed when committed is false, and // H(seed ‖ salt) when it is true. head is the digest of the last accepted // sigchain entry, which the next entry must chain to. type identity struct { name string owner address accountPK []byte payload []byte committed bool revision int displayName string createdAt int64 updatedAt int64 history []rotation devices []deviceEntry head []byte } var ( // byName is the directory, keyed by handle so avl iteration is alphabetical. byName avl.Tree // byOwner maps a gno address to its handle: one identity per address. byOwner avl.Tree // byAccount maps a hex account key to its handle, so two handles can never // claim the same Wesh account. byAccount avl.Tree ) // Register publishes an identity whose rendezvous seed is public. // // sigHex must be an ed25519 signature by the account private key over // wesh.BindStatement(chainID, caller, accountPK, seed, 1). Revision 1 is fixed // at registration so a signature captured from a later rotation cannot be // replayed to re-register the name after it is released. func Register(cur realm, name, accountPKHex, seedHex, displayName, sigHex string) { if !cur.IsCurrent() { panic("spoofed realm") } register(cur.Previous().Address(), name, accountPKHex, seedHex, displayName, sigHex, false) } // RegisterCommitted publishes an identity that keeps its rendezvous point off // chain: commitmentHex is wesh.SeedCommitment(seed, salt), and the seed itself // is shared out of band. // // The chain still attests the binding (this account key really did claim this // commitment from this gno address), so a seed later disclosed privately can // be checked against it. What it does not do is tell the world where the // account listens. func RegisterCommitted(cur realm, name, accountPKHex, commitmentHex, displayName, sigHex string) { if !cur.IsCurrent() { panic("spoofed realm") } register(cur.Previous().Address(), name, accountPKHex, commitmentHex, displayName, sigHex, true) } // register is shared by Register and RegisterCommitted. It takes the already // resolved caller rather than a realm: a helper whose first parameter is // `realm` would be a second crossing hop, and Previous() inside it would // resolve to this realm rather than to the user who called in. func register(caller address, name, accountPKHex, payloadHex, displayName, sigHex string, committed bool) { name = normalizeName(name) assertNameAvailable(name) if byOwner.Has(caller.String()) { panic("wesh: this address already owns an identity; release it first") } accountPK := mustAccountPK(accountPKHex) if byAccount.Has(accountPKHex) { panic("wesh: this account key is already published under another name") } payload := mustPayload(payloadHex, committed) assertDisplayName(displayName) if err := wesh.VerifyBind(runtime.ChainID(), caller.String(), accountPK, payload, 1, mustSig(sigHex)); err != nil { panic("wesh: " + err.Error()) } now := time.Now().Unix() id := &identity{ name: name, owner: caller, accountPK: accountPK, payload: payload, committed: committed, revision: 1, displayName: displayName, createdAt: now, updatedAt: now, history: []rotation{{revision: 1, payload: payload, height: runtime.ChainHeight()}}, head: wesh.GenesisDigest(), } byName.Set(name, id) byOwner.Set(caller.String(), name) byAccount.Set(accountPKHex, name) } // Rotate publishes a new seed (or commitment) for the caller's identity. // // This is the operation weshnet's ContactRequestResetReference has no // counterpart for. There, resetting the seed silently invalidates every link // ever shared. Here the new value is numbered and the old one stays in the // history, so a holder of a stale link can see that it was superseded and when. // // The signature must be over revision+1, which is what stops a superseded // binding being replayed to roll a rotation back. func Rotate(cur realm, payloadHex, sigHex string) { if !cur.IsCurrent() { panic("spoofed realm") } id := mustOwnIdentity(cur.Previous().Address()) payload := mustPayload(payloadHex, id.committed) if string(payload) == string(id.payload) { panic("wesh: the new value is identical to the current one") } next := id.revision + 1 if err := wesh.VerifyBind(runtime.ChainID(), id.owner.String(), id.accountPK, payload, next, mustSig(sigHex)); err != nil { panic("wesh: " + err.Error()) } id.revision = next id.payload = payload id.updatedAt = time.Now().Unix() id.history = append(id.history, rotation{revision: next, payload: payload, height: runtime.ChainHeight()}) if len(id.history) > MaxRotations { id.history = id.history[len(id.history)-MaxRotations:] } } // AppendDevice adds one entry to the caller's device sigchain. // // prevHex must be the digest of the current head (all zeros for the first // entry), and the signature must cover the whole statement including the // sequence number and that digest. Chaining every entry to its predecessor is // what stops the log being reordered or having an entry quietly dropped: any // gap changes every digest after it. // // op is "add" or "revoke". Adding a device that is already active, or revoking // one that is not, is refused: an append-only log is only useful if its entries // are meaningful. func AppendDevice(cur realm, prevHex, op, devicePKHex, sigHex string) { if !cur.IsCurrent() { panic("spoofed realm") } id := mustOwnIdentity(cur.Previous().Address()) if len(id.devices) >= MaxDevices { panic("wesh: device sigchain is full") } prev, err := wesh.DecodeCommitment(prevHex) if err != nil { panic("wesh: previous digest: " + err.Error()) } if string(prev) != string(id.head) { panic("wesh: previous digest does not match the sigchain head " + hex.EncodeToString(id.head)) } devicePK, err := wesh.DecodeDevicePK(devicePKHex) if err != nil { panic("wesh: device key: " + err.Error()) } active := deviceIsActive(id, devicePK) switch op { case wesh.OpAdd: if active { panic("wesh: device is already active") } case wesh.OpRevoke: if !active { panic("wesh: device is not active, nothing to revoke") } default: panic("wesh: operation must be add or revoke") } seq := len(id.devices) digest, err := wesh.VerifyDevice(runtime.ChainID(), id.accountPK, seq, prev, op, devicePK, mustSig(sigHex)) if err != nil { panic("wesh: " + err.Error()) } id.devices = append(id.devices, deviceEntry{ seq: seq, op: op, devicePK: devicePK, digest: digest, height: runtime.ChainHeight(), }) id.head = digest id.updatedAt = time.Now().Unix() } // SetDisplayName updates the identity's free-form label. // // It carries no signature because it authenticates nothing: the display name // is app metadata, and berty's own link format keeps it outside the signed // payload for the same reason. Only the owning address may change it. func SetDisplayName(cur realm, displayName string) { if !cur.IsCurrent() { panic("spoofed realm") } assertDisplayName(displayName) id := mustOwnIdentity(cur.Previous().Address()) id.displayName = displayName id.updatedAt = time.Now().Unix() } // Release removes the caller's identity and frees its name and account key. // // The sigchain goes with it. That is the honest behaviour: a directory entry // is a live claim, not an archive, and keeping a dangling roster for a name // somebody else can now take would be worse than keeping nothing. func Release(cur realm) { if !cur.IsCurrent() { panic("spoofed realm") } caller := cur.Previous().Address() id := mustOwnIdentity(caller) byName.Remove(id.name) byOwner.Remove(caller.String()) byAccount.Remove(hex.EncodeToString(id.accountPK)) } // --- read-only API, for gnoweb and for other realms --- // Resolve returns the account key and current payload of a name, both hex, and // whether the payload is a commitment rather than a seed. ok is false when the // name is not registered. func Resolve(name string) (accountPKHex, payloadHex string, committed, ok bool) { id := lookup(name) if id == nil { return "", "", false, false } return hex.EncodeToString(id.accountPK), hex.EncodeToString(id.payload), id.committed, true } // Link returns the shareable Berty web link for a name, or "" when the name is // unknown or its rendezvous point is committed rather than published. func Link(name string) string { id := lookup(name) if id == nil || id.committed { return "" } link, err := contactOf(id).WebLink() if err != nil { return "" } return link } // RendezvousPointAt returns the hex rendezvous point the named account // announces on during the rotation period containing unixSec, or "" when the // name is unknown or committed. // // This is what makes a directory entry checkable rather than merely claimed: // anyone can compare it against the DHT without trusting this realm. func RendezvousPointAt(name string, unixSec int64) string { id := lookup(name) if id == nil || id.committed { return "" } point, err := contactOf(id).RendezvousPointAt(unixSec, wesh.DefaultRotationInterval) if err != nil { return "" } return hex.EncodeToString(point) } // DeviceStatus reports "active", "revoked" or "unknown" for a device under a // name. "unknown" also covers an unregistered name: a caller must not be able // to tell those apart by status alone. func DeviceStatus(name, devicePKHex string) string { id := lookup(name) if id == nil { return "unknown" } devicePK, err := wesh.DecodeDevicePK(devicePKHex) if err != nil { return "unknown" } if !deviceSeen(id, devicePK) { return "unknown" } if deviceIsActive(id, devicePK) { return "active" } return "revoked" } // SigchainHead returns the hex digest the next sigchain entry must chain to, // or "" when the name is unknown. A client builds its next statement from this. func SigchainHead(name string) string { id := lookup(name) if id == nil { return "" } return hex.EncodeToString(id.head) } // NameOf returns the handle owned by an address, or "". func NameOf(owner address) string { v := byOwner.Get(owner.String()) if v == nil { return "" } return v.(string) } // Count returns the number of registered identities. func Count() int { return byName.Size() } // --- helpers --- func lookup(name string) *identity { v := byName.Get(normalizeName(name)) if v == nil { return nil } return v.(*identity) } func contactOf(id *identity) wesh.Contact { return wesh.Contact{ AccountPK: id.accountPK, Seed: id.payload, DisplayName: id.displayName, } } func deviceIsActive(id *identity, devicePK []byte) bool { active := false for _, e := range id.devices { if string(e.devicePK) != string(devicePK) { continue } active = e.op == wesh.OpAdd } return active } func deviceSeen(id *identity, devicePK []byte) bool { for _, e := range id.devices { if string(e.devicePK) == string(devicePK) { return true } } return false } func mustOwnIdentity(caller address) *identity { v := byOwner.Get(caller.String()) if v == nil { panic("wesh: no identity registered for this address") } return lookup(v.(string)) } func mustAccountPK(s string) []byte { pk, err := wesh.DecodeAccountPK(s) if err != nil { panic("wesh: account key: " + err.Error()) } return pk } func mustPayload(s string, committed bool) []byte { if committed { c, err := wesh.DecodeCommitment(s) if err != nil { panic("wesh: commitment: " + err.Error()) } return c } seed, err := wesh.DecodeSeed(s) if err != nil { panic("wesh: seed: " + err.Error()) } return seed } func mustSig(s string) []byte { sig, err := hex.DecodeString(s) if err != nil || len(sig) != 64 { panic("wesh: signature must be 64 hex-encoded bytes") } return sig } func assertDisplayName(s string) { if len(s) > wesh.MaxDisplayNameLen { panic("wesh: display name is too long") } } func assertNameAvailable(name string) { if len(name) < MinNameLen || len(name) > MaxNameLen { panic("wesh: name must be between 3 and 32 characters") } for i := 0; i < len(name); i++ { c := name[i] ok := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' if !ok { panic("wesh: name may only contain a-z, 0-9, '-' and '_'") } } if byName.Has(name) { panic("wesh: name is already taken") } } // normalizeName lowercases a handle so lookups are case-insensitive and two // handles cannot differ only by case. func normalizeName(name string) string { return strings.ToLower(strings.TrimSpace(name)) }
  12. #12wesh_test.gno
  13. #13package wesh import ( "testing" "gno.land/p/moul/x/wesh/v0" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // Signature fixtures. Each was produced with Go's crypto/ed25519 over the exact // statement wesh.BindStatement / wesh.DeviceStatement builds, for chain-id // "dev" (what the test VM reports) and the address named in the constant. // Regenerating them means re-signing: that is the point, since the whole // scheme rests on the chain being unable to accept anything unsigned. const ( // alice, g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh aliceAccountPKHex = "2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12" aliceSeed1Hex = "abababababababababababababababababababababababababababababababab" aliceSeed2Hex = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" aliceCommitHex = "c9744f547763321102debcf355a5652208604d53dba6c77e978ee8077ceb472a" aliceBind1Sig = "1279475fc9f7423d13e3eeea96a1714754f74c77abbb5859ba899bd3f820f59d" + "2b0a16f2f6c436cdafd8e20b0d709a5e247a5bbbf4b34df38c1405aba3645905" aliceBind2Sig = "e915b181109936b1ef77ea8e31e512b56ac63a7e690c382e3613df3ef50472ba" + "23233442d789c22ad9401cb882f57be737a67e24db207a9856186eda61fdc108" aliceBind3Sig = "d04f8733f564ee45f141fb71ecb049078a23215b29a14dafeed6c1ced91511ec" + "47b8c3de120f0d23c534c7387e3273e86b50787280ff38a7f1bd31c23ab35f03" aliceCommitBindSig = "d467d806145c228ff7b784658f06f8f05e7fa19354ae0bad3a69b40a1a4cf518" + "ecb3a679674705017c6dfa1bf0bac3e1ec67a4f417c18a80faf77062b8c1e100" // signed by alice's key but naming bob's gno address aliceBindForBobSig = "7bac691e9da577c6474af269b690a7415f07c4a8e4df486deccf824b41061735" + "01cd22af6f1b9654603a866a3a46942ec020714226b01105700eab84cfc08700" // bob, g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu bobAccountPKHex = "332ebe8d27cb7323b3a401c1c13b5dd64bccc0e10ecda1c2b5d11a03779a85e5" bobBind1Sig = "69653e1bef41150eed026b355d5ee9837455e3432003ca19102075078305fdbe" + "b440eedb421056a0e62a182a406cb3394c1225de768dcd114f9d0b9b3501b90f" // alice's device sigchain: add dev1, add dev2, revoke dev1 dev1Hex = "0707070707070707070707070707070707070707070707070707070707070707" dev2Hex = "0909090909090909090909090909090909090909090909090909090909090909" devAdd1Sig = "259abde508b8880dd1f53b236d93b08aa1c7511c4308c2b16da2520d5f553a50" + "67c24876299b6c3e4d9fb88e043dec1978f8d9328f7ac13d80bfdab536893507" devAdd1Digest = "bc51799b5d012dc7ce806b4c63474b1e2db515e7d1622ecd5900b23e1d6a9519" devAdd2Sig = "4799b276bf9b713d84855670198a86de8d38bc2644349a554fe0d4dab5fbea9b" + "7e90aecfe9b7da74345e61f5016361e92e7c0959fe09665a1f20f604015bea00" devAdd2Digest = "b0da03f83e5349e92bc524714862daa850750f756ca6fd47264333fbf2175c15" devRevoke1Sig = "47433475efd00ed87769e7bb748a5af9be77cde56817ea9a1ddd9288533e2bf9" + "b783d85a025a632466ce8dcf637047c932c9d1a15131187213e1ae354e22df02" devRevoke1Digest = "ef0c1d0614420c7d660f8416b18651206118e49e8f46ac0360bef61ae6b9100d" // signed for sequence 5 while the head is still at sequence 1 devWrongSeqSig = "c65d9c48c441bf36dcf01c8c0e46fe73a9f7f8d8edffb07bd05f9ca4a8f875bc" + "6de7ebe30c1d5ae785fde8bfe7b48705605a6c247a62def9b03d37fb62f6f809" genesisDigestHex = "0000000000000000000000000000000000000000000000000000000000000000" ) // reset clears realm globals: gno subtests do not roll them back. func reset() { byName = avl.Tree{} byOwner = avl.Tree{} byAccount = avl.Tree{} } // Two gno-specific rules shape every test below, and both cost a red run to // learn: // // - testing.SetRealm is scoped to the frame that calls it, so it has to be // called in the test body, not in a setUpAlice() helper. // - a helper whose first parameter is `realm` is itself a crossing function, // so routing a call through one makes THIS realm the Previous() the // callee sees, not the user. Crossing calls stay inline. // // Together they mean the two setup lines are repeated rather than factored. func aliceAddr() address { return testutils.TestAddress("alice") } func bobAddr() address { return testutils.TestAddress("bob") } func TestRegister(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "Alice", aliceBind1Sig) uassert.Equal(t, 1, Count()) pk, payload, committed, ok := Resolve("alice") uassert.True(t, ok, "the name resolves") uassert.Equal(t, aliceAccountPKHex, pk) uassert.Equal(t, aliceSeed1Hex, payload) uassert.False(t, committed, "the seed is published, not committed") uassert.Equal(t, "alice", NameOf(aliceAddr())) } // TestRegisterEmitsARealBertyLink is the payoff: the realm hands back a link a // Berty client can actually open. func TestRegisterEmitsARealBertyLink(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "Alice", aliceBind1Sig) uassert.Equal(t, "https://berty.tech/id#contact/oZBLFpzghxrATkepWvDPNX9pHYqi6BWgP45xGWhqxcwmqN2bnMMbU7UcwUuTaCcDyUvMjmWRMDWcP96bXAndcjNiAZ1Vz9X/name=Alice", Link("alice")) // and the rendezvous point anyone can check against the DHT uassert.Equal(t, "aad2926c39dacabaabbbde48522d043557c8945b6a2dc7b08499c112c72587da", RendezvousPointAt("alice", 1789171200)) } func TestRegisterIsCaseInsensitive(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), " ALICE ", aliceAccountPKHex, aliceSeed1Hex, "", aliceBind1Sig) _, _, _, ok := Resolve("Alice") uassert.True(t, ok, "lookup is case-insensitive") uassert.Equal(t, "alice", NameOf(aliceAddr()), "the handle is stored lowercased") } // TestRegisterRejectsAnUnsignedClaim is the attack the binding signature // exists to stop: bob publishing alice's account key next to a seed he // controls, and harvesting the contact requests that follow. func TestRegisterRejectsAnotherAccountsKey(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(bobAddr())) uassert.AbortsContains(t, cur, "signature does not verify", func() { Register(cross(cur), "notalice", aliceAccountPKHex, aliceSeed1Hex, "", aliceBind1Sig) }, "alice's signature does not authorise bob's address") } // TestRegisterRejectsAReplayedBinding: the statement names the gno address, so // a signature harvested from alice's transaction is useless to anyone else. func TestRegisterRejectsAReplayedBinding(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) uassert.AbortsContains(t, cur, "signature does not verify", func() { // this signature names bob's address, alice is calling Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "", aliceBindForBobSig) }) } func TestRegisterRejectsDuplicates(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "Alice", aliceBind1Sig) testing.SetRealm(testing.NewUserRealm(bobAddr())) uassert.AbortsContains(t, cur, "name is already taken", func() { Register(cross(cur), "alice", bobAccountPKHex, aliceSeed1Hex, "", bobBind1Sig) }) uassert.AbortsContains(t, cur, "already published under another name", func() { Register(cross(cur), "alice2", aliceAccountPKHex, aliceSeed1Hex, "", aliceBind1Sig) }, "one Wesh account, one directory entry") testing.SetRealm(testing.NewUserRealm(aliceAddr())) uassert.AbortsContains(t, cur, "already owns an identity", func() { Register(cross(cur), "alice2", aliceAccountPKHex, aliceSeed1Hex, "", aliceBind1Sig) }) } func TestRegisterValidatesInput(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) uassert.AbortsContains(t, cur, "between 3 and 32", func() { Register(cross(cur), "ab", aliceAccountPKHex, aliceSeed1Hex, "", aliceBind1Sig) }) uassert.AbortsContains(t, cur, "a-z, 0-9", func() { Register(cross(cur), "al!ce", aliceAccountPKHex, aliceSeed1Hex, "", aliceBind1Sig) }) uassert.AbortsContains(t, cur, "account key", func() { Register(cross(cur), "alice", "abcd", aliceSeed1Hex, "", aliceBind1Sig) }) uassert.AbortsContains(t, cur, "seed", func() { Register(cross(cur), "alice", aliceAccountPKHex, "abcd", "", aliceBind1Sig) }) uassert.AbortsContains(t, cur, "signature must be 64", func() { Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "", "00") }) } func TestRegisterCommitted(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) RegisterCommitted(cross(cur), "alice", aliceAccountPKHex, aliceCommitHex, "", aliceCommitBindSig) _, payload, committed, ok := Resolve("alice") uassert.True(t, ok) uassert.True(t, committed) uassert.Equal(t, aliceCommitHex, payload) uassert.Equal(t, "", Link("alice"), "a committed identity publishes no link") uassert.Equal(t, "", RendezvousPointAt("alice", 1789171200), "a committed identity does not disclose its rendezvous point") } // TestCommitmentOpensWithTheOutOfBandSeed: the chain attests the binding, and // the seed handed over privately checks against it. func TestCommitmentOpensWithTheOutOfBandSeed(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) RegisterCommitted(cross(cur), "alice", aliceAccountPKHex, aliceCommitHex, "", aliceCommitBindSig) _, payload, _, _ := Resolve("alice") commitment, err := wesh.DecodeCommitment(payload) uassert.NoError(t, err) seed, err := wesh.DecodeSeed(aliceSeed1Hex) uassert.NoError(t, err) uassert.True(t, wesh.OpenCommitment(commitment, seed, []byte("a-16-byte-salt!!")), "the seed shared out of band matches what the chain attested") uassert.False(t, wesh.OpenCommitment(commitment, seed, []byte("wrong-salt!!!!!!"))) } func TestRotate(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "Alice", aliceBind1Sig) Rotate(cross(cur), aliceSeed2Hex, aliceBind2Sig) _, payload, _, _ := Resolve("alice") uassert.Equal(t, aliceSeed2Hex, payload) id := lookup("alice") uassert.Equal(t, 2, id.revision) uassert.Equal(t, 2, len(id.history), "the superseded seed is kept so a stale link is recognisable") uassert.Equal(t, aliceSeed1Hex, hexOf(id.history[0].payload)) } // TestRotateRejectsAReplayedRevision is what a monotonic revision buys: an old // binding cannot be replayed to roll a rotation back. func TestRotateRejectsAReplayedRevision(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "Alice", aliceBind1Sig) Rotate(cross(cur), aliceSeed2Hex, aliceBind2Sig) uassert.AbortsContains(t, cur, "signature does not verify", func() { Rotate(cross(cur), aliceSeed1Hex, aliceBind1Sig) }, "the revision-1 signature is dead once revision 2 exists") // the correctly-signed revision 3 does go back to the first seed Rotate(cross(cur), aliceSeed1Hex, aliceBind3Sig) uassert.Equal(t, 3, lookup("alice").revision) } func TestRotateRejectsANoOp(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "Alice", aliceBind1Sig) uassert.AbortsContains(t, cur, "identical to the current one", func() { Rotate(cross(cur), aliceSeed1Hex, aliceBind2Sig) }) } func TestRotateRequiresAnIdentity(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(bobAddr())) uassert.AbortsContains(t, cur, "no identity registered", func() { Rotate(cross(cur), aliceSeed2Hex, aliceBind2Sig) }) } // TestDeviceSigchain walks the sequence weshnet itself cannot express: // add a device, add a second, then revoke the first. func TestDeviceSigchain(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "Alice", aliceBind1Sig) uassert.Equal(t, genesisDigestHex, SigchainHead("alice"), "an empty chain starts at the genesis digest") uassert.Equal(t, "unknown", DeviceStatus("alice", dev1Hex)) AppendDevice(cross(cur), genesisDigestHex, wesh.OpAdd, dev1Hex, devAdd1Sig) uassert.Equal(t, devAdd1Digest, SigchainHead("alice")) uassert.Equal(t, "active", DeviceStatus("alice", dev1Hex)) AppendDevice(cross(cur), devAdd1Digest, wesh.OpAdd, dev2Hex, devAdd2Sig) uassert.Equal(t, devAdd2Digest, SigchainHead("alice")) AppendDevice(cross(cur), devAdd2Digest, wesh.OpRevoke, dev1Hex, devRevoke1Sig) uassert.Equal(t, devRevoke1Digest, SigchainHead("alice")) uassert.Equal(t, "revoked", DeviceStatus("alice", dev1Hex), "revocation is public, ordered and attributable") uassert.Equal(t, "active", DeviceStatus("alice", dev2Hex)) uassert.Equal(t, 1, activeDevices(lookup("alice"))) } // TestDeviceSigchainRejectsAForkedChain: every entry chains to the digest of // the one before it, so the log cannot be reordered or have an entry dropped. func TestDeviceSigchainRejectsAForkedChain(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "Alice", aliceBind1Sig) AppendDevice(cross(cur), genesisDigestHex, wesh.OpAdd, dev1Hex, devAdd1Sig) uassert.AbortsContains(t, cur, "does not match the sigchain head", func() { AppendDevice(cross(cur), genesisDigestHex, wesh.OpAdd, dev2Hex, devAdd2Sig) }, "the second entry must chain to the first") uassert.AbortsContains(t, cur, "signature does not verify", func() { AppendDevice(cross(cur), devAdd1Digest, wesh.OpAdd, dev2Hex, devWrongSeqSig) }, "a signature made for another sequence number is not accepted at this one") } func TestDeviceSigchainRejectsMeaninglessOps(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "Alice", aliceBind1Sig) AppendDevice(cross(cur), genesisDigestHex, wesh.OpAdd, dev1Hex, devAdd1Sig) uassert.AbortsContains(t, cur, "already active", func() { AppendDevice(cross(cur), devAdd1Digest, wesh.OpAdd, dev1Hex, devAdd1Sig) }) uassert.AbortsContains(t, cur, "not active, nothing to revoke", func() { AppendDevice(cross(cur), devAdd1Digest, wesh.OpRevoke, dev2Hex, devAdd2Sig) }) uassert.AbortsContains(t, cur, "add or revoke", func() { AppendDevice(cross(cur), devAdd1Digest, "delete", dev2Hex, devAdd2Sig) }) uassert.AbortsContains(t, cur, "device key", func() { AppendDevice(cross(cur), devAdd1Digest, wesh.OpAdd, "abcd", devAdd2Sig) }) } func TestSetDisplayName(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "Alice", aliceBind1Sig) SetDisplayName(cross(cur), "Alice, support line") uassert.Equal(t, "Alice, support line", lookup("alice").displayName) testing.SetRealm(testing.NewUserRealm(bobAddr())) uassert.AbortsContains(t, cur, "no identity registered", func() { SetDisplayName(cross(cur), "hijacked") }) } func TestRelease(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(aliceAddr())) Register(cross(cur), "alice", aliceAccountPKHex, aliceSeed1Hex, "Alice", aliceBind1Sig) Release(cross(cur)) uassert.Equal(t, 0, Count()) _, _, _, ok := Resolve("alice") uassert.False(t, ok) uassert.Equal(t, "", NameOf(aliceAddr())) // the name and the account key are both free again testing.SetRealm(testing.NewUserRealm(bobAddr())) Register(cross(cur), "alice", bobAccountPKHex, aliceSeed1Hex, "", bobBind1Sig) uassert.Equal(t, 1, Count()) } func TestReadersOnUnknownNames(cur realm, t *testing.T) { reset() uassert.Equal(t, "", Link("nope")) uassert.Equal(t, "", RendezvousPointAt("nope", 0)) uassert.Equal(t, "", SigchainHead("nope")) uassert.Equal(t, "unknown", DeviceStatus("nope", dev1Hex)) uassert.Equal(t, "unknown", DeviceStatus("nope", "not-hex")) _, _, _, ok := Resolve("nope") uassert.False(t, ok) } func hexOf(b []byte) string { const digits = "0123456789abcdef" out := make([]byte, 0, len(b)*2) for _, c := range b { out = append(out, digits[c>>4], digits[c&0x0f]) } return string(out) }
#25AddPackagegno.land/r/moul/x/wiki/v011 arguments
Attached funds
13000000ugnot

Arguments · 11

  1. #1wiki
  2. #2README.md
  3. #3# `gno.land/r/moul/x/wiki/v0` An open, on-chain encyclopedia. Anyone can create or edit a page, every edit is a signed revision, and the whole history is public and tamper-evident. A thin realm over [`p/moul/x/wiki/v0`](../../../../../p/moul/x/wiki): the library owns content (titles, revisions, wikilinks, categories, diffs, rendering), this realm owns **authority** (who may write what) and the chain wiring (block height, block time, the calling address, transaction links). There is no policy in the library and no content logic here. ## Writing | call | who | |---|---| | `Edit(title, body, summary)` | anyone, subject to the page's protection | | `Revert(title, rev, summary)` | same | | `Comment(title, body, replyTo)` | anyone not banned, whatever the protection | | `HideComment(title, id)` | steward | | `Protect(title, "open"\|"semi"\|"locked")` | steward | | `Move(from, to, reason)` | steward | | `Blank(title, reason)` | steward | | `Purge(title)` | steward | | `AddEditor` / `RemoveEditor` / `Ban` / `Unban` / `SetCooldown` | steward | ```sh gnokey maketx call -pkgpath gno.land/r/moul/x/wiki/v0 -func Edit \ -args 'Gno land' -args 'gno.land is a chain that runs [[Gno]]. [[Category:Chains]] ' -args 'expand the intro' \ -gas-fee 1000000ugnot -gas-wanted 20000000 -broadcast -chainid <id> <key> ``` The body is markdown plus `[[wikilinks]]`, `[[Category:Name]]` and a `#REDIRECT [[Target]]` first line; the syntax table is in the library README. ## Reading | route | page | |---|---| | `` | front page: counts and recent changes | | `Title` | the article | | `Title/history?offset=` | revisions, newest first | | `Title/raw` | current source with its SHA-256 | | `Title/rev/<id>` | one stored revision | | `Title/diff?from=&to=` | a line diff | | `Title/talk?offset=` | the page's discussion | | `Category:Name` | the category and its members | | `Special:AllPages?ns=` · `Special:Categories` · `Special:RecentChanges` · `Special:Backlinks?page=` · `Special:Stats` | listings | `Render` is total: a malformed path returns a page, never an abort. ## Authority One `ownable.Ownable` steward, transferable, so the wiki can be handed to a DAO realm without redeploying. Three protection levels: `open` (anyone), `semi` (an explicit editor list), `locked` (steward only). A page that does not exist yet is open to anyone. **Commenting ignores the protection level on purpose.** Locking an article is how a steward stops an edit war; the discussion is where that war is supposed to move, so a locked page still takes comments. A banned address cannot comment, and the cooldown still applies. On anti-vandalism, the honest version: the storage deposit makes **adding** bytes cost the adder, but the chain refunds released storage to whoever frees it, at the realm's blended rate, not to whoever paid (see the library README, "Who gets the deposit back"). So shrinking a page can pay the editor who does it, and reverting the damage costs the good actor. That is why `Blank`, `Purge` and `HideComment` are steward-gated, and why the ban list, the protection levels and `SetCooldown` are not optional extras. Blanking is the deletion a chain can honestly offer. The page stops rendering and stops costing rent as its bodies age out, while the revision spine stays as proof that something was there and who removed it. `Purge` releases the retained bytes immediately. Neither removes the transactions that wrote the content: on a public chain, "delete" means "stop serving", not "unhappen". ## Cost Storage deposit is 100ugnot per byte, locked while the bytes are held and released when they are removed. A 5 KB article is therefore about 0.5 GNOT of deposit for its current revision, plus roughly 0.02 GNOT for each revision's permanent spine; the default retention window keeps three bodies per page. Every page's footer shows its own held bytes and deposit, and `Special:Stats` shows the wiki's. Retention defaults to 3 rather than 1 so that a revert can reach past two bad edits in a row, which is the common vandalism pattern. Raising it costs linearly more deposit per page. ## Seeded content `init` writes four linked pages (`Gno land`, `Gno`, `Tendermint2`, `Help:Editing`) so a fresh deploy renders a connected wiki instead of an empty index. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/wiki/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/wiki/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4example_test.gno
  5. #5package wiki import "strings" // ExampleRender shows what an article looks like once the library has // rewritten its wikilinks and hoisted its categories into the footer. // // It prints only the structural lines: the header carries a timestamp and the // footer a byte count, and neither is stable across the package's other tests. // The full output is pinned by TestRenderRoutes and by the library's own // render tests. func ExampleRender() { for _, line := range strings.Split(Render("Tendermint2"), "\n") { switch { case strings.HasPrefix(line, "# "), strings.HasPrefix(line, "**Categories:**"), strings.HasPrefix(line, "Tendermint2 is"): print(line + "\n") } } // Output: // # Tendermint2 // Tendermint2 is the consensus engine under [Gno land](/r/moul/x/wiki/v0:Gno_land). // **Categories:** [Chains](/r/moul/x/wiki/v0:Category:Chains) }
  6. #6gnomod.toml
  7. #7module = "gno.land/r/moul/x/wiki/v0" gno = "0.9" private = true
  8. #8wiki.gno
  9. #9// Package wiki is an open, on-chain encyclopedia: anyone can create and edit // a page, every edit is a signed revision, and the whole history is public. // // It is a thin realm over gno.land/p/moul/x/wiki/v0. The library owns content // (titles, revisions, wikilinks, categories, diffs, rendering); this realm // owns authority (who may edit what) and the chain wiring (block height, block // time, the calling address, transaction links). // // The split matters for reading the code: there is no policy in the library // and no content logic here. package wiki import ( "strings" "time" "chain/runtime" "gno.land/p/moul/addrset/v1" "gno.land/p/moul/md/v0" "gno.land/p/moul/realmpath/v0" "gno.land/p/moul/txlink/v0" "gno.land/p/moul/x/wiki/v0" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ownable/v0" "gno.land/p/nt/ufmt/v0" ) // basePath is this realm's render path. It is a constant rather than a lookup // so link building stays a pure string operation, and it is asserted against // the real path by TestBasePathMatchesTheRealm. const basePath = "/r/moul/x/wiki/v0" // steward is the address that starts out able to protect, move, blank and // purge pages. Ownable makes it transferable, so the wiki can be handed to a // DAO realm without redeploying. const steward address = "g1manfred47kzduec920z88wfr64ylksmdcedlf5" // @moul var ( site *wiki.Wiki Ownable *ownable.Ownable // editors may write to semi-protected pages; banned may write nowhere. editors addrset.Set banned addrset.Set // cooldown is the minimum number of blocks between two writes by the same // address, 0 to disable. // // The storage deposit makes ADDING bytes cost the adder, but it does not // make removing them cost anything: the chain refunds released storage to // the caller of the transaction that frees it, at the realm's blended // deposit rate, not to whoever originally paid (processStorageDeposit in // gno.land/pkg/sdk/vm/keeper.go). So replacing a long article with a short // one can pay the editor who does it. Blank and Purge are steward-gated // for that reason, and this cooldown plus the ban list and the protection // levels are what is left for ordinary edits. See #140 Q2. cooldown int64 lastEdit avl.Tree // address -> the height of that address's last edit editCount int ) func init() { Ownable = ownable.NewWithAddress(steward) site = wiki.New(wiki.DefaultRetention, wiki.DefaultMaxBody) seed() } // Edit creates or updates a page and returns the new revision id. func Edit(cur realm, title, body, summary string) int { caller := cur.Previous().Address() assertMayEdit(caller, title) rev, err := site.Edit(caller, time.Now(), runtime.ChainHeight(), title, body, summary, false) if err != nil { panic(err) } noteEdit(caller) return int(rev.ID) } // Revert restores an earlier revision of a page as a new revision. func Revert(cur realm, title string, rev int, summary string) int { caller := cur.Previous().Address() assertMayEdit(caller, title) r, err := site.Revert(caller, time.Now(), runtime.ChainHeight(), title, uint64(rev), summary) if err != nil { panic(err) } noteEdit(caller) return int(r.ID) } // Comment appends a message to a page's discussion and returns its id. Pass // replyTo = 0 for a new thread, or the id of a top-level message to reply to. // // Commenting deliberately ignores the page's protection level: locking an // article is how a steward stops an edit war, and the discussion is where that // war is supposed to move. A banned address still cannot comment, and the // cooldown still applies. func Comment(cur realm, title, body string, replyTo int) int { caller := cur.Previous().Address() assertNotBanned(caller) c, err := site.Comment(caller, time.Now(), runtime.ChainHeight(), title, body, uint64(replyTo)) if err != nil { panic(err) } noteEdit(caller) return int(c.ID) } // HideComment clears one message's body and returns the bytes released. The // message stays in the thread, marked as removed. func HideComment(cur realm, title string, id int) int { Ownable.AssertOwnedBy(cur.Previous().Address()) n, err := site.HideComment(title, uint64(id)) if err != nil { panic(err) } return n } // Protect sets a page's edit gate: "open", "semi" or "locked". func Protect(cur realm, title, level string) { Ownable.AssertOwnedBy(cur.Previous().Address()) if err := site.SetProtection(cur.Previous().Address(), time.Now(), runtime.ChainHeight(), title, level); err != nil { panic(err) } } // Move renames a page, keeping its history and leaving a redirect behind. func Move(cur realm, from, to, reason string) { Ownable.AssertOwnedBy(cur.Previous().Address()) if err := site.Move(cur.Previous().Address(), time.Now(), runtime.ChainHeight(), from, to, reason); err != nil { panic(err) } } // Blank replaces a page's content with a tombstone revision. The history stays. func Blank(cur realm, title, reason string) { Ownable.AssertOwnedBy(cur.Previous().Address()) if _, err := site.Blank(cur.Previous().Address(), time.Now(), runtime.ChainHeight(), title, reason); err != nil { panic(err) } } // Purge drops every body this realm still holds for a page and returns the // number of bytes released. Use it for content that must stop being served // from realm state; it cannot remove the transactions that wrote it. func Purge(cur realm, title string) int { Ownable.AssertOwnedBy(cur.Previous().Address()) n, err := site.Purge(title) if err != nil { panic(err) } return n } // AddEditor lets an address write to semi-protected pages. func AddEditor(cur realm, addr address) { Ownable.AssertOwnedBy(cur.Previous().Address()) editors.Add(addr) } // RemoveEditor revokes semi-protected write access. func RemoveEditor(cur realm, addr address) { Ownable.AssertOwnedBy(cur.Previous().Address()) editors.Remove(addr) } // Ban stops an address from editing anything. func Ban(cur realm, addr address) { Ownable.AssertOwnedBy(cur.Previous().Address()) banned.Add(addr) } // Unban lifts a ban. func Unban(cur realm, addr address) { Ownable.AssertOwnedBy(cur.Previous().Address()) banned.Remove(addr) } // SetCooldown sets the minimum number of blocks between two edits by the same // address; 0 disables it. func SetCooldown(cur realm, blocks int) { Ownable.AssertOwnedBy(cur.Previous().Address()) if blocks < 0 { panic("cooldown must not be negative") } cooldown = int64(blocks) } // assertNotBanned is the floor every write shares: the ban list and the // per-address cooldown. func assertNotBanned(caller address) { if banned.Has(caller) { panic("this address is banned from editing") } if cooldown > 0 { if v := lastEdit.Get(caller.String()); v != nil { if wait := cooldown - (runtime.ChainHeight() - v.(int64)); wait > 0 { panic(ufmt.Sprintf("edit cooldown: wait %d more blocks", wait)) } } } } // assertMayEdit is the whole authority model of this wiki, in one function. func assertMayEdit(caller address, title string) { assertNotBanned(caller) p, err := site.Page(title) if err != nil { return // a page that does not exist yet is open to anyone } switch p.Protection { case wiki.Locked: Ownable.AssertOwnedBy(caller) case wiki.SemiProtected: if !editors.Has(caller) && !Ownable.OwnedBy(caller) { panic("this page is semi-protected: ask a steward for edit access") } } } func noteEdit(caller address) { lastEdit.Set(caller.String(), runtime.ChainHeight()) editCount++ } // ctx is the render context handed to the library on every read. func ctx() wiki.Ctx { return wiki.Ctx{Base: basePath, Exists: site.Exists} } // Render is the whole read surface of the wiki. // // Routes: // // the front page // Title an article // Title/history[?offset=] its revisions, newest first // Title/raw the current source, with its hash // Title/rev/<id> one stored revision // Title/diff?from=&to= a line diff between two revisions // Title/talk[?offset=] the page's discussion // Category:Name a category page and its members // Special:AllPages[?ns=] the page index for a namespace // Special:Categories every category with at least one member // Special:RecentChanges the change feed // Special:Backlinks?page= what links to a page // Special:Stats size and storage cost func Render(path string) string { req := realmpath.Parse(path) c := ctx() first := req.PathPart(0) if first == "" { return wiki.RenderIndex(c, site, 20) } t, err := wiki.ParseTitle(first) if err != nil { return "400: " + err.Error() } if t.NS == wiki.NSSpecial { return renderSpecial(c, t.Name, req) } p, perr := site.PageByTitle(t) // A category renders its members even with no description page of its // own: membership is an index, not a page, so "the page does not exist" // would hide every member. if t.NS == wiki.NSCategory && req.PathPart(1) == "" { if perr != nil { p = nil } return wiki.RenderCategory(c, site, t, p, txlink.Call) } if perr != nil { return wiki.RenderMissing(c, site, t, txlink.Call) } switch req.PathPart(1) { case "": // Follow a redirect only for a bare read, so history and source // always address the page that was asked for. if dest, _, rerr := site.Resolve(first); rerr == nil { p = dest } return wiki.RenderArticle(c, site, p, txlink.Call) case "history": offset := intParam(req.Query.Get("offset"), 0) return wiki.RenderHistory(c, p, offset, 20, txlink.Call) case "raw": if p.Head() == nil { return "404: no revision" } return wiki.RenderRaw(c, p, p.Head()) case "rev": r := p.Revision(uint64(intParam(req.PathPart(2), 0))) if r == nil { return "404: no such revision" } return wiki.RenderRevision(c, p, r) case "talk": offset := intParam(req.Query.Get("offset"), 0) return wiki.RenderTalk(c, site, p, offset, 20, txlink.Call) case "diff": from := p.Revision(uint64(intParam(req.Query.Get("from"), 0))) to := p.Revision(uint64(intParam(req.Query.Get("to"), 0))) if from == nil || to == nil { return "404: no such revision" } return wiki.RenderDiff(c, p, from, to) } return "404: unknown route" } func renderSpecial(c wiki.Ctx, name string, req *realmpath.Request) string { switch strings.ToLower(name) { case "allpages": ns := wiki.Namespace(intParam(req.Query.Get("ns"), 0)) offset := intParam(req.Query.Get("offset"), 0) return wiki.RenderAllPages(c, site, ns, offset, 50) case "categories": return wiki.RenderCategories(c, site) case "recentchanges": return wiki.RenderRecent(c, site, 50) case "backlinks": t, err := wiki.ParseTitle(req.Query.Get("page")) if err != nil { return "400: " + err.Error() } return wiki.RenderBacklinks(c, site, t) case "stats": return wiki.RenderStats(c, site) + md.BulletList([]string{ ufmt.Sprintf("edits since deploy: %d", editCount), ufmt.Sprintf("editors on the semi-protected list: %d", editors.Size()), ufmt.Sprintf("banned addresses: %d", banned.Size()), ufmt.Sprintf("edit cooldown: %d blocks", cooldown), ufmt.Sprintf("max comment length: %d bytes", wiki.MaxCommentLen), "steward: `" + Ownable.Owner().String() + "`", }) } return "404: unknown special page" } // intParam parses a decimal parameter, falling back to def. It never panics: // Render is reached from a URL, and a malformed query must render a page, not // abort the query. func intParam(s string, def int) int { if s == "" { return def } n := 0 for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return def } n = n*10 + int(s[i]-'0') } return n } // seed writes the pages the wiki starts with, so a fresh deploy renders // something a reader can follow instead of an empty index. func seed() { pages := []struct{ title, body, summary string }{ { "Gno land", "gno.land is a smart-contract platform that runs [[Gno]], a deterministic " + "interpretation of Go, on top of [[Tendermint2]].\n\n" + "Realms keep their state as live objects rather than as a key-value blob, " + "which is what lets this wiki store its articles in the contract itself.\n\n" + "[[Category:Chains]]\n", "seed", }, { "Gno", "Gno is the language realms are written in: Go's syntax and semantics, minus " + "the sources of non-determinism a chain cannot tolerate.\n\n" + "See [[Gno land]].\n\n[[Category:Languages]]\n", "seed", }, { "Tendermint2", "Tendermint2 is the consensus engine under [[Gno land]].\n\n[[Category:Chains]]\n", "seed", }, { "Help:Editing", "Anyone may create or edit a page by calling `Edit(title, body, summary)`.\n\n" + "The body is markdown with two additions:\n\n" + "- `[[Target]]` or `[[Target|label]]` links to another page. A link to a " + "page that does not exist yet is marked, and creating that page turns every " + "such link live.\n" + "- `[[Category:Name]]` puts the page in a category instead of rendering inline.\n\n" + "A page whose first line is `#REDIRECT [[Target]]` redirects.\n\n" + "Every page has a discussion thread at `<Title>/talk`, written with " + "`Comment(title, body, replyTo)`. It stays open even when the article " + "itself is locked, because that is where a disagreement should go.\n\n" + "Your transaction locks a storage deposit for the bytes you add, and releases " + "it when they are removed, so the wiki charges the author of a page rather " + "than its readers.\n\n[[Category:Help]]\n", "seed", }, } now := time.Now() h := runtime.ChainHeight() for _, p := range pages { if _, err := site.Edit(steward, now, h, p.title, p.body, p.summary, false); err != nil { panic(err) } } }
  10. #10wiki_test.gno
  11. #11package wiki import ( "strings" "testing" "gno.land/p/moul/x/wiki/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") // carol edits only in TestCooldown: the cooldown is keyed by address and // by block height, and gno's testing.SkipHeights is relative, so a test // that reused an address another test already edited with would see a // zero-height delta and trip the cooldown before it meant to. carol = testutils.TestAddress("carol") ) // TestBasePathMatchesTheRealm pins the constant used to build every link // against the realm's own path. A wrong basePath produces a page whose links // all 404, and nothing else would catch it. func TestBasePathMatchesTheRealm(t *testing.T) { uassert.True(t, strings.HasSuffix(basePath, "/r/moul/x/wiki/v0"), basePath) out := Render("Gno_land") uassert.True(t, strings.Contains(out, "("+basePath+":Tendermint2)"), out) } func TestRenderRoutes(t *testing.T) { cases := []struct { path string want string }{ {"", "# Wiki"}, {"Gno_land", "# Gno land"}, {"gno_land", "# Gno land"}, // titles normalize {"Gno_land/history", "# History of Gno land"}, // {"Gno_land/raw", "# Source of Gno land"}, // {"Gno_land/rev/1", "revision 1"}, // {"Nonexistent_page", "does not exist yet"}, // {"Special:AllPages", "# All pages"}, // {"Special:allpages", "# All pages"}, // case-insensitive {"Special:Categories", "# Categories"}, // {"Special:RecentChanges", "# Recent changes"}, // {"Special:Stats", "# Wiki stats"}, // {"Special:Backlinks?page=Gno", "Pages that link to Gno"}, {"Special:Nope", "404"}, {"Gno_land/nope", "404"}, {"Gno_land/rev/999", "404"}, {"Category:Chains", "Pages in this category"}, {"Gno_land/talk", "# Discussion: Gno land"}, } for _, c := range cases { out := Render(c.path) uassert.True(t, strings.Contains(out, c.want), "Render("+c.path+") should contain "+c.want+", got: "+first80(out)) } } func TestRenderIsTotal(t *testing.T) { // Render is reached from a URL, so malformed input must produce a page, // never an abort that makes the realm unreadable. for _, path := range []string{"Gno|land", "Special:Backlinks?page=", "Gno_land/diff?from=x&to=y", "//"} { uassert.NotEmpty(t, Render(path), path) } } func TestEditAndRevert(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) rev := Edit(cross(cur), "Alice page", "First body.\n", "create") uassert.True(t, rev > 0) testing.SetRealm(testing.NewUserRealm(bob)) Edit(cross(cur), "Alice page", "Vandalized.\n", "oops") uassert.True(t, strings.Contains(Render("Alice_page"), "Vandalized.")) testing.SetRealm(testing.NewUserRealm(alice)) Revert(cross(cur), "Alice page", rev, "rv") out := Render("Alice_page") uassert.True(t, strings.Contains(out, "First body.")) // The vandalism is still in the history: that is the point of a wiki. uassert.True(t, strings.Contains(Render("Alice_page/history"), "oops")) } func TestEditRejectsAnEmptyBody(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "empty title", func() { Edit(cross(cur), "", "body\n", "") }) uassert.AbortsContains(t, cur, "empty body", func() { Edit(cross(cur), "Some page", "", "") }) } func TestBannedAddressCannotEdit(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(steward)) Ban(cross(cur), bob) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "banned", func() { Edit(cross(cur), "Ban test", "body\n", "") }) testing.SetRealm(testing.NewUserRealm(steward)) Unban(cross(cur), bob) testing.SetRealm(testing.NewUserRealm(bob)) uassert.True(t, Edit(cross(cur), "Ban test", "body\n", "") > 0) } func TestProtectionLevels(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Edit(cross(cur), "Protected page", "body\n", "") testing.SetRealm(testing.NewUserRealm(steward)) Protect(cross(cur), "Protected page", "semi") testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "semi-protected", func() { Edit(cross(cur), "Protected page", "sneaky\n", "") }) testing.SetRealm(testing.NewUserRealm(steward)) AddEditor(cross(cur), alice) testing.SetRealm(testing.NewUserRealm(alice)) uassert.True(t, Edit(cross(cur), "Protected page", "allowed\n", "") > 0) testing.SetRealm(testing.NewUserRealm(steward)) Protect(cross(cur), "Protected page", "locked") testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "caller is not owner", func() { Edit(cross(cur), "Protected page", "still sneaky\n", "") }) } func TestStewardOnlyFunctionsRejectOthers(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Edit(cross(cur), "Steward test", "body\n", "") for _, call := range []struct { name string fn func() }{ {"Protect", func() { Protect(cross(cur), "Steward test", "locked") }}, {"Move", func() { Move(cross(cur), "Steward test", "Elsewhere", "") }}, {"Blank", func() { Blank(cross(cur), "Steward test", "") }}, {"Purge", func() { Purge(cross(cur), "Steward test") }}, {"Ban", func() { Ban(cross(cur), bob) }}, {"SetCooldown", func() { SetCooldown(cross(cur), 5) }}, } { testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "caller is not owner", call.fn, call.name) } } func TestCooldown(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(steward)) SetCooldown(cross(cur), 5) testing.SetRealm(testing.NewUserRealm(carol)) Edit(cross(cur), "Cooldown page", "one\n", "") uassert.AbortsContains(t, cur, "cooldown", func() { Edit(cross(cur), "Cooldown page", "two\n", "") }) testing.SkipHeights(6) testing.SetRealm(testing.NewUserRealm(carol)) uassert.True(t, Edit(cross(cur), "Cooldown page", "two\n", "") > 0) testing.SetRealm(testing.NewUserRealm(steward)) SetCooldown(cross(cur), 0) } func TestBlankAndPurgeReleaseBytes(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Edit(cross(cur), "Purge page", "a body worth some bytes\n", "") testing.SetRealm(testing.NewUserRealm(steward)) Blank(cross(cur), "Purge page", "policy") uassert.True(t, strings.Contains(Render("Purge_page"), "was blanked")) testing.SetRealm(testing.NewUserRealm(steward)) released := Purge(cross(cur), "Purge page") uassert.True(t, released > 0, "purging a page with a retained body must release bytes") uassert.True(t, strings.Contains(Render("Purge_page/history"), "body evicted")) } func TestMoveLeavesARedirect(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Edit(cross(cur), "Before move", "the body\n", "") testing.SetRealm(testing.NewUserRealm(steward)) Move(cross(cur), "Before move", "After move", "rename") uassert.True(t, strings.Contains(Render("After_move"), "the body")) // Reading the old title follows the redirect; its history does not. uassert.True(t, strings.Contains(Render("Before_move"), "the body")) uassert.True(t, strings.Contains(Render("Before_move/raw"), "#REDIRECT")) } // TestSeedIsLinkedTogether checks the deployed starting state renders as a // connected wiki rather than four orphans. func TestSeedIsLinkedTogether(t *testing.T) { uassert.True(t, strings.Contains(Render("Gno_land"), "("+basePath+":Gno)")) uassert.True(t, strings.Contains(Render("Special:Backlinks?page=Gno_land"), "[Gno]")) uassert.True(t, strings.Contains(Render("Category:Chains"), "[Gno land]")) } // TestParseTitleIsTheSameOneTheLibraryUses guards against the realm and the // library disagreeing about what a title is, which would make a page // creatable but unreachable. func TestParseTitleIsTheSameOneTheLibraryUses(t *testing.T) { tt := wiki.MustParseTitle("Gno land") uassert.Equal(t, "Gno_land", tt.Slug()) uassert.True(t, strings.Contains(Render(tt.Slug()), "# Gno land")) } func first80(s string) string { if len(s) > 80 { return s[:80] } return s } func TestCommentOnALockedPage(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Edit(cross(cur), "Locked talk", "body\n", "") testing.SetRealm(testing.NewUserRealm(steward)) Protect(cross(cur), "Locked talk", "locked") // Locking an article is how a steward stops an edit war; the discussion // is where that war is supposed to move, so it stays open. testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "caller is not owner", func() { Edit(cross(cur), "Locked talk", "sneaky\n", "") }) testing.SetRealm(testing.NewUserRealm(alice)) id := Comment(cross(cur), "Locked talk", "I disagree, and here is why.\n", 0) uassert.True(t, id > 0) uassert.True(t, strings.Contains(Render("Locked_talk/talk"), "I disagree")) } func TestCommentRepliesAndModeration(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Edit(cross(cur), "Talk target", "body\n", "") root := Comment(cross(cur), "Talk target", "First.\n", 0) testing.SetRealm(testing.NewUserRealm(bob)) Comment(cross(cur), "Talk target", "Replying.\n", root) out := Render("Talk_target/talk") uassert.True(t, strings.Contains(out, "First.")) uassert.True(t, strings.Contains(out, "Replying.")) testing.SetRealm(testing.NewUserRealm(steward)) released := HideComment(cross(cur), "Talk target", root) uassert.True(t, released > 0, "hiding a message releases its bytes") out = Render("Talk_target/talk") uassert.True(t, strings.Contains(out, "removed by a steward")) uassert.False(t, strings.Contains(out, "First.")) } func TestBannedAddressCannotComment(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Edit(cross(cur), "Ban talk", "body\n", "") testing.SetRealm(testing.NewUserRealm(steward)) Ban(cross(cur), bob) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "banned", func() { Comment(cross(cur), "Ban talk", "spam\n", 0) }) testing.SetRealm(testing.NewUserRealm(steward)) Unban(cross(cur), bob) } func TestHideCommentIsStewardOnly(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Edit(cross(cur), "Mod talk", "body\n", "") id := Comment(cross(cur), "Mod talk", "hello\n", 0) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "caller is not owner", func() { HideComment(cross(cur), "Mod talk", id) }) }

Result log

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

← Back to block 272,410