moul's settings, plus the manager list that decides who may change them. One
realm the others read, so a value that several contracts share lives in one
place and moving it is a transaction rather than a redeploy of each.
Settings
1funcSet(currealm,key,valuestring)// manager only; aborts otherwise2funcUnset(currealm,keystring)// manager only; aborts on a missing key3funcGet(keystring)string// "" when unset4funcGetOr(key,fallbackstring)string// what a consumer should call5funcHas(keystring)bool6funcKeys()[]string7funcSize()int8funcSettingsRevision()int9funcManifest()string// the whole config in one qeval read
A key is 1 to 64 bytes of [a-z0-9._-], dot-namespaced by convention
(mygnoscan.url). A value is capped at 1024 bytes: this realm holds settings,
not content, and storage is paid for and never refunded. Every write emits a
ConfigSet / ConfigUnset event, so the history of a setting is readable from
an indexer without a call per key.
The generic API is the point. A new setting is a new key, which is a
transaction. Only a change to the shape of this realm needs a version bump,
and a bump is expensive here: the path changes, so every realm importing the
old one keeps reading the old one until it is itself redeployed. Reach for a
key before reaching for a typed accessor.
Why the settings functions abort instead of returning an error
The manager functions below return error. A returned error from a realm call
leaves the transaction successful: the caller sees a green receipt and
walks away believing the write landed, while every realm reading that key keeps
serving the old value. For a configuration realm that is the one outcome worth
ruling out, so Set and Unset abort. The manager functions predate that
reasoning and are frozen on chain at v0; the new surface does not inherit it.
The notice blocks
Two strings a realm drops at the top and the bottom of its Render, empty by
default, so a warning, a changelog line or a bit of news can go on every realm
at once or on one of them.
Set them with the ordinary Set, which is what keeps this realm's surface from
growing a function per idea:
1Set block.top "> Chain migration on Tuesday."# every realm2Set block.top@r/moul/gns "> v2 shipped, see the changelog"# this one only3Unset block.top # back to silence
TopBlock shows three things when they exist, in this order, separated by
blank lines: the pause banner, the global message, then this realm's own. Both
messages, not one overriding the other: a chain-wide warning and a per-realm
changelog are different messages, and dropping either because the other exists
is the surprising behaviour.
When there is nothing to say it returns "", so a realm that concatenates it
unconditionally renders byte-for-byte what it rendered before.
Pausing
1Set pause "paused: incident, back in an hour"# everything2Set pause@r/moul/gns "readonly"# one realm3Unset pause # running again
1funcPost(currealm,bodystring){2config.AssertWritable()// aborts while ReadOnly or Paused3...4}
Three levels (running, readonly, paused, each optionally : <reason>),
because taking a realm fully offline hides the thing people came to read while
most incidents only need the writes stopped. The levels, the fail-closed parse
and the precedence rule live in
p/moul/pausable; this realm is the storage and
the wiring.
Two properties worth knowing:
A global pause cannot be defeated by a per-realm setting. The two combine
with pausable.Strictest, so a stale pause@r/moul/foo of running does
not re-open that realm during a global halt. Exempting one realm is therefore
not expressible: clear the global and set the others.
A pause value is validated on write.pausable.MustParse fails closed, so
an unvalidated typo would take every realm offline at the next render.
Set refuses anything the reader could not understand, which turns that into
a failed transaction the writer sees immediately.
TopBlock already carries the pause banner, so guarding writes with
AssertWritable is enough to also explain the refusal on the page.
Zero-argument or explicit
Every helper comes in two forms: TopBlock() names the realm calling in,
TopBlockFor(pkgPath) names one you pass.
The zero-argument form works because these are plain reads with no cur realm
parameter, so gno runs them borrowed, opens no realm frame, and
unsafe.CurrentRealm() reports the caller rather than this realm. Measured in
the test harness on 2026-09-22 and pinned by a test.
Use the explicit form from a crossing function, where there is a realm frame
and the answer would be that function's realm, or when asking about a realm
other than your own. Do not add a cur realm parameter to any of the
zero-argument helpers: it would silently start answering
gno.land/r/moul/config for every caller.
Versions: v2 relays to v1, never the other way
This realm is public, so its path is permanent and a new API means a new
version at a new path. Left alone that fragments everything: a realm importing
v1 and a realm importing v2 would read two different member lists and two
different pause switches.
Delegation fixes it, and it only runs one way. A version can import what
already existed when it was written, never what does not exist yet. So the
state stays in the oldest version that has it and every later version is a
thin relay:
A realm importing v1, v2 or v3 reads the same state whichever door it came
through. v0 cannot take part: it is already on chain and has no settings
store, so it keeps answering for its own member list and nothing else.
Writing v2: it holds no state, forwards reads directly, and forwards writes
with the address it was called by.
Then once, from a manager: AllowProxy gno.land/r/moul/config/v2.
A proxy is trusted to say who is asking, not to decide whether they may.SetAs still puts the principal through the Authorizer, so the member list
stays the single answer to "who may change config" for every version at once,
and adding a manager works through v2 and v3 with no further deploys. It is not
a boundary against the proxy's own code, and does not need to be: the same
person deploys both, registration is deliberate, and RevokeProxy is
immediate. What it buys is that v2 never carries a copy of the member list, so
the two can never disagree.
AllowProxy only accepts gno.land/r/moul/config/vN, so a fat-fingered path
cannot become a standing write grant to an unrelated realm.
The explorer accessors
1funcMygnoscanURL()string// the configured base, or the package default2funcScanner()mygnoscan.Scanner// a configured link builder3funcMygnoscanFor(pkgPathstring)string// a realm's explorer page4funcMygnoscanFooter(pkgPathstring)string// the markdown line for a Render
This is the worked example of the whole idea. A realm renders
config.MygnoscanFooter("gno.land/r/moul/mything") in its footer; moul points
every one of them at a different explorer with:
Keys: mygnoscan.url (base URL) and mygnoscan.network (overrides the
?network= id, for an instance that names the chain differently; normally
unset, and the chain-id decides). Unset, readers fall back to
p/moul/mygnoscan's DefaultBase, so a realm
importing this one still renders correctly on a chain where nothing was ever
configured.
MygnoscanFor takes the path explicitly and there is no zero-argument version.
p/moul/mygnoscancan name the calling realm by stack-walking, but the stack
seen from inside this realm has this realm on it, so such a helper would
confidently return gno.land/r/moul/config for every caller.
A thin layer over p/moul/authz. init refuses to
run unless the caller is an EOA (cur.Previous().IsUserCall()) and seeds the
authority with that address; that address is then the only one that can write a
setting until it adds another.
AddManager and RemoveManager only work while the authority is a
MemberAuthority. Once TransferManagement hands control to something else (a
DAO, a contract), they return an error rather than silently bypassing the new
authority, and Set follows the new authority from that moment on: it asks the
Authorizer, not a member list.
The realm governs itself. There is no address hardcoded in the settings path,
so handing this realm to a DAO hands it the settings too.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:
⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.
Overview
Package config is the one realm moul's other realms read their settings from: a key/value store plus the manager list that decides who may write it.
Why a realm and not a constant
A value shared by several contracts, hardcoded in each, is changed by redeploying each. On mainnet a public realm cannot even be redeployed at its own path, so "changing it" means a new version and a new path for every consumer. Here it is a transaction.
Bump rarely, and never for a new setting
This realm IS public and versioned, so its path moves on every bump. That makes a bump expensive, which is exactly why the settings API is generic: a new setting is a new KEY, and a key costs one transaction. Only a change to the shape of this realm (a new function, a changed signature) is worth a version.
When a bump does happen, the state does not move
A later version imports this one and relays to it rather than holding anything of its own, so v1, v2 and v3 all read and write the same settings, the same pause and the same member list. Delegation only runs backwards in time, which is why the root is the oldest version that has the state and not the newest. See proxy.gno.
What lives here
settings.gno the key/value store, and the mygnoscan accessors over it keys.gno the key grammar: a name, optionally scoped to one realm blocks.gno the notice a realm renders above and below its content pause.gno the switch a realm checks before it acts proxy.gno the relay that lets a later version share this state config.gno the member list, unchanged from v0
Governance
init seeds the authority with the deploying EOA. Everything that writes goes through Authorizer, so transferring authority to a DAO transfers the settings with it: there is no address hardcoded on the write path.
1const(2// KeyBlockTop is the notice rendered above a realm's content.3KeyBlockTop="block.top"4// KeyBlockBottom is the notice rendered below it.5KeyBlockBottom="block.bottom"6)
The notice blocks: two strings a realm drops at the top and the bottom of its Render, empty by default, so moul can put a warning, a changelog line or a bit of news on every realm at once or on one of them.
1const(2// ScopeSep separates a name from the realm it applies to. "@" and not "."3// so a scope can never be mistaken for a longer name, and not ":" because4// gno realm render paths already use that.5ScopeSep="@"67maxNameLen=648maxScopeLen=1289)
A key is a NAME, optionally followed by "@" and a SCOPE:
Example
1pause applies to every realm that asks
2pause@r/moul/home applies to that realm only
The scope is a package path with the chain domain stripped, because the domain is the same for every realm reading this one and repeating it in a few dozen keys buys nothing but storage.
Two settings therefore answer every scoped question, and each reader decides how they combine: pause takes the stricter of the two (pausable.Strictest), while the notice blocks show both. That choice belongs to the reader and not here, because "stricter wins" and "show both" are both right, for different settings.
1const(2// KeyMygnoscanURL is the explorer instance every realm of moul's links to.3KeyMygnoscanURL="mygnoscan.url"4// KeyMygnoscanNetwork overrides the ?network= id, for an instance that5// names the chain something other than what mygnoscan.NetworkFor expects.6// Unset is the normal case: the chain-id decides.7KeyMygnoscanNetwork="mygnoscan.network"8)
The keys this realm answers for by name. A key is just a string and any key can be set, but one the rest of the repo reads gets a constant here so the reader and the writer cannot drift apart on a typo.
The pause switch: one setting that stops every realm reading this one, and one per realm that stops just that one.
Example
1Set pause "paused: incident, back in an hour"
2Set pause@r/moul/gns "readonly"
3Unset pause # running again
A realm guards its writes and leaves its reads alone, which is what makes "readonly" the level worth having: the page keeps rendering and explains itself through TopBlock, and nothing new lands.
AddManager adds a new address to the list of authorized managers. This only works if the current authority is a MemberAuthority. The caller must be authorized by the current authority.
AllowProxy registers a newer version of this realm as a relay. Manager only.
It takes a package path rather than an address because a path is what a manager can check by eye; the address is derived here with the same function the chain uses, so the two cannot drift.
AssertReadable aborts unless the realm calling in may still be read.
Most realms should NOT put this in Render. A page that aborts tells a reader nothing; TopBlock tells them what happened and when to come back. Reach for this only where serving stale data is itself the harm.
Get returns a setting's value, or "" when it is not set. Reads are open: a realm importing this one calls Get on every render and pays nothing beyond the cross-realm call.
GetOr returns a setting's value, or fallback when it is unset or empty. This is the shape every consumer wants: a realm should still render on a chain where this one was never deployed with that key.
It is the whole config in one vm/qeval read, for a tool that wants to diff the chain against a local file without a call per key. The value is last because it is the only field that can contain a tab.
A stale per-realm entry can never re-open a realm during a global pause, because the two combine with pausable.Strictest rather than one overriding the other. The cost is that exempting one realm from a global pause is not expressible: clear the global and set the others.
RemoveManager removes an address from the list of authorized managers. This only works if the current authority is a MemberAuthority. The caller must be authorized by the current authority.
Revoking one that was never registered aborts rather than passing quietly: at this point in an incident, "done" and "you misspelled it" must not look the same.
Scanner returns a link builder pointed at the configured explorer, on whichever network answers for the running chain (or at the KeyMygnoscanNetwork override, when one is set).
This is the function other realms are meant to call:
Scope turns a package path into the scope half of a key, stripping the chain domain: "gno.land/r/moul/home" and "r/moul/home" both give "r/moul/home".
A path that is not shaped like one comes back empty, and every caller here treats that as "no scope", falling back to the global setting rather than inventing a key nobody can type.
The chain domain is tried first and the literal "gno.land/" second, so a key written on one chain still resolves on another whose domain differs. Twin of mygnoscan.TrimDomain, which answers the same question for a URL and is stricter about the characters, because its output lands inside a link.
Set creates or replaces a setting. Only an address the Authorizer accepts may call it.
It panics rather than returning an error, which is the opposite of the manager functions in config.gno, and deliberately so: a returned error from a realm call leaves the transaction SUCCESSFUL. A rejected config write that reports success is the one outcome worth ruling out here, because the caller then walks away believing the new value is live and every realm reading this key keeps serving the old one. The manager functions predate that reasoning and are frozen on chain at v0; new surface does not inherit the mistake.
SetAs is Set, performed by a registered proxy on behalf of principal.
Two checks, and both have to hold: the caller is a registered relay, and the principal it names is authorized. Neither is redundant. Dropping the first would let any realm claim any principal; dropping the second would make a registered proxy an unconditional bypass of the member list.
SplitKey takes a key apart. An unscoped key returns an empty scope, and so does a malformed one: callers pair this with validKey rather than trusting the split.
TopBlock returns the notice for the realm calling in, ready to concatenate in front of its content. Empty when there is nothing to say, which is the default and the usual case.
Three things can appear, in this order, separated by blank lines:
the pause banner, when this realm or every realm is paused
the global block.top
this realm's own block.top
The pause banner comes first because it is the one a reader has to see, and it is included here rather than left to the realm so that guarding writes with AssertWritable is enough to also explain the refusal on the page.
Global and scoped are BOTH shown rather than one overriding the other: a chain-wide warning and a per-realm changelog are different messages, and dropping either because the other exists is the surprising behaviour.
TopBlockFor is TopBlock for a named realm. Use it from a crossing function, where the caller cannot be read off the stack, or to render another realm's notice.
Unset removes a setting, so readers fall back to their built-in default. Removing a key that is not set is an error, not a no-op: it almost always means the key was misspelled here or in the realm that reads it.