Transaction

8AA8131EF05A5A…D0294038DF8B

Block 407,732 · index 0 · indexed

Summary

Hash
8AA8131EF05A5A21BA8030DF16984877E44BEFDF05119CB95E56D0294038DF8B
Block
407,732
Size
46963 bytes
Gas used
46,218,466 / 133,105,400
Fee
399316ugnot
Status
success

Messages

#1AddPackagegno.land/r/moul/home21 arguments
Attached funds
19000000ugnot

Arguments · 21

  1. #1home
  2. #2README.md
  3. #3# `gno.land/r/moul/home` The realm behind **https://gno.land/u/moul**. A profile page whose content is data, not code: update a paragraph with one small transaction instead of redeploying a realm. ## Why this one has no `/vN` It is the only contract in this repo at an unversioned path, and that is forced, not a slip. gnoweb builds a user profile by calling `Render("")` on the realm at the **exact** path `/r/<username>/home` and embedding the result as the page body ([`gno.land/pkg/gnoweb/handler_http.go`][handler], `GetUserView`). That lookup does no version resolution, so `gno.land/r/moul/home/v0` would never be found. The bare path *is* the interface with gnoweb. Versioning moves inside instead: content lives in slots (below), and the code can be replaced in place because the package is `private`. This is also why `gnopm bump` refuses this package: it looks for a trailing `/vN` on the module line to increment and there is none. The module line stays bare, `gnopm status` and `gnopm verify` accept it, and only `bump` is off the table. A compatibility change here is a redeploy of the same path, not a new version. ### What it replaces: `gno.land/r/moul/home/v0` This directory used to hold a different realm: a hand-maintained dashboard with a todo list, a status string and a meme URL, its state mutable only by the admin and its *shape* only by redeploying. It never reached any network (`contracts.json` had it `uploaded: false` everywhere), so nothing on chain is being replaced, only the source. That version is not deleted, it is **pinned to history** in `gnomod.lock` the way `AGENTS.md` prescribes for a superseded version: ```toml [[module]] module = "gno.land/r/moul/home/v0" source = { commit = "4f2df83869b80470eb81c48a82fdbe82256b8113", dir = "r/moul/home" } ``` So it keeps resolving for anything that imports it, `gnopm sync` materializes it under `.gnopm/`, and it is still linted and tested from there. Read the code at [`r/moul/home` @ 4f2df83][v0], the last commit where this directory held it. [handler]: https://github.com/gnolang/gno/blob/master/gno.land/pkg/gnoweb/handler_http.go [v0]: https://github.com/moul/gno-contracts/tree/4f2df83869b80470eb81c48a82fdbe82256b8113/r/moul/home ## Slots The page is assembled from **slots**: named markdown fragments in an avl tree. | function | what it does | | --- | --- | | `Set(cur, slug, body)` | create or replace one slot, the ordinary update | | `Append(cur, slug, body)` | append, for a body too large for one transaction | | `Delete(cur, slug)` | remove a slot | | `Get(slug)` | read one body | | `Manifest()` | `slug⇥rev⇥len⇥sha256` per slot, the diff surface | | `Revision()` | total writes accepted, so a client can tell "nothing moved" | Writes are restricted to `g1manfred47kzduec920z88wfr64ylksmdcedlf5`. A slug is 1–64 bytes of `[a-z0-9._-]`. No `':'`, so a slug can never break out of its own `:slug:` placeholder. ## The layout is a slot too `Render("")` takes the slot named `layout` as its template and fills every `:slug:` placeholder in it with `p/moul/dynreplacer`. So the shape of the page (headings, order, what appears at all) changes without touching the code: ``` <gno-columns> ![Manfred Touron](https://avatars.githubusercontent.com/u/94029?s=400) <gno-columns-sep /> # Manfred Touron :bio: :social: </gno-columns> ## Packages :packages: ``` `<gno-columns>` / `<gno-columns-sep />` are gnoweb's own extension, not HTML: raw HTML is not rendered, these are parsed. Adding a section is a **content** change, never a code one: the renderer registers one placeholder per slot by iterating the tree, so writing `content/social.md` and referencing `:social:` is the whole of it. ### The slots this page ships with `content/` is the source of truth, one file per slot, the slug being the file name without `.md`. Nothing registers them: `gnohome` reads the directory and the realm iterates the tree. | slot | what it is | | --- | --- | | `bio` | who I am, and what this page is, in the header column | | `social` | the links under it | | `now` | what I am working on, hand-written, and deliberately free of counts | | `stack` | the tools, one line | | `packages` | **generated** from contracts.json on `main`; a PR may not carry it | | `about` | a collapsed `> [!NOTE]-` explaining that this page is a realm | | `layout` | the template all of the above are filled into | `about` is deliberately last and deliberately collapsed. gnoweb renders an alert as `<details>`, and a `-` after the type closes it (`gno.land/pkg/gnoweb/markdown/ext_alert.go`), so the mechanism is one click away instead of occupying the paragraph where a reader decides whether to keep going. ### `layout` may only reference placeholders that are deployed An unmatched placeholder survives into the output verbatim (see below), which makes the layout slot the one file that can be **ahead of the chain in a way that shows**. Rule: before pushing `layout`, every `:slug:` in it is either a file in `content/` or a computed placeholder **the deployed code answers**, which is not the same as one this repo implements. ⚠️ **This is currently true of `layout` itself.** It now contains `:reactions:`, and `reactions.gno` is in this repo but **not in the deployed package**. The live `gno.land/r/moul/home` holds eight files, `README.md` `gnomod.toml` `home.gno` `home_test.gno` `render.gno` `render_example_test.gno` `scan.gno` `scan_test.gno`, and `reactions.gno` is not one of them (read from the chain 2026-09-28 with `gnopie INSPECT gno.land/r/moul/home -all`). So `layout` must not be pushed on its own. It goes out with the redeploy that carries `reactions.gno`, which also wipes every slot and is therefore followed by `gnohome tx -all` anyway. That same file list settles the older half of this warning: `scan.gno` **is** deployed, so `:scan.links:` resolves on the live page and the redeploy it was waiting for has happened. Do not re-add it here from memory; the file list is the check. The redeploy's one new dependency, [`r/moul/reactions/v0`](../../../r/moul/reactions), is not on chain yet and has to be published first. After that what is left is this realm's own `MsgAddPackage`, which no account session may sign. `gnohome status` is the check, and `tools/gnohome/scan.go` makes `preview` render these links rather than showing a literal `:scan.links:`, so the page can be judged before any of it is signed. ### Images: two gates, and neither is the one you expect An image in a slot passes **gnoweb's validator** and then the **CSP the site is served behind**. They block different things, and only the second is a domain list: - gnoweb's `AllowSvgDataImage` (`gno.land/pkg/gnoweb/render_config.go`, wired in `markdown/ext_imgvalidator.go`) rejects every `data:` URI that is not `image/svg+xml`, and blanks the `src` rather than dropping the tag. Ordinary `https://` URLs are not checked at all. - The live `content-security-policy` header on gno.land pins `img-src` to `'self' data:` plus a fixed host list: `*.githubusercontent.com`, `*.github.io`, `github.com`, `imgur.com`, `*.imgur.com`, `assets.gnoteam.com`, `sa.gno.services`, `gnolang.github.io`, `ipfs.io`, `cloudflare-ipfs.com` (read 2026-09-19). Anything else is silently not painted by the browser, with the HTML looking perfectly fine. So a GitHub avatar needs no hosting of its own: `https://avatars.githubusercontent.com/u/94029?s=400` matches `*.githubusercontent.com` and renders as-is. Verified by running this exact page through gnoweb's real goldmark pipeline, not by reading the policy. Twelve placeholders are computed at render time rather than stored, and are refused as slot names so nothing can shadow them. Six come straight from chain state: `:owner:` `:realm:` `:chainid:` `:height:` `:rev:` `:slots:`. Five are explorer links, built by [`p/moul/mygnoscan`](../../../p/moul/mygnoscan) (`scan.gno`): `:scan:` is the explorer base URL as plain text, `:scan.realm:` `:scan.me:` `:scan.block:` are markdown links to this realm, to the owner's account and to the block being rendered, and `:scan.links:` is the three of them on one line. One is the reaction block: `:reactions:` renders the embeddable widget from [`r/moul/reactions`](../../../r/moul/reactions), keyed on this realm's path. It is the only thing on this page a reader can act on, and this realm stores nothing for it: the tallies live in that realm, so the redeploy that wipes every slot here does not touch anyone's reaction. See `reactions.gno`. Which explorer the scan links point at is read from [`r/moul/config`](../../../r/moul/config), not hardcoded here, so moving every one of moul's realms to a different instance is one transaction against that realm rather than a redeploy of each. With nothing configured, they fall back to `mygnoscan.DefaultBase`, which is what a fresh chain renders. Three properties worth knowing: - **Lazy.** dynreplacer only invokes the callbacks whose placeholder actually occurs in the layout, so an unused slot is never read out of the tree. - **Single-pass.** A placeholder inside a slot *body* is left alone. No slot can expand into another, so no cycle exists. - **Order-independent.** Every placeholder is `:slug:` and a slug cannot contain `':'`, so no placeholder is a prefix of another and the replacer has no ambiguity to resolve. An unmatched placeholder survives into the output verbatim. That is deliberate: a missing section should be visible, not silently blank. ### Other render paths - `:slots`: the slot index (name, size, revision, height of last write) - `:slots/<slug>`: one slot's raw markdown, fenced ## Why `private` `gnomod.toml` declares `private = true`. On gno.land that means two things: 1. **No other realm may import this one.** Fine for a profile page. 2. **The creator may re-add the package at this path.** `AddPackage` waives its already-exists refusal for a private package and binds the replacement to the address in `[addpkg].creator`. ⚠️ **A redeploy re-runs `init()` and resets all realm state.** The slots are gone. That is why `content/` is the source of truth and why `gnohome tx -all` exists: after a redeploy, push every slot back. The intended path is that this never happens. Slots cover content and the layout slot covers presentation, so the code should not need to change. ## Local workflow `tools/gnohome` is the local half: it builds the slots from `content/*.md`, renders the page offline exactly as the realm would, diffs against the chain, and prints the `gnokey` commands for what is outdated, nothing else. ```sh go -C tools tool gnohome preview # see the page before anyone else does go -C tools tool gnohome status # what differs from the chain go -C tools tool gnohome tx # the commands to fix that ``` ⚠️ **`gnohome preview` is the only preview of this realm that means anything.** The CI preview link deploys the package to a fresh dev chain, where there are no slots and `content/` never travels (the uploader skips sub-directories), so it always renders `defaultLayout` and is identical on every content-only PR. Confirmed on PR #225, whose preview reads "No layout slot yet" while the change was six slots. The bot cannot know this and links it anyway. The `packages` slot is generated, not written: it is a claim about what is deployed, and `contracts.json` already tracks that per network. ```sh go -C tools tool gnohome packages > r/moul/home/content/packages.md ``` See [`tools/gnohome/README.md`](../../../tools/gnohome/README.md). ## Deploying The realm **is live on mainnet** (`gnoland-1`), serving https://gno.land/u/moul. What follows applies to a redeploy, or to a first deploy on another network. ⚠️ A redeploy of this path wipes every slot, so it is always followed by `gnohome tx -all`. `Set` needs the package on chain first, and a `private` package still needs `MsgAddPackage`, which no account session can sign. **mainnet deploys in two phases.** `gnoland-1` runs `vm:p:code_submission_policy = "inert"` (read back from the chain 2026-09-19), so `MsgAddPackage` **parks** the bytes under `inert_pkg:<path>` and returns `success: true` without making the package live. `Render` still answers `package not found`, `/u/moul` is still blank, and `gnohome tx` cannot land a single slot until an approver in `vm:p:pkg_approvers` sends `MsgEnablePackage`. In practice that gate is an oracle, not a queue: `g1yaaa6rcp4ew5yjzdj4yms596wx2dtrj3a86704` enabled the four most recent parked packages after 1 to 4 blocks, 3.3 to 13.2 seconds. It can still refuse, and a refusal is easy to miss, so check rather than assume. ### The whole plan, from gnopm Deploying is not special to this realm, so it is not this realm's tool that does it. `gnopm publish` reads the chain, reports whether the package is live, parked or absent, checks that every non-test dependency is already up, sizes gas, fee and deposit from the real payload, and writes the `gnokey` commands. It never signs. ```sh gnopm publish -key moul moul/home # read the report, read the script gnopm publish -key moul moul/home > /tmp/deploy.sh && sh /tmp/deploy.sh ``` Then the content, once the realm answers: ```sh go -C tools tool gnohome tx -all -batch /tmp/home.tx.json sh /tmp/home.tx.sh ``` That writes one transaction holding every slot and prints the `gnokey sign` and `gnokey broadcast` to run: one passphrase instead of one per slot, and atomic. **Never pipe these into `sh`**: `gnokey` reads the passphrase from stdin and a pipe takes stdin away, so the prompt fails with `inappropriate ioctl for device`. Run those rather than copying commands from here: anything written down goes stale, and the tools recompute from the files as they are. ### How it sizes them Deliberately no byte count here. This README **is part of the payload**, so any figure quoted in it invalidates itself the moment the file is edited. The tool reads the real numbers off the files; what follows is only the method, so a reader can judge it. **What travels.** `gnokey maketx addpkg` uploads with `MPUserAll` (`gno.land/pkg/keyscli/addpkg.go`), so every `.gno`, `.toml` and `.md` in the package directory goes on chain, **test files and this README included**. Sub-directories are skipped, which is why `content/` never ships. The README is usually the single largest file, and you pay gas and storage on it. **`-gas-wanted`, at 1,800 gas per uploaded byte.** Ten successful mainnet `add_package` transactions above h160000 cost **1,014 to 1,781 gas/byte** (median 1,393, measured 2026-09-19). The spread is the package's own `init()` work, which a byte count cannot see, so size from the top of the range. It is a ceiling and a ceiling is not charged. **`-gas-fee`, at ten times the accepted floor.** What the mempool enforces is the `gas_fee / gas_wanted` **ratio**, not the absolute (`EnsureSufficientMempoolFees`), so raising the ceiling raises the required fee and headroom is not free. The lowest ratio accepted on mainnet is **0.001 ugnot/gas**; comparable `add_package` transactions paid 0.001 to 0.00125. The tool offers 0.01. **`gas_fee` is deducted in full as offered and never refunded**, so over-offering is a real cost and not insurance: the flat `1000000ugnot` this tooling used to emit for a slot write was about ninety times the floor. **`-max-deposit`, an explicit ceiling.** Omitting it is not opting out: it falls back to `vm:p:default_deposit`, **100 GNOT of ceiling per message**. Storage locks 100ugnot per byte. Unlike the gas fee this one is **refundable** and only the measured byte delta is ever locked, so headroom here costs nothing. Re-measure before a later redeploy: gas is a function of the code, and both the gas price and the submission policy are chain parameters. <!-- 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/home dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/home/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/r/moul/home" gno = "0.9" # Redeployable by its creator. See README.md § "Why private". # # A private package may be re-added at the same path by the address recorded in # [addpkg].creator (gno.land/pkg/sdk/vm/keeper.go, checkRedeployPermission), and # nothing else may import it. A redeploy RE-RUNS init() and resets realm state, # so content/ is the source of truth and tools/gnohome pushes the slots back. private = true
  6. #6home.gno
  7. #7// Package home is the realm behind https://gno.land/u/moul. // // gnoweb builds a user profile page by calling Render("") on the realm at the // exact path /r/<username>/home (gno.land/pkg/gnoweb/handler_http.go, // GetUserView) and embedding the result as the profile body. There is no // version resolution in that lookup, which is why this realm (alone in this // repo) carries no /vN suffix: the bare path IS the interface with gnoweb, // and gno.land/r/moul/home/v0 would never be found. // // # Slots // // The page is not hard-coded. It is assembled from SLOTS: named markdown // fragments in an avl tree, each written by its own Set call, so updating one // paragraph is one small transaction instead of a redeploy. // // The layout is itself a slot ("layout"), so the shape of the page (headings, // order, what appears at all) changes without touching the code. // p/moul/dynreplacer fills every :slug: placeholder found in the layout, lazily: // a slot whose placeholder is absent from the layout costs nothing to render. // // Substitution is SINGLE-PASS and non-recursive. A placeholder inside a slot // body is left alone, so no slot can expand into another and no cycle exists. // Two registered placeholders can never be prefixes of one another either // (every one is :slug: and a slug may not contain ':'), so the result does not // depend on registration order. // // A placeholder with no matching slot survives into the output verbatim. That // is deliberate: a missing section should be visible, not silently blank. // // # Versioning // // gnomod.toml declares private = true, which lets the creator re-add the // package at this path. That redeploy re-runs init() and RESETS everything // here, so the markdown under content/ is the source of truth and // tools/gnohome pushes the slots back afterwards. The intended path is that this // never happens: slots cover content, and the layout slot covers presentation. package home import ( "crypto/sha256" "encoding/hex" "strconv" "strings" "gno.land/p/nt/avl/v0" ) // admin is the only address allowed to write. Deliberately a constant and not // transferable ownership: this realm is one person's profile page, and the // same address is the only one the chain lets redeploy it. const admin = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5") // layoutSlug names the slot used as the page template. const layoutSlug = "layout" // realmPath is this realm's own path. Written out rather than read from // unsafe.CurrentRealm() because it is also the interface with gnoweb (see the // package doc): the constant is the thing that must not drift, so it is stated // once and every other use points here. const realmPath = "gno.land/r/moul/home" // maxSlugLen bounds a slug so an index row stays readable and a key stays cheap. const maxSlugLen = 64 var ( slots = avl.NewTree() // slug -> *slot rev int // total number of writes, bumped by every mutation ) type slot struct { body string rev int // rev at the time of the last write to this slot updated int64 // block height of the last write } // reservedSlugs are placeholder names computed at render time from chain state. // A slot may not take one, because it would shadow the computed value and make // the page depend on callback registration order. // // The scan.* half comes from scan.gno's scanPlaceholders and the :reactions: // one from reactions.gno, so adding a placeholder in either file reserves it // here and cannot be forgotten. var reservedSlugs = append( append( []string{"chainid", "height", "owner", "realm", "rev", "slots"}, scanPlaceholders..., ), reactionsPlaceholders..., ) func assertAdmin(cur realm) { if cur.Previous().Address() != admin { panic("restricted to admin") } } // validSlug reports whether slug is a legal slot name: 1..maxSlugLen bytes of // [a-z0-9._-]. The character set excludes ':' so a slug can never break out of // its own :slug: placeholder, and excludes uppercase so a slot has one name. func validSlug(slug string) bool { if len(slug) == 0 || len(slug) > maxSlugLen { return false } for i := 0; i < len(slug); i++ { c := slug[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9': case c == '-', c == '_', c == '.': default: return false } } return true } func assertWritableSlug(slug string) { if !validSlug(slug) { panic("invalid slug: want 1-" + strconv.Itoa(maxSlugLen) + " bytes of [a-z0-9._-], got " + strconv.Quote(slug)) } for _, r := range reservedSlugs { if slug == r { panic("reserved slug: " + slug + " is computed at render time") } } } // Set creates or replaces the slot named slug. This is the ordinary update: one // slot, one transaction. Passing the empty body keeps the slot but empties it; // use Delete to remove it. func Set(cur realm, slug, body string) { assertAdmin(cur) assertWritableSlug(slug) rev++ slots.Set(slug, &slot{body: body, rev: rev, updated: chainHeight()}) } // Append adds to the end of a slot, creating it when absent. It exists for // content too large to fit in one transaction: Set the first chunk, Append the // rest. Ordinary updates should use Set, which is idempotent. func Append(cur realm, slug, body string) { assertAdmin(cur) assertWritableSlug(slug) rev++ prev := "" if v := slots.Get(slug); v != nil { prev = v.(*slot).body } slots.Set(slug, &slot{body: prev + body, rev: rev, updated: chainHeight()}) } // Delete removes a slot. Deleting the layout slot restores the built-in // default layout. func Delete(cur realm, slug string) { assertAdmin(cur) if _, removed := slots.Remove(slug); !removed { panic("no such slot: " + slug) } rev++ } // Get returns a slot body, or "" when the slot does not exist. func Get(slug string) string { v := slots.Get(slug) if v == nil { return "" } return v.(*slot).body } // Revision returns the total number of writes this realm has accepted. It // changes on every mutation, so a client can cheaply tell "nothing moved". func Revision() int { return rev } // Manifest returns one tab-separated line per slot: // // <slug>\t<rev>\t<len>\t<sha256 of body, hex> // // It is the diff surface tools/gnohome queries with vm/qeval: one read tells it // exactly which local file is out of date, without downloading any body. func Manifest() string { var b strings.Builder slots.Iterate("", "", func(key string, value any) bool { s := value.(*slot) sum := sha256.Sum256([]byte(s.body)) b.WriteString(key) b.WriteString("\t") b.WriteString(strconv.Itoa(s.rev)) b.WriteString("\t") b.WriteString(strconv.Itoa(len(s.body))) b.WriteString("\t") b.WriteString(hex.EncodeToString(sum[:])) b.WriteString("\n") return false }) return b.String() }
  8. #8home_test.gno
  9. #9package home import ( "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var stranger = testutils.TestAddress("mallory") // reset clears realm state. Realm globals live for the whole test binary, so // every test that reads Render or Manifest starts from a known tree. func reset() { slots = avl.NewTree() rev = 0 } // seed writes a slot without going through the admin check, for tests (and the // Example) that only care about the rendering side. func seed(slug, body string) { rev++ slots.Set(slug, &slot{body: body, rev: rev, updated: 0}) } func TestSetGetDelete(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(admin)) Set(cross(cur), "bio", "Building on gno.") uassert.Equal(t, "Building on gno.", Get("bio")) uassert.Equal(t, 1, Revision()) // Set is idempotent in shape: a second write replaces, never appends. Set(cross(cur), "bio", "Still building.") uassert.Equal(t, "Still building.", Get("bio")) uassert.Equal(t, 2, Revision()) Delete(cross(cur), "bio") uassert.Equal(t, "", Get("bio"), "a deleted slot reads as empty") uassert.Equal(t, 3, Revision()) uassert.AbortsWithMessage(t, cur, "no such slot: bio", func() { Delete(cross(cur), "bio") }) } func TestAppendChunks(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(admin)) // The escape hatch for a body too large for one transaction. Set(cross(cur), "long", "part one. ") Append(cross(cur), "long", "part two.") uassert.Equal(t, "part one. part two.", Get("long")) // Append creates the slot when it is absent. Append(cross(cur), "fresh", "hello") uassert.Equal(t, "hello", Get("fresh")) } func TestWritesAreAdminOnly(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(stranger)) uassert.AbortsWithMessage(t, cur, "restricted to admin", func() { Set(cross(cur), "bio", "pwned") }) uassert.AbortsWithMessage(t, cur, "restricted to admin", func() { Append(cross(cur), "bio", "pwned") }) uassert.AbortsWithMessage(t, cur, "restricted to admin", func() { Delete(cross(cur), "bio") }) uassert.Equal(t, "", Get("bio")) } func TestSlugRules(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(admin)) uassert.True(t, validSlug("bio")) uassert.True(t, validSlug("now.2026-09")) uassert.True(t, validSlug("latest_packages")) uassert.False(t, validSlug(""), "empty") uassert.False(t, validSlug("Bio"), "uppercase: a slot must have one name") uassert.False(t, validSlug("a b"), "space") uassert.False(t, validSlug("a:b"), "a colon would break out of the :slug: placeholder") uassert.False(t, validSlug(strings.Repeat("a", maxSlugLen+1)), "too long") uassert.AbortsContains(t, cur, "invalid slug", func() { Set(cross(cur), "Bad Slug", "x") }) // A reserved name would shadow a value computed from chain state. for _, name := range reservedSlugs { uassert.AbortsContains(t, cur, "reserved slug", func() { Set(cross(cur), name, "x") }) } // "layout" is special but NOT reserved: it is a real, writable slot. Set(cross(cur), layoutSlug, "# hi") uassert.Equal(t, "# hi", Get(layoutSlug)) } func TestManifestIsTheDiffSurface(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(admin)) Set(cross(cur), "bio", "hello") Set(cross(cur), "alpha", "") // Sorted by slug (avl order), one line each, four tab-separated fields. lines := strings.Split(strings.TrimSuffix(Manifest(), "\n"), "\n") uassert.Equal(t, 2, len(lines)) alpha := strings.Split(lines[0], "\t") uassert.Equal(t, 4, len(alpha)) uassert.Equal(t, "alpha", alpha[0]) uassert.Equal(t, "0", alpha[2], "byte length of an empty body") // sha256(""), pinning that the hash is over the body, not over a wrapper. uassert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", alpha[3]) bio := strings.Split(lines[1], "\t") uassert.Equal(t, "bio", bio[0]) uassert.Equal(t, "5", bio[2]) // sha256("hello") uassert.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", bio[3]) } func TestRenderFillsTheLayoutSlot(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(admin)) Set(cross(cur), layoutSlug, "# :owner:\n\n:bio:\n\n:missing:\n") Set(cross(cur), "bio", "Building on gno.") out := Render("") uassert.True(t, strings.Contains(out, "# "+admin.String()), "a computed placeholder is filled from chain state") uassert.True(t, strings.Contains(out, "Building on gno."), "a slot placeholder is filled from the tree") uassert.True(t, strings.Contains(out, ":missing:"), "an unmatched placeholder survives verbatim, so a gap is visible") } func TestRenderDoesNotRecurse(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(admin)) // A slot body that looks like a placeholder must not expand: substitution // is one pass, which is what makes cycles impossible. Set(cross(cur), layoutSlug, ":a:") Set(cross(cur), "a", ":b:") Set(cross(cur), "b", "should not appear") uassert.Equal(t, ":b:", Render("")) } func TestRenderUnsetLayoutFallsBack(t *testing.T) { reset() out := Render("") uassert.True(t, strings.Contains(out, "No layout slot yet")) uassert.False(t, strings.Contains(out, ":slots:"), "the default layout must not leave its own placeholders unresolved") uassert.False(t, strings.Contains(out, ":rev:")) uassert.False(t, strings.Contains(out, ":height:")) uassert.False(t, strings.Contains(out, ":chainid:")) } func TestRenderSubPaths(t *testing.T) { reset() seed("bio", "Building on gno.") idx := Render("slots") uassert.True(t, strings.Contains(idx, "| [bio](/r/moul/home:slots/bio) |")) raw := Render("slots/bio") uassert.True(t, strings.Contains(raw, "Building on gno.")) uassert.True(t, strings.Contains(raw, "16 bytes")) uassert.True(t, strings.Contains(Render("slots/nope"), "No slot named")) uassert.True(t, strings.Contains(Render("whatever"), "No such path")) } func TestRenderEmptyIndex(t *testing.T) { reset() uassert.True(t, strings.Contains(Render("slots"), "_no slots yet_")) }
  10. #10reactions.gno
  11. #11package home import ( "gno.land/p/moul/dynreplacer/v0" "gno.land/r/moul/reactions/v0" ) // This file is the one interactive thing on the page: a :reactions: placeholder // that renders the reaction block from gno.land/r/moul/reactions, keyed on this // realm's path. // // Nothing is stored here. The tallies live in the reactions realm, which is why // this realm can be redeployed (and wiped, see the package doc) without losing // anyone's reaction, and why adding this cost one placeholder rather than a // state machine. // // The block renders its own txlinks, so a reader clicks an emoji here and lands // on the reactions realm's $help form with the page prefilled. This realm has // no write path of its own for it and does not want one: the point of the // indirection is that the same block behaves identically on every page that // embeds it. // reactionsPlaceholders are the reserved slugs this file answers for. Listed in // reservedSlugs (home.gno) so a slot can never take one of these names and // shadow the computed value. var reactionsPlaceholders = []string{"reactions"} // registerReactionCallbacks adds the reaction placeholder to r. // // dynreplacer only invokes a callback whose placeholder actually occurs in the // layout, so a layout that does not use :reactions: never calls into the // reactions realm at all. // // RenderBlockFor is used rather than the zero-argument RenderBlock, for the // same reason config.TopBlockFor is (see render.gno): the zero-argument form // reads the caller off the stack, which would be correct here, but this realm // already states its own path as a constant and passing it cannot be wrong. func registerReactionCallbacks(r *dynreplacer.Replacer) { r.RegisterCallback(":reactions:", func() string { return reactions.RenderBlockFor(realmPath) }) }
  12. #12reactions_test.gno
  13. #13package home import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) // TestReactionBlockRenders exercises the cross-realm call itself, which is the // only thing that can find the failure mode this realm is exposed to: it is // private = true, and a private realm that hands one of its own objects to // another realm panics at RUNTIME with "cannot persist object from the private // realm", where gno lint sees nothing (AGENTS.md, ADR 0044). Only strings cross // here, in both directions, and this test is what says so. func TestReactionBlockRenders(t *testing.T) { reset() seed("layout", "# page\n\n:reactions:\n") got := Render("") uassert.True(t, strings.Contains(got, "(/r/moul/reactions/v0$help&func=React&"), "the block's txlinks target the reactions realm") uassert.True(t, strings.Contains(got, "page=gno.land%2Fr%2Fmoul%2Fhome"), "keyed on this realm's path, which is what makes the tally this page's") uassert.True(t, strings.Contains(got, "*no reactions yet*")) } // TestReactionsIsAReservedSlug: the placeholder is computed, so a slot may not // take its name and shadow it. gnohome refuses the same name off chain, before // the gas is spent (tools/gnohome/content.go). func TestReactionsIsAReservedSlug(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(admin)) uassert.AbortsWithMessage(t, cur, "reserved slug: reactions is computed at render time", func() { Set(cross(cur), "reactions", "nope") }) } // TestLayoutWithoutTheBlockCallsNothing is the property that makes the // placeholder free: dynreplacer only invokes a callback whose placeholder // occurs in the layout, so a layout that drops :reactions: does not reach into // the reactions realm at all. func TestLayoutWithoutTheBlockCallsNothing(t *testing.T) { reset() seed("layout", "# page\n\nnothing here\n") uassert.Equal(t, "# page\n\nnothing here\n", Render("")) }
  14. #14render.gno
  15. #15package home import ( "strconv" "strings" "chain/runtime" "gno.land/p/moul/dynreplacer/v0" "gno.land/r/moul/config/v1" ) // defaultLayout is what renders before a layout slot exists, which is not only // the very first deploy: a redeploy re-runs init() and wipes every slot, so // this is also the page at the exact moment someone is restoring it. It uses // only computed placeholders, so it never shows an unresolved :slug: of its own. // // It prints no gnokey command and no gas numbers, on purpose. The one it used // to print offered 1000000ugnot against 20000000 gas: a ratio of 0.05 against a // mainnet floor of 0.001, and gas_fee is deducted in full as offered and never // refunded, so it asked for about fifty times what the write costs. The tooling // was corrected and this string was not, which is the argument against having // it at all. A command baked into a realm cannot be fixed without a redeploy, // and the redeploy is the thing that wipes the slots. gnohome recomputes it // from content/ and from the chain, so it cannot go stale. const defaultLayout = "# gno.land/r/moul/home\n\n" + "No layout slot yet. A redeploy wipes every slot and `content/` is the source\n" + "of truth, so push them back with the tool rather than by hand:\n\n" + " go -C tools tool gnohome tx -all\n\n" + "No gas numbers here on purpose: a command written into a realm goes stale and\n" + "cannot be corrected without another redeploy.\n\n" + "## Slots\n\n:slots:\n\n---\n\nrev :rev: · height :height: · :chainid:\n" func chainHeight() int64 { return runtime.ChainHeight() } // Render serves three views: // // "" the assembled page, what https://gno.land/u/moul shows // "slots" the slot index: name, size, revision, last write // "slots/<slug>" one slot's raw markdown, fenced func Render(path string) string { switch { case path == "": return renderPage() case path == "slots": return renderIndex() case strings.HasPrefix(path, "slots/"): return renderSlot(strings.TrimPrefix(path, "slots/")) default: return "# Not found\n\nNo such path: " + strconv.Quote(path) + "\n\nTry [the slot index](/r/moul/home:slots).\n" } } // renderPage fills the layout. dynreplacer only invokes the callbacks whose // placeholder actually occurs in the layout, so an unused slot is never even // read out of the tree. func renderPage() string { layout := Get(layoutSlug) if layout == "" { layout = defaultLayout } r := dynreplacer.New() // Computed from chain state. Registered first; assertWritableSlug keeps a // slot from ever taking one of these names, so nothing shadows them. r.RegisterCallback(":owner:", func() string { return admin.String() }) r.RegisterCallback(":realm:", func() string { return realmPath }) r.RegisterCallback(":chainid:", func() string { return runtime.ChainID() }) r.RegisterCallback(":height:", func() string { return strconv.FormatInt(runtime.ChainHeight(), 10) }) r.RegisterCallback(":rev:", func() string { return strconv.Itoa(rev) }) r.RegisterCallback(":slots:", func() string { return slotLinks() }) // The explorer links, which are computed the same way but come from // another realm's configuration. See scan.gno. registerScanCallbacks(r) // The reaction block, which is the one thing on this page a reader can // act on. See reactions.gno. registerReactionCallbacks(r) // One callback per slot. The closure captures s, which is re-bound on each // iteration, so every callback sees its own slot. slots.Iterate("", "", func(key string, value any) bool { s := value.(*slot) r.RegisterCallback(":"+key+":", func() string { return s.body }) return false }) // The notice blocks, from gno.land/r/moul/config: a warning, a changelog // line or a bit of news, set either on every realm of moul's at once or on // this one alone, and empty by default. A pause banner rides along at the // top, so the page explains itself if this realm is ever held back. // // The path is passed explicitly rather than using config's zero-argument // TopBlock(). That form reads the caller off the stack, which is correct // here and is covered by config's own tests, but this realm's Render IS // gno.land/u/moul and it already states its own path: passing the constant // cannot be wrong, at no cost. return config.TopBlockFor(realmPath) + r.Replace(layout) + config.BottomBlockFor(realmPath) } // slotLinks is the :slots: placeholder: a bullet list of every slot, linking to // its raw view. func slotLinks() string { if slots.Size() == 0 { return "_no slots yet_" } var b strings.Builder slots.Iterate("", "", func(key string, value any) bool { b.WriteString("- [") b.WriteString(key) b.WriteString("](/r/moul/home:slots/") b.WriteString(key) b.WriteString(")\n") return false }) return strings.TrimSuffix(b.String(), "\n") } func renderIndex() string { var b strings.Builder b.WriteString("# Slots\n\n") b.WriteString("rev ") b.WriteString(strconv.Itoa(rev)) b.WriteString(" · ") b.WriteString(strconv.Itoa(slots.Size())) b.WriteString(" slot(s)\n\n") if slots.Size() == 0 { b.WriteString("_no slots yet_\n") return b.String() } b.WriteString("| slot | bytes | rev | height |\n") b.WriteString("| --- | ---: | ---: | ---: |\n") slots.Iterate("", "", func(key string, value any) bool { s := value.(*slot) b.WriteString("| [") b.WriteString(key) b.WriteString("](/r/moul/home:slots/") b.WriteString(key) b.WriteString(") | ") b.WriteString(strconv.Itoa(len(s.body))) b.WriteString(" | ") b.WriteString(strconv.Itoa(s.rev)) b.WriteString(" | ") b.WriteString(strconv.FormatInt(s.updated, 10)) b.WriteString(" |\n") return false }) return b.String() } func renderSlot(slug string) string { v := slots.Get(slug) if v == nil { return "# Not found\n\nNo slot named " + strconv.Quote(slug) + ".\n\nTry [the slot index](/r/moul/home:slots).\n" } s := v.(*slot) var b strings.Builder b.WriteString("# ") b.WriteString(slug) b.WriteString("\n\n") b.WriteString(strconv.Itoa(len(s.body))) b.WriteString(" bytes · rev ") b.WriteString(strconv.Itoa(s.rev)) b.WriteString(" · written at height ") b.WriteString(strconv.FormatInt(s.updated, 10)) b.WriteString("\n\n```\n") b.WriteString(s.body) if !strings.HasSuffix(s.body, "\n") { b.WriteString("\n") } b.WriteString("```\n") return b.String() }
  16. #16render_example_test.gno
  17. #17package home // ExampleRender pins the assembled page. It seeds state directly (rather than // through Set) so the example takes no `cur realm`, and it uses only // non-volatile placeholders so the golden output does not depend on the test // machine's block height or chain id. func ExampleRender() { reset() seed("layout", "# :owner:\n\n:bio:\n\n## Packages\n\n:packages:\n") seed("bio", "Building on gno.") seed("packages", "- p/moul/dynreplacer\n- r/moul/home") print(Render("")) // Output: // # g1manfred47kzduec920z88wfr64ylksmdcedlf5 // // Building on gno. // // ## Packages // // - p/moul/dynreplacer // - r/moul/home }
  18. #18scan.gno
  19. #19package home import ( "strings" "chain/runtime" "gno.land/p/moul/dynreplacer/v0" "gno.land/p/moul/mygnoscan/v0" "gno.land/r/moul/config/v1" ) // This file is the explorer half of the page: a handful of computed // placeholders that turn what this realm already knows (its own path, its // owner, the block it is rendering at) into links a reader can click. // // They are COMPUTED, not slots, so they are always current: the layout slot // says where a link goes on the page, and nothing in storage has to be // rewritten when the answer changes. // // The explorer they point at comes from gno.land/r/moul/config, not from a // constant here. That is the whole point of the indirection: moving every one // of moul's realms to a different explorer is one transaction against config, // not a redeploy of each realm that links to one. When config has no setting, // mygnoscan.DefaultBase answers, so this realm renders correctly on a chain // where nothing was ever configured. // scanPlaceholders are the reserved slugs this file answers for. They are // listed in reservedSlugs (home.gno) so a slot can never take one of these // names and shadow the computed value. var scanPlaceholders = []string{ "scan", // the explorer base URL, as plain text "scan.realm", // this realm on the explorer "scan.me", // the owner's account page "scan.block", // the block this render is happening at "scan.links", // the three above, on one line } // scanner returns the configured link builder. // // config.Scanner() is a read, and a cheap one: an avl lookup plus a struct. // It is called once per render and the result reused, rather than once per // placeholder, because dynreplacer may invoke several of the callbacks below // and each would otherwise repeat the cross-realm call. func scanner() mygnoscan.Scanner { return config.Scanner() } // registerScanCallbacks adds the explorer placeholders to r. // // dynreplacer only invokes a callback whose placeholder actually occurs in the // layout, so a layout using none of these costs one config.Scanner() call and // nothing else. func registerScanCallbacks(r *dynreplacer.Replacer) { s := scanner() r.RegisterCallback(":scan:", func() string { return s.Base() }) r.RegisterCallback(":scan.realm:", func() string { return mygnoscan.Link("mygnoscan", s.Realm(realmPath)) }) r.RegisterCallback(":scan.me:", func() string { return mygnoscan.Link("my account", s.Address(admin)) }) r.RegisterCallback(":scan.block:", func() string { return mygnoscan.Link("this block", s.Block(runtime.ChainHeight())) }) r.RegisterCallback(":scan.links:", func() string { return scanLinks(s) }) } // scanLinks is the :scan.links: placeholder: everything above on one line, for // a layout that wants the footer and does not want to lay it out by hand. func scanLinks(s mygnoscan.Scanner) string { parts := []string{ mygnoscan.Link("this realm", s.Realm(realmPath)), mygnoscan.Link("my account", s.Address(admin)), mygnoscan.Link("this block", s.Block(runtime.ChainHeight())), } return strings.Join(parts, " · ") }
  20. #20scan_test.gno
  21. #21package home import ( "testing" "gno.land/p/moul/mygnoscan/v0" "gno.land/p/nt/uassert/v0" ) // TestScanPlaceholdersAreReserved is the one that actually protects the // design: every placeholder scan.gno answers for must also be refused as a // slot name, or a slot could take the name and the page would depend on which // callback dynreplacer registered last. func TestScanPlaceholdersAreReserved(t *testing.T) { for _, p := range scanPlaceholders { found := false for _, r := range reservedSlugs { if r == p { found = true break } } uassert.True(t, found, "scan placeholder not reserved: "+p) } } func TestScanSlugsAreWritableShapes(t *testing.T) { // A reserved slug still has to be a legal slug, or reserving it is a // no-op: an illegal name is refused for a different reason and the // reservation never gets exercised. for _, p := range scanPlaceholders { uassert.True(t, validSlug(p), "scan placeholder is not a valid slug: "+p) } } func TestSetRefusesScanSlugs(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(admin)) uassert.AbortsWithMessage(t, cur, "reserved slug: scan.realm is computed at render time", func() { Set(cross(cur), "scan.realm", "anything") }) } // TestScanLinksFollowConfig is the end-to-end claim of this whole change: what // the page renders follows gno.land/r/moul/config, so pointing moul's realms // at a different explorer is a transaction and not a redeploy. // // It cannot set the config from here (that needs config's own authority and a // crossing call into another realm), so it asserts the default path: unset // config means mygnoscan.DefaultBase, in the page, for real. func TestScanLinksFollowConfig(t *testing.T) { reset() seed("layout", ":scan.realm:") uassert.Equal(t, "[mygnoscan]("+mygnoscan.DefaultBase+"/realm/r/moul/home)", Render(""), "with nothing configured the page falls back to the package default") } // ExampleRender_scan pins the explorer footer as a layout would use it. The // block link is deliberately absent: it moves with the chain, and an example // that pins it would fail on whichever test ran SkipHeights before it. func ExampleRender_scan() { reset() seed("layout", "# :owner:\n\n---\n\n:scan.realm: · :scan.me:\n") print(Render("")) // Output: // # g1manfred47kzduec920z88wfr64ylksmdcedlf5 // // --- // // [mygnoscan](https://mygnoscan.moul.p2p.team/realm/r/moul/home) · [my account](https://mygnoscan.moul.p2p.team/address/g1manfred47kzduec920z88wfr64ylksmdcedlf5) } // ExampleRender_scanBase pins the raw base URL placeholder, which is what a // slot uses to build a link this realm has no helper for. func ExampleRender_scanBase() { reset() seed("layout", ":scan:\n") print(Render("")) // Output: // https://mygnoscan.moul.p2p.team }

Result log

msg:0,success:true,log:,events:[]

← Back to block 407,732