You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(toolbox): a directory of small gno.land utilities, with the usernames tool - #334
A home for small utilities on gno.land: one directory page, and each tool its own realm one
level below it. A tool composes contracts that are already on chain and renders the answer, so
the toolbox adds the view and not the data.
First tool: usernames, the page form of gnokey query vm/qeval --data 'gno.land/r/sys/users.ResolveName("akkadia")', with a box you
type into instead of a query you compose.
package
lines
what it owns
p/moul/toolbox/v0
487 (+ 425 test)
the directory model: Tool, Catalog, version-aware path helpers, the gnoweb query-form builder, the index and card rendering
r/moul/toolbox
206 (+ 118 test)
the hub: the compiled-in catalog and its Render
r/moul/toolbox/usernames
317 (+ 302 test)
the first tool: resolve a username or an address through r/sys/users
It also widens one repository rule, in AGENTS.md and tools/gnocontracts/model.go. That part
is decision 2 and is worth reading before the code.
The design in 5 decisions
1. Everything is private = true, so the catalog is compiled in rather than registered.
The obvious design is the opposite one: each tool calls Register on the hub from its own
init. It is unavailable, and not as a preference. private = true buys redeploy-in-place at
the same path and costs the ability to be imported at all (ImportPrivateError, gnovm/pkg/gnolang/gotypecheck.go:380), so a private hub cannot be imported by its tools and a
registering hub has to be public. A public realm can never be redeployed at its own path, which
would freeze the directory at v0 and move its URL on the first bug in it. A stable URL is the
whole point of a directory, so the trade goes the other way: adding a tool is one entry plus a
redeploy, and since the catalog is rebuilt from source there is no state for the wipe to lose. Catalog stays mutable in the p/ for whoever wants the other trade.
2. A private realm whose URL a human types carries no /vN, and that is a new ground for
the exemption.unversionedContracts had exactly one: an external consumer hard-codes the
path (r/moul/home for gnoweb's /u/<name>, r/moul/blog for every link already pasted
somewhere). It said, in as many words, that "the name is nicer" is not a second one. This adds
a second that is not about taste: /vN exists because a PUBLIC path can never be redeployed,
so a fix there means a new path, and a private realm is redeployable by its creator, so its
version can never increment and the suffix is a promise the realm cannot keep.
The narrow part is "a human types it". An experiment under x/ is private too and nobody
bookmarks it, so there the suffix costs nothing and stays. And the decision is made before the
first publish and never after: the chain holds whichever path was deployed, so renaming the
module line of a live realm publishes a SECOND copy and pays the deposit twice. Measured
2026-10-09: of 79 private realms in this tree, 75 are already live on mainnet and are therefore
frozen where they are, whatever this rule says.
3. BasePath compares families, not full paths.p/moul/nestedpkg decides "is the caller
below me" by prefixing one package path with the other, which is exactly right once neither
side carries a version, and wrong the moment either does: gno.land/r/moul/toolbox/usernames/v0 does not begin with gno.land/r/moul/toolbox/v0. BasePath strips a trailing /vN so a hub and a tool may each be versioned or not,
independently, and all four combinations answer the same. A sibling that merely starts with the
hub's name (toolboxtwo) is still not under it: the comparison is against the base plus a
slash.
4. The form is a read, never a write.NewQuery emits gnoweb's <gno-form path="...">,
whose submission redirects to one of the realm's own Render paths with the fields as query
parameters (gno.land/pkg/gnoweb/handler_http.go, Post). Nothing is signed. A form that
calls a function is a different element (<gno-form exec="Fn">) and is deliberately not this
one with a flag. This is the same shape r/gnoland/coins already uses on mainnet.
5. Catalog is a slug-keyed B+ tree, not a sorted slice. A sorted slice is cheaper to READ
at the size a directory reaches: at n = 100 one cold operation costs 92,571 gas against a
fanout-128 tree's 230,754 (gnobench, gno master 1fc4c140e, 2026-09-29). It is still the
wrong structure. A slice is ONE persisted object, so every write rewrites all of it, O(n) per
transaction and O(n^2) over n of them, which costs nothing for a compiled-in catalog and is the
whole bill for a hub that accepts registration. Removal is worse than writing: shortening a
slice in place keeps the peak allocation charged and refunds no deposit at all, which is a house
rule gnovet enforces and which the first draft of this PR tripped. The tree is embedded by
value, so the zero value is a usable catalog and there is one fewer persisted object; slug order
comes free instead of from a hand-rolled binary search.
What usernames adds over the raw query
r/sys/users.ResolveName answers "which address", and nothing else. The page also answers:
whether the name you typed is the user's current name or one they renamed away from,
what canonical key it collapses to (moul folds to moui), and who already holds that key,
which of the three kinds of "not found" you hit: a valid address with no username, a name
nobody ever held, or a name that is taken by a deleted or renamed account and would still
refuse a registration.
ResolveAny is the trap it exists not to repeat. It returns (*UserData, bool) and the
bool does not mean "found": for an input that parses as an address it returns (ResolveAddress(addr), true) whatever the lookup produced, so an unregistered address comes
back as (nil, true) and every caller that reads the bool and dereferences the pointer panics. TestResolveAnyTrap pins the upstream behaviour so a later fix is visible here.
This is worth having because almost nobody does it. r/sys/users has nine importers on mainnet
and only three of them are third-party apps (r/gnoland/boards2, r/gnoland/coins, r/nym-vikbez000/nightsky), against 314 realms on chain.
What it cannot do
No tool can be imported, by the hub or by anything else, because all three realms are
private. The read API is reachable over vm/qeval and nowhere else. That is the cost paid for
the redeployable path, stated here rather than discovered later, and it means AddressOf
serves a human or a script and cannot serve another realm.
Adding a tool is a redeploy, not a transaction. On mainnet that parks like any other MsgAddPackage and waits for an approver.
The hub cannot render a tool's content, only link to it, for the same import reason. Each
tool owns its own page and its own form.
Verification
make guards lint test exit 0 over 360 packages with six guards green, then make audit-patterns (223 hits, all at baseline) and make gnovet (121 hits, all at baseline). go -C tools test ./... green for the model.go change.
Four load-bearing tests were broken and watched go red before being restored: IsUnder without
its trailing slash (TestIsUnderAndSlugOf), the form attribute escaper removed
(TestQueryEscapesAttributes, TestAttr), the reflected query parameter rendered unescaped
(TestRenderEscapesInput), and List iterating the tree backwards
(TestCatalogSortsBySlug).
Booted under gnodev and asked, since a realm's read API prints differently from what a test
sees:
Preserve queried alias when rendering canonical name
r/moul/toolbox/usernames/render.gno:95
For a renamed alias, lookup(input) returns UserData whose Name() is the new current name. This renders the canonical key of data.Name() rather than the name the user queried, so an alias such as alice renamed to bob displays Canonicalize("bob") instead of Canonicalize("alice"). Canonicalize the input for name/alias queries and use data.Name() only when the input is an address; add a regression case for an alias result.
Check deleted names before canonical-owner collisions
r/moul/toolbox/usernames/render.gno:129
For an exact deleted name, ResolveName returns nil but r/sys/users deliberately retains both the name and canonical-store tombstones (api.gno:3-15, store.gno:267-275). CanonicalOwner(trimmed) therefore succeeds first and the page says it is a lookalike collision, never reaching the exact-name Taken message promised by the README. Check Taken(trimmed) before the canonical-owner branch (or distinguish owner == trimmed).
Copilot pass 2 landed three findings and all three were real. 47c846d:
Catalog was copyable and copying split it. Embedding bptree.BPTree by value shared the root pointer and copied the size int, so b := a; b.Set(x) left a.Len() reporting 1 while a.Get(x) found the entry. Now behind one *state, the same shape p/moul/kit/index already uses, with TestCatalogCopyIsAHandle and TestCatalogCopyOfAZeroValueIsSeparate watched go red against the value-embedded version first. This one would have shipped on a permanent p/ path.
p/moul/toolbox/README.md still described a sorted slice. Rewritten to the B+ tree, its fanout and the actual reason.
CLAUDE.md still said r/moul/home is the one unversioned path. Updated to the two grounds, matching AGENTS.md and unversionedContracts.
One more the review did not catch, found while fixing those: the same README claimed gno.land/r/moul/toolbox/usernames does not begin with gno.land/r/moul/toolbox, which stopped being true when the realms lost their /v0. An earlier bulk rewrite had stripped the versions out of the example and left the sentence asserting the opposite of what it shows. Corrected to match the package doc.
Gate after the fixes: make guards lint test exit 0 over 360 packages, audit-patterns and gnovet both at baseline.
Budget note: that was the second Copilot pass, so no third is coming.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A home for small utilities on gno.land: one directory page, and each tool its own realm one
level below it. A tool composes contracts that are already on chain and renders the answer, so
the toolbox adds the view and not the data.
First tool:
usernames, the page form ofgnokey query vm/qeval --data 'gno.land/r/sys/users.ResolveName("akkadia")', with a box youtype into instead of a query you compose.
p/moul/toolbox/v0Tool,Catalog, version-aware path helpers, the gnoweb query-form builder, the index and card renderingr/moul/toolboxRenderr/moul/toolbox/usernamesr/sys/usersIt also widens one repository rule, in
AGENTS.mdandtools/gnocontracts/model.go. That partis decision 2 and is worth reading before the code.
The design in 5 decisions
1. Everything is
private = true, so the catalog is compiled in rather than registered.The obvious design is the opposite one: each tool calls
Registeron the hub from its owninit. It is unavailable, and not as a preference.
private = truebuys redeploy-in-place atthe same path and costs the ability to be imported at all (
ImportPrivateError,gnovm/pkg/gnolang/gotypecheck.go:380), so a private hub cannot be imported by its tools and aregistering hub has to be public. A public realm can never be redeployed at its own path, which
would freeze the directory at v0 and move its URL on the first bug in it. A stable URL is the
whole point of a directory, so the trade goes the other way: adding a tool is one entry plus a
redeploy, and since the catalog is rebuilt from source there is no state for the wipe to lose.
Catalogstays mutable in thep/for whoever wants the other trade.2. A private realm whose URL a human types carries no
/vN, and that is a new ground forthe exemption.
unversionedContractshad exactly one: an external consumer hard-codes thepath (
r/moul/homefor gnoweb's/u/<name>,r/moul/blogfor every link already pastedsomewhere). It said, in as many words, that "the name is nicer" is not a second one. This adds
a second that is not about taste:
/vNexists because a PUBLIC path can never be redeployed,so a fix there means a new path, and a private realm is redeployable by its creator, so its
version can never increment and the suffix is a promise the realm cannot keep.
The narrow part is "a human types it". An experiment under
x/is private too and nobodybookmarks it, so there the suffix costs nothing and stays. And the decision is made before the
first publish and never after: the chain holds whichever path was deployed, so renaming the
module line of a live realm publishes a SECOND copy and pays the deposit twice. Measured
2026-10-09: of 79 private realms in this tree, 75 are already live on mainnet and are therefore
frozen where they are, whatever this rule says.
3.
BasePathcompares families, not full paths.p/moul/nestedpkgdecides "is the callerbelow me" by prefixing one package path with the other, which is exactly right once neither
side carries a version, and wrong the moment either does:
gno.land/r/moul/toolbox/usernames/v0does not begin withgno.land/r/moul/toolbox/v0.BasePathstrips a trailing/vNso a hub and a tool may each be versioned or not,independently, and all four combinations answer the same. A sibling that merely starts with the
hub's name (
toolboxtwo) is still not under it: the comparison is against the base plus aslash.
4. The form is a read, never a write.
NewQueryemits gnoweb's<gno-form path="...">,whose submission redirects to one of the realm's own
Renderpaths with the fields as queryparameters (
gno.land/pkg/gnoweb/handler_http.go,Post). Nothing is signed. A form thatcalls a function is a different element (
<gno-form exec="Fn">) and is deliberately not thisone with a flag. This is the same shape
r/gnoland/coinsalready uses on mainnet.5.
Catalogis a slug-keyed B+ tree, not a sorted slice. A sorted slice is cheaper to READat the size a directory reaches: at n = 100 one cold operation costs 92,571 gas against a
fanout-128 tree's 230,754 (
gnobench, gno master1fc4c140e, 2026-09-29). It is still thewrong structure. A slice is ONE persisted object, so every write rewrites all of it, O(n) per
transaction and O(n^2) over n of them, which costs nothing for a compiled-in catalog and is the
whole bill for a hub that accepts registration. Removal is worse than writing: shortening a
slice in place keeps the peak allocation charged and refunds no deposit at all, which is a house
rule
gnovetenforces and which the first draft of this PR tripped. The tree is embedded byvalue, so the zero value is a usable catalog and there is one fewer persisted object; slug order
comes free instead of from a hand-rolled binary search.
What
usernamesadds over the raw queryr/sys/users.ResolveNameanswers "which address", and nothing else. The page also answers:moulfolds tomoui), and who already holds that key,nobody ever held, or a name that is taken by a deleted or renamed account and would still
refuse a registration.
ResolveAnyis the trap it exists not to repeat. It returns(*UserData, bool)and thebool does not mean "found": for an input that parses as an address it returns
(ResolveAddress(addr), true)whatever the lookup produced, so an unregistered address comesback as
(nil, true)and every caller that reads the bool and dereferences the pointer panics.TestResolveAnyTrappins the upstream behaviour so a later fix is visible here.This is worth having because almost nobody does it.
r/sys/usershas nine importers on mainnetand only three of them are third-party apps (
r/gnoland/boards2,r/gnoland/coins,r/nym-vikbez000/nightsky), against 314 realms on chain.What it cannot do
private. The read API is reachable over
vm/qevaland nowhere else. That is the cost paid forthe redeployable path, stated here rather than discovered later, and it means
AddressOfserves a human or a script and cannot serve another realm.
MsgAddPackageand waits for an approver.tool owns its own page and its own form.
Verification
make guards lint testexit 0 over 360 packages with six guards green, thenmake audit-patterns(223 hits, all at baseline) andmake gnovet(121 hits, all at baseline).go -C tools test ./...green for themodel.gochange.Four load-bearing tests were broken and watched go red before being restored:
IsUnderwithoutits trailing slash (
TestIsUnderAndSlugOf), the form attribute escaper removed(
TestQueryEscapesAttributes,TestAttr), the reflected query parameter rendered unescaped(
TestRenderEscapesInput), andListiterating the tree backwards(
TestCatalogSortsBySlug).Booted under
gnodevand asked, since a realm's read API prints differently from what a testsees:
vm/qeval gno.land/r/moul/toolbox.Slugs()slice[("usernames" string)] []string, noref(...)vm/qeval gno.land/r/moul/toolbox.PathOf("usernames")"gno.land/r/moul/toolbox/usernames"vm/qeval gno.land/r/moul/toolbox/usernames.Canonical("M0ul")"Moui"GET /r/moul/toolbox?tag=identityRenderPOST __gno_path=lookup&q=moul303to/r/moul/toolbox/usernames:lookup?q=moul