Architecture
How Gno.land works, as Cosmic Bull relies on it. This is the model the rest of the documentation assumes.
Package kinds
Gno is an interpreted Go-derived VM where source lives on-chain. Every code unit is a package; the path prefix is the kind.
| Prefix | Kind | State | Used for |
|---|---|---|---|
/p/ | Pure | frozen after init | Reusable libraries — stateless logic, types, guards |
/r/ | Realm | persistent | Applications — on-chain state, public crossing functions |
/e/ | Ephemeral | per-tx, discarded | MsgRun invocations, created by the chain |
/p/ — pure packages
- Cannot declare crossing functions. Therefore a
/p/package cannot hold authority of its own. - A
/p/helper that must act with realm authority takes the capability as a parameter — conventionally(_ int, rlm realm, …)— and must checkrlm.IsCurrent()before trusting it. An uncheckedrlmis a forged capability. - Post-init frozen: no persistent state. Anything stateful the package offers is an object the consumer realm owns and stores.
Cosmic Bull's four primitives follow this exactly: feeledger hands back a *Ledger that the consuming realm keeps in an unexported global; coinio is entirely stateless and panic-only; duebook and permbook likewise return objects the consumer owns and stores.
/r/ — realms
- Globals persist across transactions.
init()runs once, at deploy. - The public API is the set of crossing functions:
func F(cur realm, …).MsgCallonly dispatches to crossing functions. - A realm has an address, derived from its package path, and can hold coins.
- Deployed bytes are immutable. There is no in-place upgrade. Getting it right before
addpkgis the entire game.
Interrealm semantics (v2)
The caller-identity model is the part most often gotten wrong, and it is the part that produced a finding in every one of the five applications.
The cur realm capability token
func RegisterService(cur realm, name, pkgPath string) {
rejectStraySend(cur)
owner := cur.Previous().Address() // the immediate caller
…
}
- The first
cur realmparameter of a crossing function is runtime-current by construction. It does not need re-checking. cur.Previous().Address()is the immediate caller's identity — the EOA for a directMsgCall, or the calling realm for a cross-call.- A secondary
rlm realmparameter on a helper is not runtime-current. It must be checked withrlm.IsCurrent()before being trusted for authority.
Calling across realms
F(cross(cur), …)— a cross-call into another realm.F(cur, …)— a same-realm, non-crossing call.
The stack-walker, and why it is a bug class
unsafe.PreviousRealm() (from chain/runtime/unsafe) walks the stack: it returns whatever was previous at the most recent realm boundary, regardless of which function you are in.
// BUG CLASS 2 — does NOT identify the immediate caller
func caller() address {
return unsafe.PreviousRealm().Address()
}
Inside a non-crossing helper, this may return an unrelated frame upstream. It was correct on every current path in all five applications — but correctness enforced only by call-site discipline is a latent vulnerability, not a property. The fix in every case was the same: make the entrypoints crossing functions and read cur.Previous().Address() inline.
unsafe is retained only where stack-walking is genuinely what is wanted — OriginSend() for the transaction envelope, and OriginCaller() in init().
Payment guards
func rejectStraySend(cur realm) {
if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
panic("this realm does not accept coins")
}
}
IsUserCall()— EOA viaMsgCall. This is the receipt-guaranteed shape and the correct guard forOriginSend.IsUser()— insufficient: theMsgRunephemeral realm can consume theOriginSendenvelope before forwarding control.
Readonly taint
Values read across a realm-storage boundary are tainted read-only. The taint is sticky and propagates through field access, indexing, slicing, copies, and conversions. Mutating a tainted value panics.
Storage is authority
Every object is stamped with the PkgID of the realm that allocated it. Storage realm = authority. Receiver attachment is a privilege grant, and the compiler does not protect against it — a /p/ type with higher-order methods embedded in /r/ data is an audit-only line of defense.
Realm addresses
A realm's address is derived from its package path:
bech32("g", sha256("pkgPath:" + pkgPath)[:20])
The pkgPath: prefix is mandatory (gnovm/pkg/gnolang/misc.go:201 — crypto.AddressFromPreimage([]byte("pkgPath:" + pkgPath)).Bech32(); AddressFromPreimage is tmhash.SumTruncated, i.e. sha256 truncated to 20 bytes).
Deriving without the prefix yields a plausible-looking wrong address with no error — which is exactly how it gets shipped. Always validate a derivation method by reproducing a recorded, known-good address before trusting it on a new one.
The storage-deposit address uses the preimage "pkgPath:" + pkgPath + ".storageDeposit".
All eleven realm addresses recorded in pearl/DEPLOYMENT.md and catalog/applications.md have been reproduced from this formula and matched against their recorded values, as has service_registry's storage-deposit address from the .storageDeposit preimage above.
(Corrected 2026-09-22: this line read "All ten portfolio realm addresses in catalog/applications.md". Two things were wrong — that file records five realm addresses, not ten, so it could not support the count either way; and a re-derivation of all 17 deployed packages against every address in the records matches nine realm addresses. The tenth verified derivation is a storage-deposit address, which is a different preimage and is now named as such.)
State shape
- Globals persist. Declare them at package scope and initialize in
init(). - Maps are non-deterministic to iterate. Anything that reaches
Rendermust iterate in a defined order — anavl.Treewith.Iterate(), or a separately maintained insertion-ordered slice. panicreverts the transaction; returning anerrordoes not. Usepanicfor unrecoverable contract state. This is the opposite of Go habit.- Zero-free index maps. A counter map keyed by address must
deletethe key when the count reaches zero, not leave a0entry — otherwise the map grows without bound under adversarial use.
Example, from service_registry:
func quotaDrop(owner address) {
n := ownerServices[owner] - 1
if n <= 0 {
delete(ownerServices, owner)
return
}
ownerServices[owner] = n
}
Composition in this portfolio
market ─┬─> coinio ──> (stdlib only)
├─> feeledger ──> p/nt/avl/v0
└─> p/nt/markdown/sanitize/v0
grants ─┬─> coinio
├─> feeledger
└─> sanitize/v0
coindemo ─┬─> coinio
└─> feeledger
bounties ───> feeledger
vault ───> feeledger
service_registry ───> p/nt/markdown/sanitize/v0
fee_split, timelock_guardian, upgrade_registry,
permission_registry ───> (stdlib only)
The pipeline applications are deliberately import-light: they were ported from existing upstream repositories, and adding dependencies would have changed the application rather than porting it. The one exception is service_registry, which replaced a hand-rolled sanitizer with the ecosystem p/nt/markdown/sanitize/v0 — a reuse decision made because the hand-rolled version was a security finding (Y5), not a style preference.
Consumer contracts the packages cannot enforce
coinio
- Pass
0and your crossing entrypoint's own livecur. The(_ int, rlm realm, …)shape is required because/p/cannot declare crossing functions; every mover assertsrlm.IsCurrent(). Receiveis a read of the tx envelope, not a consumption — call it at most once per transaction and credit its result at most once.- Debit your accounting before
Payout/Sweep(checks-effects- interactions); a panic-abort reverts debit and send together. - Authorization is the consumer's job — gate entrypoints before calling in.
- Coins can only move from the calling realm's own address.
feeledger
- Keep the
*Ledgerpointer unexported (the grc20 PrivateLedger rule). - Credit exactly on verified receipt; debit exactly before payout; panic on every ledger error.
- Account keys come from
cur.Previous()at crossing boundaries only.