Realm

blog:from-maketx-run-to-templated-contracts

gno.land/r/moul/blog:from-maketx-run-to-templated-contracts

Render

your run script is a shell script, compile it

2026-09-16 · #msgrun · #msgcall · #gas · #contracts

You write a little .gno file, you run it, it does exactly what you wanted.

gnokey maketx run -broadcast -chainid pearl-1 mykey script.gno

Then you point the same command at mainnet and it stops. Not a compile error, not a panic: the transaction never gets to run at all. The gate is vm.run_submitters, an allowlist of addresses permitted to send MsgRun. Empty means off, list one address and it is on, and mainnet has it on.

why it is gated, which tells you what to do about it

MsgRun type-checks and executes arbitrary source immediately, and it is the only code-bearing message with no other gate: MsgAddPackage clears a namespace check and a CLA check, while MsgRun's path is forced to /e/<caller>/run and has no namespace to check against.

MsgAddPackage publishes code someone can look at, under a name someone owns. MsgRun publishes code that executes once and leaves no reviewable artifact. On a chain that wants a review surface, only one of those stays open.

a run script is a shell script, structurally

You hand the chain source, it compiles, runs main(), throws the binary away. Nothing persists, nothing is named, nothing can be called again. That is sh script.sh with a blockchain for a kernel, and two properties follow.

A run script carries things a call cannot. MsgCall marshals arguments from strings and accepts primitives, byte arrays and byte slices; everything else hits panic("unexpected type in contract arg"). A script has no such limit because it does not marshal the argument, it writes the code that builds it. That is why GovDAO proposals are MsgRun-only.

A run script runs as you. The ephemeral path is gno.land/e/<your-address>/run and address derivation special-cases it, so inside a run script "the calling realm" is you. Any API resolving an actor from its caller acts on your own account. From a deployed realm the same call acts as that realm. This is the property people build on without noticing, and the one that does not survive the migration.

so the migration is not a workaround, it is compiling

A run script is a shell script; a realm is the binary you ship once the script stops changing. Move the logic on chain once and what crosses the wire afterwards is primitives again, which is all MsgCall ever needed. Every script I have looked at falls in one of three piles.

It only needs a func value or a struct. A wrapper realm supplies the closure, the user passes strings. Most projects did this already without framing it that way: a router realm is this fix.

It needs to act as the signer. This is the pile that bites, and I tried to build the wrong fix for it first, a generic hub that moves an allowance on anyone's behalf. It cannot exist, on purpose: ImpersonateTeller is on the private ledger, so a helper realm acts only as itself and a hub acting as its caller is a confused deputy. The fix is the owning realm exposing the entry point, which for a token is the standard wrapper set:

var userTeller = ledger.CallerTeller() // actor = whoever crossed in = the signer

func Approve(cur realm, spender address, amount int64) {
	checkErr(userTeller.Approve(0, cur, spender, amount))
}

It is a one-off. Migrations, inspections, "let me just try this". Keep using maketx run, locally and on testnets where the gate is off. The mistake is shipping a run script as a product surface, where every user signs arbitrary source every time they press a button.

it is also half the gas

A front-end I looked at sends one transaction per user action, and every one carries a MsgRun to grant an allowance and another to revoke it, around the real call. The hygiene is genuinely nice. The MsgRun is not cleverness, it is that the generic registry path was the only one their UI had to write once and reuse per asset. Every token involved already exposed Approve on its own realm, so the rewrite is mechanical. On that same chain, a single-message approve and a single-message registry write cost:

formgas used
MsgCall wugnot.Approve(router, 10000000)6,089,073
MsgRun with one registry write12.2M to 15.8M (10 samples)

Roughly 2x, measured 2026-09-16, a cost class rather than a byte-identical A/B since no solo MsgRun approve exists on that chain to pair. The overhead is structural: MsgRun pays ValidateMemPackage, TypeCheckMemPackage and per-byte preprocess gas on every transaction, MsgCall pays none of it because the code is already on chain and already type-checked. You compile once instead of once per user per click. Tx.msgs is a list, so the sandwich stays one transaction and one signature: you swap message kinds, not the flow.

where this stops

GovDAO proposals stay MsgRun-only, because a proposal request carries a func value. That is why the allowlist must never be emptied on a live chain: it would take governance with it and leave no in-band repair. A token nobody wrapped is still unreachable, so ask for the wrappers at listing time, not after. And local development does not change at all: gnodev, testnets and your own chain leave run_submitters empty.

The rule I kept out of all this is short: if a person will do it more than once, it is a function on a realm. maketx run is for the first time you do something, not the thousandth.

If you have a run script you think cannot be compiled, send it to me. Pile 2 is the one worth arguing about and I have only seen my own corner of it.


← all posts

Transactions

Calls, deploys, and child packages in indexed history.

Call function

Source (qfile)