Cosmic Bull

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

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.

PrefixKindStateUsed for
/p/Purefrozen after initReusable libraries — stateless logic, types, guards
/r/RealmpersistentApplications — on-chain state, public crossing functions
/e/Ephemeralper-tx, discardedMsgRun invocations, created by the chain

/p/ — pure packages

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


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
    …
}

Calling across realms

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")
    }
}

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:201crypto.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

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

  1. Pass 0 and your crossing entrypoint's own live cur. The (_ int, rlm realm, …) shape is required because /p/ cannot declare crossing functions; every mover asserts rlm.IsCurrent().
  2. Receive is a read of the tx envelope, not a consumption — call it at most once per transaction and credit its result at most once.
  3. Debit your accounting before Payout/Sweep (checks-effects- interactions); a panic-abort reverts debit and send together.
  4. Authorization is the consumer's job — gate entrypoints before calling in.
  5. Coins can only move from the calling realm's own address.

feeledger

  1. Keep the *Ledger pointer unexported (the grc20 PrivateLedger rule).
  2. Credit exactly on verified receipt; debit exactly before payout; panic on every ledger error.
  3. Account keys come from cur.Previous() at crossing boundaries only.