gpao

command
v0.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 30, 2026 License: UNKNOWN not legal advice Imports: 0 Imported by: 0

README

gpao

gpao (gno package-approver oracle) is a small off-chain approval daemon for gno.land chains running with the inert code-submission policy (see PR #5888).

Under the inert policy, anyone may submit a package with MsgAddPackage, but it is stored inert — not typechecked, not executed, not importable. A package only becomes active once an address in the chain's PkgApprovers param sends MsgEnablePackage.

gpao automates that approver role:

  1. Watches new blocks over RPC.
  2. Extracts MsgAddPackage transactions from each block.
  3. Verifies the submitted package off-chain — typecheck and preprocess, the same two stages the chain re-runs at MsgEnablePackage — under one wall-clock budget. Stdlibs resolve from the local disk store; every /p/ and /r/ import resolves from the chain, over vm/qfile queries against the watched node, and disk is not consulted for those.
  4. If it passes and finishes in time, broadcasts a MsgEnablePackage signed by the approver key, activating the package on-chain.

The oracle proposes, the chain enforces. gpao is untrusted for correctness: the validator re-runs TypeCheckMemPackage at MsgEnablePackage time and rejects ill-typed code. gpao only decides which pending packages get proposed for activation, and when — keeping the typechecker off the critical block-execution path.

Which is why the time budget, not the correctness check, is the part that earns its keep: the chain will re-check correctness regardless, but it cannot bound how long that takes.

Install

From this directory:

make install   # go install . — puts gpao on your $PATH
make build     # go build -o build/gpao . — leaves it here instead
Docker

ghcr.io/gnolang/gno/gpao ships the binary with the repo's stdlibs and examples at /gnoroot (the baked-in --gno-root default), built from the same Dockerfile targets as the other images. Mount a gnokey keystore and pass the key password through GPAO_PASSWORD:

docker run -d \
  -v /path/to/gnokey-home:/keystore \
  -e GPAO_PASSWORD=... \
  -p 8546:8546 \
  ghcr.io/gnolang/gno/gpao \
  --remote http://node:26657 \
  --chain-id dev \
  --home /keystore \
  --key approver \
  --status-listen 0.0.0.0:8546

Usage

The approver key lives in a local gnokey keystore. Create one, fund it, add its address to the chain's PkgApprovers param, then:

gpao \
  --remote http://127.0.0.1:26657 \
  --chain-id dev \
  --home ~/.gnokey \
  --key approver

gpao unlocks the key at startup: it reads the password from $GPAO_PASSWORD if set (for unattended/service deployments), otherwise prompts once interactively.

Flags
Flag Default Description
--remote http://127.0.0.1:26657 RPC address of the node to watch; every /p/ and /r/ import is resolved from it
--chain-id (required) Chain ID used to sign approval transactions
--home gnokey home ($GNOHOME) Keystore directory holding the approver key
--key (required) Name or bech32 address of the approver key
--gno-root auto-detected gno repo root, used to resolve stdlibs and examples for typechecking
--gas-fee 100000ugnot Flat fee per approval transaction, charged whether or not it succeeds. It must cover the gas an approval asks for — at gno.land's 1ugnot/1000gas that is GasWanted <= 100,000,000; raise both together for a package that genuinely wants more
--max-spend 100000000ugnot Total fees this run will pay for approvals before it stops approving
--gas-wanted 20000000 Fallback gas wanted, used only when the node will not simulate an approval
--poll-interval 1s How often to poll for new blocks
--start-height 0 Height to start watching from (0 = current tip)
--verify-budget 10s Withhold approval from a package that takes longer than this to verify
--prepare-budget 1m How long the verifier may take to fetch a package's imports from the node before verification starts
--status-listen (off) Address to serve the read-only status API on, e.g. 127.0.0.1:8546
About --status-listen

gpao decides things nobody else can see. That a package failed to typecheck, or that its enable was simulated and the chain would reject it, is the oracle's knowledge alone -- the chain can say a package is parked, and why no enable could succeed right now, but not why this one was refused. Without somewhere to put it the only record is this process's stderr, so a submitter pays the submission charge and then hears nothing.

With --status-listen set, two read-only JSON endpoints answer for it:

curl http://127.0.0.1:8546/status                        # every verdict
curl http://127.0.0.1:8546/status/gno.land/r/you/yours   # one package
{
  "path": "gno.land/r/you/yours",
  "status": "rejected",
  "reason": "typecheck failed: undefined: Foo"
}

status is one of rejected (the code did not pass), pending (will be retried), gave_up (retried to the cap, needs a human), blocked (nothing wrong with the package -- the oracle has hit --max-spend), approved, or unknown (never seen). blocked is the one worth separating: it means to go and ask the operator, not to go and fix your code.

Off by default, and unauthenticated when on. Everything it reports concerns a package submitted in a public transaction, so there is nothing here its submitter could not already see -- but it does tell the world what this oracle is doing, so bind it where you want that read.

About --verify-budget

This is what the oracle is for. The chain re-runs the same type check and preprocess when the package is enabled, so an oracle that only checked correctness would repeat a check the validator already performs. What only an off-chain actor can judge is whether verification finishes quickly, because wall-clock time is not a consensus quantity.

Both stages are measured, since a package that type-checks quickly but preprocesses slowly costs the chain just as much. Verification runs in a child process (gpao verify-one, invoked by gpao on itself), which is what lets the budget be enforced — a goroutine cannot be killed, but a process can. It also means a package that crashes the typechecker takes down only its own child, and that the approver key is never loaded in the process handling untrusted code.

The budget starts once the child has everything the compile needs: the standard library and examples from disk, and every chain package the candidate's production files import, fetched from the node. An import that only its test files name is never fetched, because neither stage compiles a test file. Fetching is the oracle's cost, not the package's, so a slow node cannot turn a fast package into an overrun. That phase has a budget of its own, --prepare-budget, a minute by default; its expiry leaves the package pending as unavailable rather than counting against it.

The child's type-check options mirror MsgEnablePackage's exactly (production files only, no test-file evaluation), because the whole point is to predict what the validator will do — any divergence is a way to approve something the chain then rejects, or to reject something it would have accepted. Preprocess is skipped, with a log line, for a package whose imports this oracle cannot resolve — approving on the type check alone is what it did before, and refusing would penalise the package for a limitation of the oracle.

The default is deliberately generous: a real package verifies in milliseconds, and a borderline one should pass rather than lose a race with whatever else the machine is doing.

Exceeding the budget is not a rejection. The package is left pending and neither approved nor recorded as bad, and is offered again later (see Stranded packages).

A failed enable works the same way. A package that verified but whose approval failed on chain is left pending through a bounded number of attempts, because the usual causes are not the package: an unfunded storage deposit, a dependency not live yet, a namespace or governance param that moved, a block out of gas. Those clear on their own. After the last attempt the path is recorded and the log says a human is needed.

A package importing one that is parked, submitted but not yet enabled, is left pending too, uncounted, with the import named in the reason. vm/qfile cannot see a parked package, so the type check reports the import exactly as it would one that was never submitted. Before the budget starts, the verifier asks vm/qpkgmeta_json about the imports the node would not serve, in path order, and stops at the first one that is absent: an import submitted nowhere is a rejection whatever else is parked. When none is absent and one is parked, a failed type check leaves the package pending, and an error in its own code is reported once the import is live. An import the chain reports live but would not serve is fetched once more: a package enabled during the fetch resolves, and one whose sources or gnomod vm/qfile cannot serve leaves the package pending as unavailable, which is the oracle's limit and not a verdict. A file the verification never reads, such as a license, is not fetched, so the node refusing one holds nothing up.

Stranded packages

Block heights are read once and only move forward, so a package left pending is not reached again from its block. gpao remembers each submission it leaves pending, by path and height, and every 10 minutes, between candidates, looks at them again: one the chain still holds parked at that height, and which no parked import holds back, is verified again with its bytes read from its block. A package waiting on a parked import is verified once the import goes live, whoever enabled it, and its status names that import meanwhile; one whose verification could not finish is verified again, and one importing a path the chain holds nowhere is verified and rejected. Each submission is verified again at most 3 times per run, after which its status says gave_up.

New blocks go first: a look stops as soon as a candidate is queued, and resumes where it stopped once the verifier has handled it, so a path late in order is reached even while blocks keep arriving.

What is remembered lives in memory, so a restarted gpao does not know what an earlier run left pending: restart with --start-height at or below the oldest such block. Replaying is cheap, since a package already live is skipped without being fetched.

A redeploy parked over a live private realm is not verified again: the chain reports the live package for that path.

The key's address must be listed in the chain's vm PkgApprovers param, and code_submission_policy must be inert, otherwise the MsgEnablePackage transactions are rejected.

Signing options
  • Local gnokey keystore (default, recommended) — the encrypted key stays on disk and is unlocked at startup. This is the same keystore gnokey uses.
  • Mnemonic (dev only) — set $GPAO_MNEMONIC to sign from a raw mnemonic without a keystore. Convenient for local devnets; not for production.
  • tmkms / gnokms are NOT supported — those are consensus key managers that sign block votes over the privval protocol. gpao signs application transactions (MsgEnablePackage), which they cannot do. Use the gnokey keystore (or, in future, an HSM/KMS-backed keystore that can sign txs).

Where imports come from

Standard library packages are read from local disk. They ship with the binary and are not chain state, so disk is the only place to get them.

Everything else — /p/ and /r/ packages — is read from the chain, and disk is not consulted for them at all. This is the point of the daemon: the verdict has to describe what the validator will see when it runs the enable, and the validator resolves imports from chain state. A package importing something that exists in the operator's examples/ but not on the chain must not verify clean; if it did, the approval would fail its own type-check on chain, burning a fee and blaming the code for the operator's local tree.

So the verifier requires a node: gpao verify-one refuses to start without --remote, and being unable to configure itself leaves the package pending rather than rejected. There is no mode in which disk answers for a /p/ or /r/ path — a verdict reached that way describes the operator's checkout while claiming to predict the validator, which is the failure this routing exists to remove.

Import cache

A package the chain deployed at genesis is fetched once per run, not once per verification. Before it spawns the child, gpao walks the candidate's import closure, takes what it already holds and fetches the rest, and hands the child the packages it found and the paths the node said it does not serve. It keeps every public genesis package it fetches for as long as it runs: a public package path is write-once (re-adding an existing path fails), and the genesis set is a size no submitter controls. A package added after genesis is fetched again for every verification, since anyone can get one approved and imported, and keeping those would let a submitter grow the daemon's memory without bound. A private package can be redeployed under its path, so it is fetched again too. Genesis is read from the [addpkg] height the keeper stamps into every stored package's gnomod.toml: 0 for a package deployed at genesis. A hard fork that replays transactions in its genesis runs each at its original height, so the packages it replays carry that height and are fetched every verification, as on a chain without this cache.

The cache belongs to the chain it was filled from. Before each verification gpao reads block 1, and drops the cache when its hash is not the one the cache was filled under, or when the node does not give it. A chain started again under the same ID is caught whatever its tip did, and a node that lags keeps block 1 and the cache with it.

Within one verification the node's answers are cached for that run: a fetched package, and an answered "not found" as well, so the stages the budget measures never ask the node again. A transport fault is not cached. A missed import the chain reports live is fetched once more before the typecheck, since it may have been enabled after the miss.

Only a dependency's production .gno files and gnomod.toml are fetched: neither stage reads its tests, docs or licenses.

About --gas-wanted

Each approval is sized by simulating it first: the measured gas plus 20%, bounded by the chain's Block.MaxGas. The flag is only the fallback for when the node will not answer a simulation.

Sizing it by hand is hard to get right, which is why it is measured. The worst case is nearer 40,000,000 than the default — a 1 MB parked package, plus the namespace and CLA realm calls, all of which run on the approver's meter — and that ceiling depends on what the submitter sent, so no fixed number is safe for every package.

Two details, in case the numbers look odd in the logs. The probe transaction is signed at the chain's block ceiling rather than at the fallback, because a simulation executes under the transaction's own limit — sizing the probe at the fallback would run out of gas on exactly the packages worth measuring. And the ceiling is read from the chain rather than assumed — asked for before any block is followed, and retried on the poll interval until the chain answers — because the ante REFUSES a gas-wanted above Block.MaxGas instead of clamping it, so a chain configured below the tm2 default would reject every probe. An unreachable node delays the first approval instead of settling the ceiling wrongly for the life of the process.

A failed simulation does not withhold approval. It logs, falls back, and sends. Refusing to approve whenever the query path is unavailable would let anyone who can disturb it stall approvals for the whole chain.

About --max-spend

Every approval that reaches a block costs the full gas fee, whether or not the message succeeds. The daemon decides on its own when to send one, so anything that makes approvals fail repeatedly will drain the approver key. The bound stops that.

Two things reduce how often it is reached. The daemon checks whether the package is already live with nothing waiting to be enabled, and skips it if so, which is the common case when catching up with --start-height over blocks that were already approved. It asks before verifying, so a skipped package costs one query rather than a verification, and again before approving, in case the package went live meanwhile. A skipped package is reported approved, even when its submitted bytes would not have verified: the live package is what the chain runs. And it ignores transactions that failed on chain, so a submission the chain rejected never leads to an approval.

When the bound is reached the daemon says so and stops approving. It keeps watching blocks. Raise the bound or restart to continue.

Limitations

  • No catch-up persistence: --start-height lets you replay from a given height, but gpao keeps no on-disk cursor between runs.

Documentation

Overview

gpao (gno package-approver oracle) is a small off-chain approval daemon for gno.land chains running with the "inert" code-submission policy (PR #5888).

It watches new blocks, extracts MsgAddPackage transactions, runs the Gno typechecker on each submitted package off-chain, and — if the package is well-typed — broadcasts a MsgEnablePackage transaction signed by an approver key to activate the package on-chain.

The oracle is untrusted for correctness: the chain re-runs the typechecker at MsgEnablePackage time and rejects ill-typed code. gpao only decides *which* pending packages get proposed for activation, and *when*.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL