Skip to content

feat(toolbox): a directory of small gno.land utilities, with the usernames tool - #334

Merged
moul merged 4 commits into
mainfrom
toolbox
Oct 9, 2026
Merged

moul merged 4 commits into
mainfrom
toolbox

Conversation

@moul

@moul moul commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

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:

asked answered
vm/qeval gno.land/r/moul/toolbox.Slugs() slice[("usernames" string)] []string, no ref(...)
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=identity the filtered index, so a bare query reaches Render
POST __gno_path=lookup&q=moul 303 to /r/moul/toolbox/usernames:lookup?q=moul

@moul
moul requested a lite review from Copilot October 9, 2026 12:59
@github-actions github-actions Bot added p Touches pure packages (p/) r Touches realms (r/) labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📦 3 new · 0 updated · 27 files · +2398/−19 · 🧪 64 tests (touched pkgs)
🖼️ Preview · 2 realms · stable link, updates a minute or two after each push

gno.land/r/moul/toolbox
new in this PRafter
after
gno.land/r/moul/toolbox/usernames
new in this PRafter
after
3 packages · 🤖 updated on every push
package Δ 🧪 flags
🆕 gno.land/p/moul/toolbox/v0 +1158/−0 32
🆕 gno.land/r/moul/toolbox +430/−0 12
🆕 gno.land/r/moul/toolbox/usernames +734/−0 20
`gnomod.lock` · 3 changes
  • 🆕 gno.land/p/moul/toolbox/v0 added to the lock
  • 🆕 gno.land/r/moul/toolbox added to the lock
  • 🆕 gno.land/r/moul/toolbox/usernames added to the lock
🔒 `gnomod.lock` · gnopm: every check green

gnopm

360 modules · 307 in tree · 53 pinned to history

check detail
✅ the lock describes the working tree every package accounted for
✅ pinned versions reproduce from history 53 version(s) rebuilt and hashed
✅ pins survive a squash merge every pin is already on origin/main
✅ published versions are not edited in place no package edited in place

generated by gnopm

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved moderate findings affect catalog ownership and rendered escaping, links, and input handling.

7 open findings
What changed in this PR

Adds a private toolbox hub, reusable catalog/form/rendering package, and a usernames lookup realm.

Changes:

  • Adds deterministic catalog, path helpers, query forms, and rendering APIs.
  • Adds toolbox and usernames realms with lookup and filtering pages.
  • Adds tests, documentation, module metadata, and lockfile entries.
File Description
r/​moul/​toolbox/​usernames/​usernames.gno Username and address resolution API
r/​moul/​toolbox/​usernames/​usernames_test.gno Resolver tests
r/​moul/​toolbox/​usernames/​render.gno Usernames page rendering
r/​moul/​toolbox/​usernames/​render_test.gno Rendering tests
r/​moul/​toolbox/​usernames/​render_example_test.gno Rendering examples
r/​moul/​toolbox/​usernames/​README.md Usernames tool documentation
r/​moul/​toolbox/​usernames/​gnomod.toml Module metadata
r/​moul/​toolbox/​toolbox.gno Compiled toolbox catalog and API
r/​moul/​toolbox/​toolbox_test.gno Hub tests
r/​moul/​toolbox/​render.gno Toolbox hub rendering
r/​moul/​toolbox/​render_example_test.gno Hub rendering examples
r/​moul/​toolbox/​README.md Hub documentation
r/​moul/​toolbox/​gnomod.toml Module metadata
p/​moul/​toolbox/​toolbox.gno Core toolbox types and path helpers
p/​moul/​toolbox/​toolbox_test.gno Core model tests
p/​moul/​toolbox/​render.gno Catalog and card rendering
p/​moul/​toolbox/​render_test.gno Rendering tests
p/​moul/​toolbox/​README.md Package documentation
p/​moul/​toolbox/​gnomod.toml Module metadata
p/​moul/​toolbox/​form.gno Query form builder
p/​moul/​toolbox/​form_test.gno Form tests
p/​moul/​toolbox/​catalog.gno Sorted catalog implementation
p/​moul/​toolbox/​catalog_test.gno Catalog tests
gnomod.lock New module registrations

🧠 Review effort: Lite


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread p/moul/toolbox/catalog.gno
Comment thread p/moul/toolbox/render.gno
Comment thread r/moul/toolbox/render.gno Outdated
Comment thread r/moul/toolbox/render.gno Outdated
Comment thread r/moul/toolbox/usernames/render.gno Outdated
Comment thread r/moul/toolbox/usernames/render.gno Outdated
Comment thread r/moul/toolbox/usernames/usernames.gno Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical catalog copy-safety issue and additional correctness, documentation, and test gaps remain unresolved.

3 open findings
7 resolved since last review
Previously missed (2)

In code that hasn't changed since last review

Medium severity 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.

Medium severity 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).

🧠 Review effort: Lite

Comment on lines +31 to +33
type Catalog struct {
tools bptree.BPTree
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 47c846d.

Comment thread AGENTS.md
Comment on lines +40 to +55
- **An unversioned path is the exception, and there are exactly two grounds for it.**
The allowlist is `unversionedContracts` in `tools/gnocontracts/model.go`, which carries
the reason for every entry; a path not in it and not ending in `/vN` fails the scan.
Both grounds are the same claim: the path is an INTERFACE, not a naming choice.
- *An external consumer hard-codes it.* `r/moul/home` is the original case: gnoweb
serves `gno.land/u/<username>` by calling `Render("")` on the realm at exactly
`/r/<username>/home` (`gno.land/pkg/gnoweb/handler_http.go`, `GetUserView`: built by
string concatenation, with no version resolution anywhere in the lookup), so a `/v0`
would publish where `/u/moul` never looks. `r/moul/blog`'s consumer is every link
already pasted somewhere this repo cannot rewrite.
- *A private realm whose URL a human types.* `/vN` exists because a PUBLIC path can
never be redeployed, so a fix there means a new path. A private realm is redeployable
by its creator, so its version can never increment and the suffix is a promise the
realm cannot keep. `r/moul/toolbox` and `r/moul/toolbox/usernames` are this case.
**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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 47c846d.

Comment thread p/moul/toolbox/README.md Outdated
Comment on lines +44 to +48
- **`Catalog` is a sorted slice, not a tree.** A directory is tens of entries read far more
often than written; at n = 100 a sorted slice costs 92,571 gas for one cold operation against
`avl`'s 164,812 (`gnobench`, gno master `1fc4c140e`, 2026-09-29), in one persisted object
rather than one per node. Sorted by slug rather than by insertion, because a `Render` that
reshuffles between two identical calls is a bug.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 47c846d.

@moul

moul commented Oct 9, 2026

Copy link
Copy Markdown
Owner Author

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.

@moul
moul merged commit 015e568 into main Oct 9, 2026
9 checks passed
@moul
moul deleted the toolbox branch October 9, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

meta CI / tooling / docs / repo meta p Touches pure packages (p/) r Touches realms (r/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants