Toolchain
Reusable, hard-won facts about building and testing Gno locally against a live chain target. Everything here cost a debugging session at least once.
Use a chain-matched toolchain
Gno's interrealm spec is the youngest, fastest-moving part of the stack. A local gno binary from a different revision than the target chain will compile code the chain rejects, and vice versa.
Pearl-1 target used by this project:
GNOROOT="$(go env GOMODCACHE)/github.com/gnolang/[email protected]"
GNOHOME="$HOME/.cache/gno-toolchains/pearl/gnohome-pearlpinned"
GNO="$HOME/.cache/gno-toolchains/pearl/gno"
gnohome-pearlpinned is the cache whose dependencies were fetched with the remote pinned to pearl-1. The older gnohome is not — see Dependency fetch, which is the single most consequential gotcha in this file.
Verify every intended import against the target chain, not just upstream master. A package that resolves on one testnet may not exist on another, and the pinned examples/ tree is a snapshot that drifts.
The two API eras
This project has code from both. Know which you are looking at.
| Old (gno v1.1.0) | Current (pearl, c4c72fd) | |
|---|---|---|
| Crossing call | bare cross: AddTopic(cross, x) | cross(cur): AddTopic(cross(cur), x) |
| Caller identity | chain/runtime.PreviousRealm().Address() | cur.Previous().Address() |
chain/runtime/unsafe | does not exist | exists — OriginSend, OriginCaller, PreviousRealm |
uassert.Aborts* | 3-arg, message before func | 4-arg |
| Banker | NewBanker(bt) | NewBanker(bt, cur) |
The root-level p/tally and r/upvotes were originally written against the old era and were ported to the current era in the 2026-09-22 repo-wide audit (commit 95d37c6); neither was ever deployed. The pearl/ tree is all current-era.
Testing gotchas
testing.SetRealm is FRAME-SCOPED
An identity set inside a helper does not survive that helper's return.
func asOwner(t *testing.T) {
testing.SetRealm(testing.NewUserRealm(owner))
} // <-- identity is gone here
func TestQuota(t *testing.T) {
asOwner(t)
Register(cross(cur), "name") // runs as the EMPTY address, silently
}
This ran a quota test as the empty address and passed for the wrong reason. Set the realm in the test body, at the frame where the call is made.
sanitize.InlineText escapes .
gno.land/p/nt/markdown/sanitize/v0 escapes \ * _ [ ] ( ) ~ > - + . ! ` # < & — including the period. A hostname or a package path never appears verbatim in rendered output.
Assertions pinned against raw text will fail. Pin against the escaped form (pull\-based, basis\_points).
Also: TableCell = InlineText + tab→space + |→\|. It is not idempotent — wrap exactly once. And truncate raw text before escaping, never after, or you can cut an escape sequence in half.
init() runs with an empty origin caller under test
Both unit tests and filetests run a realm's init() with an empty origin caller. Deploy-time admin capture therefore yields the zero address in the harness — it works correctly on-chain.
Pattern: seed the admin in-package for unit tests, and use a filetest to certify that the gate fails closed in the empty state. Verify the real capture live after deployment (e.g. GetFeeInfo admin == deployer).
Same-realm panics are not catchable by uassert.Aborts*
The revive-based abort assertions cannot catch a same-realm panic. Read functions that panic on missing state have to be tested via a crossing path, or excluded with a note.
MsgRun shapes are not constructible in the test driver
testing.SetRealm accepts only user realms and /r/ realms. A MsgRun-shaped caller cannot be built, so IsUserCall vs IsUser distinctions must be documented at the test rather than exercised.
Tooling gotchas
gno test/fmt/lint copy filetests into the package root
gno test --update-golden-tests, gno fmt, and gno lint each write a copy of filetests/*.gno into the package root. The next build then fails with "same filename in package dir".
Delete the stray copy afterwards. This repository's .gitignore also guards against committing one.
Dependency fetch resolves against MAINNET by default
gno mod download fetches into the toolchain's module cache. Its default remote is mainnet, regardless of the chain you are building for, and it does not say which chain it used — it prints only gno: downloading <path>.
This is not cosmetic. Found while building p/permbook:
| Default remote | pearl-1 | |
|---|---|---|
p/nt/groups/v0 group.gno:6 | "gno.land/p/moul/addrset/v0" | "gno.land/p/moul/addrset" |
p/moul/addrset/v0 | exists | vm/qfile → /vm.InvalidPackageError |
groups/v0 group.gno | c8350c2a99… | 5f89bd89ec… |
groups/v0 readonly.gno | 34d23a01c9… | 53b7a2b64f… |
groups/v0 role.gno | 7d07a58288… | a08e8c2627… |
Three of groups/v0's four compiled files differ, and the two chains carry mutually exclusive addrset paths. So local tests were compiling against a package that does not exist on pearl-1, with a different import graph than the deployed copy.
Why: the resolution rules
gnovm/pkg/packages/load.go:113-149 resolves an import in this order — injected test stdlibs, then $GNOROOT/gnovm/stdlibs/<path>, then a workspace-local directory (with the fetcher explicitly nil), then $GNOHOME/pkg/mod/<path> via the fetcher. Two consequences that are easy to get wrong:
examples/is not in the path. GNOROOT supplies only stdlibs. There is no non-test construction site forexamplespkgfetcher.- The remote is derived from the import path's domain, not from any configured target:
load.go:36buildsrpcpkgfetcher.New(nil), andrpcpkgfetcher.go:72computesrpcURL := fmt.Sprintf("https://rpc.%s:443", domain). Everygno.land/...import therefore resolves againstrpc.gno.land— mainnet — unless overridden.
Pin the remote explicitly:
gno mod download -remote-overrides gno.land=https://rpc.pearl.testnets.gno.land:443
A warm cache makes that flag a silent no-op
DownloadPackageToCache writes an empty file at $GNOHOME/pkg/mod/.markers/<DerivePkgBech32Addr(pkgPath)> and, on a later run, returns early if that marker exists. The marker is content-free — no hash, no chain-id, no height, never revalidated — so a cache poisoned from the wrong chain is indistinguishable from a correct one, and adding the override to an existing GNOHOME repairs nothing while reporting success.
Demonstrated, not inferred: a cache populated with no override (mainnet groups/v0, group.gno = c8350c2a99…) was then re-downloaded with the correct pearl override. Nothing was re-fetched and group.gno was still c8350c2a99…. Every marker was 0 bytes — re-counted 2026-09-22, both caches now hold 9 markers and all 18 are 0 bytes. (An earlier revision of this line said "all 8 markers"; the count was wrong, the 0-byte finding was not.)
Use a separate, empty GNOHOME:
GNOHOME="$HOME/.cache/gno-toolchains/pearl/gnohome-pearlpinned"
gno test will not warn you
The same poisoned cache still produces a green run:
$ gno test pearl/p/permbook
ok ./pearl/p/permbook 0.57s
That ok is the whole failure mode. A passing test says nothing about which chain's dependency bytes it compiled.
Prove it — do not trust it
tools/verify_depclosure.py walks the dependency closure from both sides — locally by the rules above, and on-chain via vm/qfile — and compares compilation inputs byte for byte. Against that same poisoned cache it reports all five divergences, including the path-set change in both directions. See DEPENDENCY_CLOSURE.md for the standard and where the gate sits in the lifecycle.
Corrections to an earlier version of this section
- The
[addpkg]-height heuristic was wrong. This section previously said a cachedgnomod.tomlwithcreatorbut no[addpkg]height proved the package was not from the chain, because "an on-chain package always carries one." It does not. The chain injectsheightonly for transaction-deployed packages; genesis packages carrycreatoralone. Verified on pearl-1:groups/v0,moul/addrsetandnt/avl/v0all have a creator and no height, whilepermbookhasheight = 612427. The heuristic would have returned a false negative on the very file it was applied to. The conclusion it was used to support happened to be true; the reasoning was not. - The retroactive gap has now been measured rather than assumed. This section previously stated that any pre-pinning test run "may have compiled against a different version." That was the correct posture with the evidence then available. It has since been resolved by measurement — see DEPENDENCY_CLOSURE.md § Historical exposure.
Narrow your greps
The Gno repository carries multi-megabyte .txtar testdata blobs. A recursive content grep for a symbol will drown in them. Use grep -rln … | head -1 to locate the file, then read the specific file.
Chain semantics absent from the local harness
Validated live; the local test harness does not model these.
-sendlands at the callee address before the body runs.IsUserCallis the receipt-guaranteed caller shape.- Per-call storage deposits lock from the transaction sender for state the call creates. A call can fail simulate with
lockStorageDeposit/insufficient-coins until the sender is topped up. - Storage refunds credit the deleter. Sweeping state pays the sweeper — which also means dust-record spam is self-funded by the spammer.
IssueCoinrequires the fully-qualified"/pkgpath:sub"denom on-chain; the harness accepted bare subdenoms.auth/accountsomits realm-issued denoms. Their bank state is visible only via the banker (GetCoin/GetCoins).- Faucet: ~10 GNOT per address per ~24h, per-address cooldown; other agent keys have independent windows.
Realm address derivation
bech32("g", sha256("pkgPath:" + pkgPath)[:20])
The pkgPath: prefix is mandatory (gnovm/pkg/gnolang/misc.go:201). Omitting it produces a plausible-looking wrong address with no error.
Always validate a derivation implementation by reproducing a recorded, known-good address before trusting it on a new path. All nine realm addresses recorded across the catalog and the deployment record reproduce exactly from this formula. (Corrected 2026-09-22 from "ten", which counted a storage-deposit address — a different preimage — as a realm address.)
Storage-deposit address: preimage "pkgPath:" + pkgPath + ".storageDeposit".
Verification shell snippets
AI-attribution scan — the \bAI\b word boundary is required, or ordinary words match:
git log --format='%B' | grep -ciE \
'co-authored-by|claude|anthropic|generated with|\bAI\b|assistant|copilot'
# expected: 0
On-chain source fetch for byte verification:
curl -s '<rpc>/abci_query?path="vm/qfile"&data=0x'"$(printf '%s' "$PKGPATH/$FILE" | xxd -p | tr -d '\n')"
Environment that is deliberately not committed
| Thing | Location | Why not committed |
|---|---|---|
| Agent keystore | ~/.local/share/gnomcp/agent-keys/ | private key material |
| MCP audit log | ~/.local/share/gnomcp/audit.jsonl | machine-local operational log |
| Toolchain binaries + GNOHOME | ~/.cache/gno-toolchains/ | machine-specific cache, multi-hundred-MB |
| Gno module cache | $(go env GOMODCACHE) | machine-specific cache |
The MCP plugin itself is enabled through Claude Code settings, which are machine-level and not part of this repository. The relevant, non-secret part is simply that the gnomcp plugin from the gnoverse/gno-mcp marketplace is enabled; see CONTRIBUTING.md.