Security and validation methodology
The audit method, and the lessons accumulated through Applications #1–#5.
Method
Evidence-gated findings
No finding without a quoted line. A finding names the file, the line, and the code. A suspicion that cannot be grounded in source is not reported as a finding — it is reported as a question, or dropped.
Audit evidence is whole files. The structural outline that tooling returns by default is navigation, never evidence.
Severity
| Grade | Meaning | Gate |
|---|---|---|
| RED | Exploitable, or a design property that lets one actor deny the application to everyone | Must be resolved before deployment |
| YELLOW (material) | A real weakness — latent vulnerability, missing guard, unbounded surface, or a correctness property enforced only by discipline | Must be resolved before deployment |
| YELLOW | A weakness in documentation, coverage, or observability | Resolved before deployment in practice |
| GREEN / INFORMATIONAL | Assessed, understood, and accepted, with the reasoning written down | Recorded, not fixed |
Findings are never downgraded to reach a deployment. If a finding is graded INFORMATIONAL, the reasoning is in the FIXES.md and a reviewer can disagree with it on the record. Worked example: permission_registry's expired-tombstone growth — graded INFORMATIONAL because retired is never iterated and the cost falls on the caller's own storage deposit.
Two-pass false-positive filter
Findings are re-derived independently before they are reported. The recurring false-positive shapes in this codebase:
- a guard that is present in a caller two frames up
- a bound that is enforced by a charset rule elsewhere
- a "missing" check that the type system already makes unreachable
Scope honesty
Every audit states what was read, what was not, and what remains unverified. An audit of local source is reported as as-provided, not verified against any deployment unless the bytes were fetched from the chain.
Layering
The port is proven green before remediation is layered on. Consequence: every later test failure is attributable to the remediation alone, and the port's fidelity is not entangled with the fix's correctness.
Port scripts are neutered after use (sys.exit, exit code verified). They pre-date the remediation layer, and re-running one would silently revert the fixes.
Accumulated lessons — Applications #1–#5
These are the findings that recurred, generalized into rules.
Caller identity comes from the realm capability, not the stack
Derive identity from a crossing entrypoint's runtime-current cur.Previous().Address(), inline. Never from a stack-walking unsafe.PreviousRealm() inside a non-crossing helper.
// WRONG — bug Class 2. Returns whatever was previous at the last realm
// boundary, not the immediate caller.
func caller() address { return unsafe.PreviousRealm().Address() }
// RIGHT
func Deregister(cur realm, name string) {
owner := cur.Previous().Address()
…
}
This was found in all five applications — Y1 in every case. In every case it was latent, not live: correct on all existing paths, enforced only by call-site discipline. That is precisely why it was fixed rather than accepted. A property that holds by discipline stops holding the first time someone adds a caller.
Keep unsafe only where stack-walking is what is actually wanted: OriginSend() for the tx envelope, and OriginCaller() inside init().
Reject stray coin sends on non-payable crossing functions
A realm with no banker cannot move coins out. Coins attached to any call strand at its address forever.
func rejectStraySend(cur realm) {
if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
panic("this realm does not accept coins")
}
}
First statement of every crossing entrypoint. Guard on IsUserCall(), not IsUser() — the MsgRun ephemeral can consume the envelope before forwarding control.
All five non-custodial pipeline applications ship this guard, and it is proven live on each by attaching coins to an otherwise-valid call — so the coin guard is the only possible cause of the abort.
Bound third-party-reachable rendering
Render is served to everyone. If a third party can grow the state it iterates, they can make the page expensive — and the cost falls on readers, not on whoever grew it.
Cap the row count, and emit an explicit truncation notice so the bound is visible rather than silently lossy. service_registry: MaxRenderServices = 25. permission_registry: 20 resources, 8 permissions.
Related: iterate deterministically. Never range a map into rendered output.
Sanitize with the ecosystem sanitizer, not a hand-rolled one
A hand-rolled escaper that misses < and > lets raw HTML — including <script> — reach a shared page (timelock_guardian Y3). One that handles brackets but not markdown link syntax permits link/image phishing (service_registry Y5).
Use gno.land/p/nt/markdown/sanitize/v0.
InlineTextescapes\ * _ [ ] ( ) ~ > - + . ! ` # < &— including., so a hostname never appears verbatim in output.TableCell=InlineText+ tab→space +|→\|.- Not idempotent. Wrap exactly once.
- Truncate raw text before escaping, never after — truncating after can cut an escape sequence in half.
Two-step ownership transfers
A one-step TransferOwnership to a wrong or dead address bricks the entry. In a shared namespace this is worse than it sounds: a bricked entry can never be deregistered, so it permanently holes the name.
Two-step: Transfer… nominates, Accept… (nominee-only) completes, and a Cancel… clears the nomination. A nomination must be inert — it confers nothing until consent. Proven live in both directions on service_registry.
Check quotas at consent, not at nomination
Where ownership changes affect a per-owner quota, check the quota at consent time. Checking at nomination lets an unsolicited nomination push an account past its limit without that account's agreement — and, worse, lets a nomination consume quota that the nominee never asked for.
Corollary: move the quota index atomically with the ownership change, and keep it zero-free (delete the key at zero rather than leaving a 0 entry).
Avoid unbounded attacker-controlled iteration
O(n) scans over a globally shared, attacker-growable list are a starvation vector. timelock_guardian Y2/Y4: a long-delay sybil wall in a global FIFO window made victims' expired actions unreapable, wedging quota and SetGuardian.
Fix shape: partition per-target (map[string][]string, each bounded), do per-target sweeps rather than a global budget, derive counters from list length so there is no counter to desync, and add a permissionless recovery path so that recovery never depends on sweep order.
Cap what a single actor can occupy in a shared permissionless registry
Namespace monopolization was the RED finding in both registries (#4, #5): one funded key could take the entire registry.
Fix: a per-owner cap (MaxServicesPerOwner = 20, MaxResourcesPerAdmin = 20) backed by an O(1) index — not a scan.
Residual, stated honestly: this raises monopolization from one funded key to N funded keys. It does not make it impossible. That is written in the FIXES.md rather than implied away.
Distinguish documented design limitations from vulnerabilities
Not every sharp edge is a bug. A frozen-entry wedge whose only cause is the registrant's own key loss is a documented limitation with guidance, not a vulnerability. An unbounded-hops read is a cost warning for integrators, not a defect.
The test is whether an attacker can cause it, or only the owner can cause it to themselves.
Know when a fix would change what the application is
service_registry Y6: pkgPath is an unverified claim. The maximal fix — self-proving registration, as r/demo/defi/grc20reg does — would mean that only realms, never their human operators, could ever register a name.
That is a fundamentally different application, which is a hard boundary, not a routine in-scope fix. The refusal is recorded, and the proportionate remediation shipped instead:
- a 3-point INTEGRATOR CONTRACT carried verbatim on
Resolve, inSPEC.mdand inREADME.md— attestation-not-proof / name-is-not-authorization / target-can-change - an event change (Y7) making a silent repoint observable
Record the refusal and the reasoning so a reviewer can disagree on evidence.
Preserve failure atomicity
panic reverts the whole transaction; a returned error does not. Debit before payout (checks-effects-interactions) so that an abort reverts the debit and the send together. Never leave a partial state mutation on an error path.
Never trust a designation passed as a parameter
Taking creator or owner as a string/address parameter is designation forgery. Derive it from cur.Previous().Address().
Corollary for /p/ packages: an interface method signature must never contain cur realm. Take an address and let the caller derive it. A secondary rlm realm parameter must be rlm.IsCurrent()-checked before use.
Deployment-integrity rules
Verify the deployment payload bytes before broadcasting
The deploy tool takes file bodies as strings, not paths. Transcribing a 600-line file into a tool argument is a silent byte-drift risk.
Invert the order: write the transcription to disk, cmp it against the committed source before broadcast, and only then pass that exact verified string. Do not deploy and hope the post-hoc check catches it.
Verify the deployed source bytes after broadcasting
Fetch the on-chain file (vm/qfile) and compare hashes against the committed bytes. A deployment record without a byte match is not a deployment record.
Expected difference: gnomod.toml always differs on-chain — the chain appends an [addpkg] creator/height stanza. That is metadata, not divergence. Confirm the form against an already-verified sibling.
Also verify the file list: a test file must not have leaked into the deployed package.
Build the payload from the committed bytes, not the working tree
Clone fresh, build the payload from the clone, then cmp the working tree against it. The working tree is where uncommitted drift lives.
An unchanged [addpkg] height proves no redeploy
Realm bytes are immutable after addpkg. Re-read every sibling's height after each deployment; unchanged heights are positive proof that nothing else moved.
Never fabricate a deployment record
Every claim carries a transaction hash, a height, and a verifiable artifact. If something was not exercised, it is listed as not exercised, with the reason and the committed test that covers it instead.
Never bypass tool or harness safety controls
If a permission classifier blocks a tool, report the block and record the resulting coverage gap. Do not route around it with gnokey, shell signing, or key-file access.
Worked example: during Application #5, key generation was blocked by the harness. No workaround was attempted; the pre-existing keys were used, and the second-identity coverage limit was written into the deployment record.
Live verification
Deployment is not the end of verification — it is the start of the real one.
- Empty-state reads before any write — confirms
init()ran and no read path panics on empty state. - Functional writes — each recorded by tx hash and height, with the resulting state read back.
- Adversarial battery — every guard attacked, with the abort message byte-matched to the audited source.
- Invariants — bank state, conservation identities, sibling heights.
Adversarial probes run at simulate: the deploy tooling pre-simulates every call, so a rejected attack consumes no gas, touches no state, and consumes no sequence number. There is no excuse for an unexercised guard.
Between 6 and 9 distinct attack shapes were rejected live per pipeline application. Full per-application results: catalog/applications.md.
Value invariants
For every realm that holds funds:
vault,coindemo,market:held == users + fees + surplusbounties:held == bounties + users + fees + surplusgrants:held == grants + users + fees + surplusfee_split: per-splitsum(balances) + TotalClaimed == TotalDeposited; realm-levelbank == Σ claimables + feesAccrued
Checked against real bank state (auth/accounts) at every checkpoint, and drained to exact zero at the end.
For every realm that does not hold funds — all five pipeline applications — the invariant is stronger and simpler: the realm address is exists:false / coins "0" at every checkpoint, including after a deliberate stray-send attempt, and the deployed bytes import no chain/banker.
Known residual limitations
Recorded rather than resolved:
- Roles are hot agent keys. This is a testnet-only posture. The deployed
fee_splitv1's fee admin is the hot agent key; nominating a user-controlled backup admin needs an address from the user. feeledger's unexported-pointer rule is documented here and in pearl/INFRASTRUCTURE.md but not in-package. The deployed copy is frozen; recorded for its v1 successor.coindemo'sSurplus()doc comment saysHeld() - UsersTotal()while the body usesledger.Liabilities()— provably equal in this realm (fee cap 0 pinsFeesAccruedto 0 forever). Left as-is to preserve byte-custody with the deployed file.bountiesv1 carries the pre-snapshotBountyCancelledevent schema (noamountattribute) and accepts self-realm winners. Both fixed in the local vNext, which is not deployed.- Time-dependent paths are not livable in a session.
timelock_guardian'sExpiresuccess path needs 30 real days past readiness; reservation expiries need 90 days. These are pinned by committed tests usingtesting.SkipHeights, and the limitation is stated in each record. - Bounds and cap tests are economically impractical on-chain — each would need dozens to hundreds of funded transactions. Correlated to committed tests in every record.