Gno Contract Review Guide for AI Agents
A concise reference for AI agents performing security review of .gno realm code.
For the full threat model and worked examples, see gno-security-guide.md.
Quick Checks
These are the highest-yield issues to look for in any realm:
1. Caller identity — use cur realm, not address parameters
// WRONG: address parameter is attacker-controlled
func AdminAction(caller address) { ... }
// RIGHT: derive identity from the live crossing frame
func AdminAction(cur realm) {
if !cur.IsCurrent() { panic("spoofed realm") }
addr := cur.Previous().Address()
...
}
2. Payment guards — IsUserCall(), not IsUser()
// WRONG: MsgRun ephemeral realms pass IsUser()
if !IsUser() { panic("not a user") }
// RIGHT
if !cur.Previous().IsUserCall() { panic("not a direct user call") }
3. No exported pointers to mutable state
// WRONG: attacker can call mutator methods on returned pointer
func GetAccount() *Account { return gAccount }
// RIGHT: return a copy, or expose read-only accessors
func GetBalance() int { return gAccount.balance }
4. No caller-supplied callbacks invoked with realm authority
// WRONG: if fn is a top-level /p/-declared function, it inherits
// the caller's m.Realm and can write to your state
func ApplyHook(fn func()) { fn() }
// RIGHT: type the callback with your own /r/-declared type so
// /p/ code can't supply a matching implementation
func ApplyHook(fn func(*MyState)) { fn(gState) }
5. Interface parameters need canonical-type assertion
// WRONG: Evil{Teller} embedding bypasses interface checks
func DoBanking(t grc20.Teller) { t.Transfer(...) }
// RIGHT: assert the concrete type before dispatch
func DoBanking(t grc20.Teller) {
if !grc20.IsCanonicalTeller(t) { panic("not a canonical Teller") }
t.Transfer(...)
}
6. Do not store realm values
realm values are ephemeral — store Address() or PkgPath() strings instead.
// WRONG: panics at attach time
var savedRealm realm
// RIGHT
var savedAddr address
func Save(cur realm) { savedAddr = cur.Previous().Address() }
7. /p/-type with callback iterators
If a realm field is a /p/-type with methods like Iterate(cb func(*Node) bool),
attackers can supply a top-level /p/-function that runs with your realm's authority.
Keep such fields unexported and do not return aliased pointers to them.
8. /p/-type with mutation methods returned as pointer
This is the subtlest case. A /p/ library type whose fields are all unexported can
still be a write-authority leak if it has exported mutation methods and you return a pointer
to an instance stored in your realm.
// avl.Tree fields are all unexported — looks safe.
// But Tree has exported mutation methods: Set, Remove, etc.
var store = avl.NewTree()
// WRONG: attacker calls store.Set(key, value) on the returned pointer.
// Borrow rule #2 fires (tree was allocated in this realm) → m.Realm = /r/V
// for the method body → the write inside Set commits under your authority.
func GetStore() *avl.Tree { return store }
// RIGHT: never return the tree pointer. Expose only what you control.
func GetValue(key string) (any, bool) { return store.Get(key) }
The rule: any exported method on a /p/ type that writes to its receiver is a
mutator. If you return a pointer to an instance, that mutator is now callable by
anyone with the authority of your realm.
Sub-case: exported pointer fields
The same path exists through exported pointer fields of /p/ structs:
// p/mylib
type Container struct {
Items *avl.Tree // exported pointer field
}
// r/V
var c = &Container{Items: avl.NewTree()}
func GetContainer() *Container { return c }
// Attacker: c.Items.Set(key, value) → borrow rule #2 on Items
// (Items was allocated in /r/V) → write commits.
Readonly taint on c does NOT block this: method dispatch is not a write operation,
so the taint check does not fire. Borrow rule #2 fires first on method entry and
authorizes the writes inside the method body.
Rule: treat every exported pointer field of a /p/ type as if it were a direct
pointer to mutable state. If the pointed-to type has any mutation method, it is a
live mutator handle. Never return the containing struct as a pointer.
9. unsafe.PreviousRealm() — old API, skips frame verification
Using chain/runtime/unsafe.PreviousRealm() directly bypasses the cur.IsCurrent()
safety check. It should never appear alongside a cur realm parameter.
// WRONG: cur is accepted but ignored; no IsCurrent() guard
import "chain/runtime/unsafe"
func Set(cur realm, key, value string) {
caller := unsafe.PreviousRealm().Address()
...
}
// RIGHT
func Set(cur realm, key, value string) {
if !cur.IsCurrent() { panic("spoofed realm") }
caller := cur.Previous().Address()
...
}
Flag any import of chain/runtime/unsafe in a realm that also has cur realm parameters.
10. Unsanitized user input in Render
Render(path string) receives attacker-controlled input. Writing path segments, keys,
or user-supplied values directly into markdown output enables injection (broken table
cells, injected links, heading overrides).
// WRONG: path, keys, and values written raw
func Render(path string) string {
return "# Vault: " + path + "\n" // heading injection
}
// ALSO WRONG: table cell content not escaped
b.WriteString("| " + key + " | " + val + " |\n") // | in key breaks table
// RIGHT: escape pipe characters at minimum; use sanitize.InlineText for
// full inline markdown escaping
import "gno.land/p/nt/markdown/sanitize/v0"
b.WriteString("| " + sanitize.InlineText(key) + " | " + sanitize.InlineText(val) + " |\n")
11. GetCoins to read one balance — attacker-influenced cost
Any realm can mint an arbitrary denom to any address without the holder's consent, and
nothing bounds how many distinct denoms an address accumulates. banker.GetCoins(addr)
reads every one of them, so its cost is set by whoever last sent that address a coin —
not by the realm. When addr comes from the caller, a third party can make the function
run out of gas, permanently.
// WRONG: cost grows with denoms the address happens to hold
if banker.NewReadonlyBanker().GetCoins(addr).AmountOf("ugnot") < price {
panic("insufficient balance")
}
// WRONG AND QUADRATIC: a full read per iteration
for _, coin := range coins {
if bnk.GetCoins(realmAddr).AmountOf(coin.Denom) < coin.Amount { ... }
}
// RIGHT: one denom, one store read
if banker.NewReadonlyBanker().GetCoin(addr, "ugnot") < price {
panic("insufficient balance")
}
Reserve GetCoins for cases that genuinely need every balance, and treat it as
unbounded when the address is caller-supplied. Hoisting the call out of a
loop helps but is not enough: a second GetCoins on the same address is much cheaper —
its per-key reads are cache hits, and a cache hit costs no gas — but the iterator walk
over the address's balance keys is charged again, so the part that scales with the denom
count survives. Measured on an address holding 64 unsolicited denoms, a second
GetCoins cost about a quarter of the first, while a second single-denom GetCoin cost
nothing at all.
Two cases where the swap is wrong, both found by making it:
-
The denom is caller-supplied.
GetCoinpanics on a malformed denom whereAmountOfreturns zero. InRender, the denom is often a path segment or query parameter, so the swap hands any visitor a way to break the page.// WRONG in Render: denom comes from the URL, and a malformed one now panics
denom := req.Query.Get("coin")
amount := bnk.GetCoin(addr, denom) -
You already hold the full set. If
GetCoinswas already called for another reason,AmountOfon the result is free andGetCoinis a second store read. Read the surrounding function before swapping a call in it.
Review Checklist
- Authenticated mutators take
cur realmand callcur.IsCurrent() - No import of
chain/runtime/unsafealongsidecur realmparameters - Payment-guarded functions use
cur.Previous().IsUserCall() - No exported function returns a pointer to internal mutable state
- No exported function returns a
/p/-type pointer whose type has mutation methods - No exported
/p/-struct field is itself a pointer to a type with mutation methods - No method accepts a
func(...)callback with a/p/-typed parameter and invokes it - Interface parameters from external callers are guarded with canonical-type asserts
- No
realm-typed value in package-level vars, struct fields, or closure captures -
/p/-type fields with callback iterators are unexported - Data types holding sensitive state are declared in this realm (
/r/), not in shared/p/ -
Rendersanitizes path segments, keys, and user-supplied values before writing to output - Single-denom balance checks use
GetCoin(addr, denom), notGetCoins(addr).AmountOf(denom)— unless the denom is unvalidated caller input or the full set is already read (case 11)
Relationship to Other Docs
| Resource | Purpose |
|---|---|
gno-security-guide.md | Deep technical explanation of the threat model, borrow rules, and anti-patterns |
gno-security.md | Numbered threat-class taxonomy |
gno-interrealm.md | Cross-realm call mechanics (cur realm, IsCurrent(), borrow rules) |
effective-gno.md | Idiomatic Gno patterns including payment guards |
misc/audit-pattern-harness/ | Automated pattern detection tooling with sanitized fixtures |
This guide distills the above into the shortest checklist that catches the most critical issues.