Cosmic Bull

Rendered from docs/TOOLCHAIN.md at commit f191063fd89d in the project repository. The committed file is the source of truth; this page is a rendering of it.

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/gno@v0.0.0-20260827075919-c4c72fdd288c"
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 callbare cross: AddTopic(cross, x)cross(cur): AddTopic(cross(cur), x)
Caller identitychain/runtime.PreviousRealm().Address()cur.Previous().Address()
chain/runtime/unsafedoes not existexists — OriginSend, OriginCaller, PreviousRealm
uassert.Aborts*3-arg, message before func4-arg
BankerNewBanker(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 remotepearl-1
p/nt/groups/v0 group.gno:6"gno.land/p/moul/addrset/v0""gno.land/p/moul/addrset"
p/moul/addrset/v0existsvm/qfile/vm.InvalidPackageError
groups/v0 group.gnoc8350c2a99…5f89bd89ec…
groups/v0 readonly.gno34d23a01c9…53b7a2b64f…
groups/v0 role.gno7d07a58288…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:

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

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.


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

ThingLocationWhy not committed
Agent keystore~/.local/share/gnomcp/agent-keys/private key material
MCP audit log~/.local/share/gnomcp/audit.jsonlmachine-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.