Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

home source realm

Package home is the realm behind [https://gno.land/u/moul](https://gno.land/u/moul).

Readme View source

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, 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:

1[[module]]
2module = "gno.land/r/moul/home/v0"
3source = { 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, the last commit where this directory held it.

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, 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 (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, 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, 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.

1go -C tools tool gnohome preview   # see the page before anyone else does
2go -C tools tool gnohome status    # what differs from the chain
3go -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.

1go -C tools tool gnohome packages > r/moul/home/content/packages.md

See 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.

1gnopm publish -key moul moul/home     # read the report, read the script
2gnopm publish -key moul moul/home > /tmp/deploy.sh && sh /tmp/deploy.sh

Then the content, once the realm answers:

1go -C tools tool gnohome tx -all -batch /tmp/home.tx.json
2sh /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.


Part of 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

⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.

Overview

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.

Functions 7

func Append

crossing Action
1func Append(cur realm, slug, body string)
source

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 Delete

crossing Action
1func Delete(cur realm, slug string)
source

Delete removes a slot. Deleting the layout slot restores the built-in default layout.

func Get

Action
1func Get(slug string) string
source

Get returns a slot body, or "" when the slot does not exist.

func Manifest

Action
1func Manifest() string
source

Manifest returns one tab-separated line per slot:

Example
1<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 Render

1func Render(path string) string
source

Render serves three views:

Example
1""            the assembled page, what https://gno.land/u/moul shows
2"slots"       the slot index: name, size, revision, last write
3"slots/<slug>" one slot's raw markdown, fenced

func Revision

Action
1func Revision() int
source

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 Set

crossing Action
1func Set(cur realm, slug, body string)
source

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.

Imports 10

Source Files 10