Transaction

BD67E9398BABB6…63234BB87517

Block 357,964 · index 1 · indexed

Summary

Hash
BD67E9398BABB6A9EF0FA61F4E68152739D08229BD3D385203F663234BB87517
Block
357,964
Size
40796 bytes
Gas used
53,913,330 / 250,000,000
Fee
250000 ugnot
Status
success

Messages

Attached funds
12000000 ugnot

Arguments · 9

  1. #1upgrade_registry
  2. #2gnomod.toml
  3. #3module = "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/upgrade_registry" gno = "0.9"
  4. #4upgrade_registry.gno
  5. #5package upgrade_registry import ( "chain" "chain/runtime/unsafe" "strconv" "strings" ) // ContractEntry represents a registered contract and its upgrade status. // // An entry's Address is ALWAYS the address that called Register — a // contract can only register itself. That call is the proof of control: // nobody can claim an address they don't command, so a Deprecate // redirect on an entry always originates from whoever genuinely // controlled the contract. Entries are permanent by design: migration // history is what consumers rely on, so it must not be erasable (and a // delete would reopen re-registration squatting). // // Ownership is two-step (re-audit 2026-09-02): Register/Transfer only // NOMINATE an owner; the nominee must AcceptOwnership. Until then the // entry is owned by the contract itself, so nobody's address can be // attached as "owner" without their consent. type ContractEntry struct { Address address Owner address // current manager (accepted) PendingOwner address // nominated, not yet accepted; empty if none Name string Deprecated bool Successor address // address of the upgraded contract, empty if current } const ( MaxNameLen = 64 // MaxChainLen bounds two WALKS, and is NOT a bound on how long a // migration chain can become (round-4 audit: the previous comment // named only the first of the two uses, which read as though chain // length itself were capped at 50): // // 1. GetMigrationChain's traversal and output — a chain longer than // this is explicitly marked truncated rather than presented as // ending early. // 2. Deprecate's cycle-verification walk, which refuses rather than // walking unbounded. // // Neither bounds TOTAL chain length, because Deprecate's walk runs // FORWARD from the new successor and never sees the chain BEHIND the // predecessor. Its cost is proportional to the chain AHEAD of the // successor, which is: // // - zero hops when the successor is terminal — the ordinary // tail-append. The walk breaks immediately, so tail-appending grows // a chain without limit. The realm's own // TestMigrationChain_MarksTruncation builds MaxChainLen+5 links this // way and is expected to succeed. // - O(chain ahead) when the successor is ITSELF already deprecated — a // head-append. Deprecate does NOT reject an already-deprecated // successor (it validates only existence and ownership), so this is // a permitted call, and past MaxChainLen hops it refuses. See // TestDeprecate_BoundedWalkRefusal. // // This is why GetLatest documents an UNBOUNDED cost (see its COST // WARNING) — that warning is accurate, not conservative. // // Extending a chain requires owning both ends of THE ONE NEW LINK at // call time (see Deprecate, which checks exactly that and never // re-verifies earlier links). That is a per-call consent check, not an // invariant of the finished chain: an owner who transfers away only the // TAIL entry hands the recipient the ability to extend it, and so to // move GetLatest for every entry behind it, without that recipient ever // having owned the root. Acquiring the tail still takes the two-step // nominate/accept handoff from its owner, so no chain can be lengthened // without some consenting owner along it. MaxChainLen = 50 // Render bounds (re-audit): rendering is paginated and the per-row // "latest" annotation walks a bounded number of hops, so an // attacker-built long chain cannot blow up the realm page. MaxRenderEntries = 100 RenderLatestHops = 10 ) // NOTE (re-audit): there is deliberately NO global entry cap, and the // reason is the second clause below, NOT the first. // // Entries are one-per-address by construction (self-registration), but // round-4 audit correction: that does NOT make spam expensive. Addresses // are cheap — anyone may deploy unlimited realms under their own // personal-address namespace, and each deployed path derives a distinct // address — so entry count is effectively unbounded regardless. The // earlier wording presented per-address uniqueness as a spam control; it // is not one. // // The cap is omitted because a hard cap, combined with the // registered-successor rule, would let an attacker saturate the registry // and permanently disable deprecation for every legitimate entry. That // failure is permanent and global; unbounded growth is neither. Growth // is also paid for by the registrant, not by this realm's operator. // // Consequence accepted here and NOT mitigated: Render shows the most // recent MaxRenderEntries entries, so a registrant who floods that many // entries pushes older ones off this realm's rendered page permanently. // The data remains queryable by address via GetInfo/GetMigrationChain. var ( entries map[address]*ContractEntry entryAddrs []address // insertion-ordered for deterministic Render ownerContracts map[address][]address ) func init() { entries = make(map[address]*ContractEntry) entryAddrs = []address{} ownerContracts = make(map[address][]address) } // rejectStraySend aborts when coins are attached to a call (audit Y2): // this realm handles no funds and holds no banker, so an attached send // would strand on the realm address forever. Aborting reverts the // transfer back to the sender. Fails open for realm-routed calls, whose // attached send lands on the intermediary realm, never here. func rejectStraySend(cur realm) { if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 { panic("this realm does not accept coins") } } // isValidName restricts names to lowercase alphanumeric with // underscores. Names appear in rendered markdown and query output, so // no delimiter or markdown character may enter one. func isValidName(name string) bool { if name == "" || len(name) > MaxNameLen { return false } for _, c := range name { if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { return false } } return true } func ownerIndexRemove(owner, contractAddr address) { old := ownerContracts[owner] for i, addr := range old { if addr == contractAddr { ownerContracts[owner] = append(old[:i], old[i+1:]...) break } } if len(ownerContracts[owner]) == 0 { delete(ownerContracts, owner) } } // ---------- write operations ---------- // Register adds the CALLING contract to the registry — the entry's // address is the caller's own address, which is the proof of control. // owner NOMINATES a manager (team EOA or governance realm); it holds no // power until it calls AcceptOwnership (nobody can be made an owner // without consent — re-audit). Pass "" to manage from the contract // itself — but note (audit Y4): entries are permanent, so an entry // managed by a contract that has no code path for calling this // registry again is FROZEN as active forever: never deprecatable, // never transferable. Realms registering from init() should nominate // an EOA or governance manager instead. func Register(cur realm, name string, owner address) string { rejectStraySend(cur) if !isValidName(name) { panic("name must be 1-" + strconv.Itoa(MaxNameLen) + " chars, lowercase alphanumeric with underscores only") } contractAddr := cur.Previous().Address() if _, exists := entries[contractAddr]; exists { panic("contract already registered: " + string(contractAddr)) } pending := address("") if owner != "" && owner != contractAddr { if !owner.IsValid() { panic("invalid owner address: " + string(owner)) } pending = owner } entries[contractAddr] = &ContractEntry{ Address: contractAddr, Owner: contractAddr, PendingOwner: pending, Name: name, } entryAddrs = append(entryAddrs, contractAddr) ownerContracts[contractAddr] = append(ownerContracts[contractAddr], contractAddr) // `owner` is the EFFECTIVE owner after this event, which at registration // is the contract itself; `pendingOwner` is the nomination, empty when // the entry is self-managed. Emitting both is what makes the key `owner` // mean the same thing in every event this realm emits (round-4 audit Y2: // omitting it here left `owner` readable as "the authorizing actor" in // some events and "the new owner" in others). chain.Emit("Register", "contract", string(contractAddr), "name", name, "owner", string(contractAddr), "pendingOwner", string(pending), ) return "registered " + name + " at " + string(contractAddr) } // AcceptOwnership completes a nominated ownership: only the pending // owner can accept, and only acceptance moves the entry (and the // ownerContracts index) to them. func AcceptOwnership(cur realm, contractAddr address) string { rejectStraySend(cur) entry := mustGet(contractAddr) c := cur.Previous().Address() if entry.PendingOwner == "" || c != entry.PendingOwner { panic("caller is not the pending owner of " + string(contractAddr)) } previousOwner := entry.Owner ownerIndexRemove(entry.Owner, contractAddr) ownerContracts[c] = append(ownerContracts[c], contractAddr) entry.Owner = c entry.PendingOwner = "" chain.Emit("OwnershipAccepted", "contract", string(contractAddr), "owner", string(c), "previousOwner", string(previousOwner), ) return "ownership accepted by " + string(c) } // TransferOwnership NOMINATES a new owner for a registry entry; the // nominee must AcceptOwnership to take control (two-step — re-audit). // Passing "" clears a pending nomination. func TransferOwnership(cur realm, contractAddr, newOwner address) string { rejectStraySend(cur) entry := mustGet(contractAddr) if entry.Owner != cur.Previous().Address() { panic("only the owner can transfer ownership") } if newOwner == "" { // Emit only when a nomination actually existed (round-4 audit L2: // clearing an entry that has no pending owner is a no-op, and an // event for it is indistinguishable from a real clearing to an // indexer). The return value is unconditional either way — this // changes what is OBSERVED, not what happens. cleared := entry.PendingOwner entry.PendingOwner = "" if cleared != "" { chain.Emit("OwnershipNominationCleared", "contract", string(contractAddr), "owner", string(entry.Owner), "clearedNominee", string(cleared), ) } return "pending ownership nomination cleared" } if !newOwner.IsValid() { panic("invalid new owner address: " + string(newOwner)) } entry.PendingOwner = newOwner chain.Emit("OwnershipNominated", "contract", string(contractAddr), "owner", string(entry.Owner), "pendingOwner", string(newOwner), ) return "ownership nominated to " + string(newOwner) + " (pending acceptance)" } // Deprecate marks a contract as deprecated and points to its successor. // Only the entry's owner can deprecate. The successor must itself be a // REGISTERED entry — registration is self-proving, so a successor can // never be a dangling pointer or an address squatted by a third party — // and the successor entry must be OWNED BY THE CALLER (re-audit: without // consent, an attacker could chain their entry INTO a legitimate // contract, forging "official predecessor" provenance). Consent is // checked AT CALL TIME: transferring the successor entry away later // does not unlink an existing chain — the attestation is that both // ends shared an owner when the deprecation was recorded. func Deprecate(cur realm, contractAddr, successorAddr address) string { rejectStraySend(cur) entry := mustGet(contractAddr) if entry.Owner != cur.Previous().Address() { panic("only the owner can deprecate this contract") } if entry.Deprecated { panic("contract is already deprecated") } if successorAddr == contractAddr { panic("successor cannot be the contract itself") } succ, exists := entries[successorAddr] if !exists { panic("successor is not a registered contract: " + string(successorAddr)) } if succ.Owner != cur.Previous().Address() { panic("successor entry is not owned by the caller: " + string(successorAddr)) } // Prevent circular chains: walk from successor and ensure we don't // loop back to contractAddr. Successors are immutable once set, so // a check over the existing chain is complete. visited := make(map[address]bool) visited[contractAddr] = true walk := successorAddr for hops := 0; ; hops++ { if hops > MaxChainLen { // bounded-walk discipline (round-3 audit): a successor chain // this long is pathological; refuse rather than walk unbounded panic("successor chain too long to verify; cannot deprecate") } if visited[walk] { panic("deprecating " + string(contractAddr) + " to " + string(successorAddr) + " would create a circular migration chain") } e, exists := entries[walk] if !exists || !e.Deprecated || e.Successor == "" { break } visited[walk] = true walk = e.Successor } entry.Deprecated = true entry.Successor = successorAddr chain.Emit("Deprecate", "contract", string(contractAddr), "name", entry.Name, "successor", string(successorAddr), ) return "deprecated " + entry.Name + ", successor: " + string(successorAddr) } // ---------- read-only queries ---------- // GetLatest follows the migration chain from a contract address and // returns the latest non-deprecated address. Safe against circular refs. // // COST WARNING (audit Y3): unlike the write-path walks, this read is // deliberately UNBOUNDED in hops so it always resolves the true // endpoint. Chain length is attacker-buildable, so an on-chain // integrator resolving an UNTRUSTED address inside its own transaction // must treat the gas cost as O(chain length) — wrap it, bound the // input set, or resolve off-chain via query. func GetLatest(contractAddr address) address { visited := make(map[address]bool) current := contractAddr for { entry, exists := entries[current] if !exists { return current } if !entry.Deprecated || entry.Successor == "" { return current } if visited[current] { return current // break on circular } visited[current] = true current = entry.Successor } } // GetMigrationChain returns the full upgrade path starting from the // given address, at most MaxChainLen hops. A chain longer than the // bound is explicitly marked as truncated (re-audit: silent truncation // presented a mid-chain node as the endpoint). func GetMigrationChain(contractAddr address) string { visited := make(map[address]bool) current := contractAddr var parts []string for { if len(parts) >= MaxChainLen { parts = append(parts, "... (truncated at "+strconv.Itoa(MaxChainLen)+ " hops; use GetLatest for the endpoint)") break } entry, exists := entries[current] if !exists { if len(parts) == 0 { return "not found: " + string(contractAddr) } parts = append(parts, string(current)+" (unregistered)") break } if visited[current] { parts = append(parts, string(current)+" (circular)") break } visited[current] = true tag := "" if entry.Deprecated { tag = " [deprecated]" } parts = append(parts, string(entry.Address)+" ("+entry.Name+")"+tag) if !entry.Deprecated || entry.Successor == "" { break } current = entry.Successor } return strings.Join(parts, " -> ") } // GetInfo returns a one-line summary for a contract. func GetInfo(contractAddr address) string { entry := mustGet(contractAddr) status := "active" if entry.Deprecated { status = "deprecated -> " + string(entry.Successor) } return entry.Name + " | owner: " + string(entry.Owner) + " | " + status } // GetOwnerContracts returns all contract addresses whose ACCEPTED owner // is the given address (nominations don't count until accepted). func GetOwnerContracts(owner address) string { addrs, exists := ownerContracts[owner] if !exists || len(addrs) == 0 { return "none" } parts := make([]string, len(addrs)) for i, a := range addrs { parts[i] = string(a) } return strings.Join(parts, ", ") } // ---------- render ---------- // renderLatest walks at most RenderLatestHops hops for the per-row // annotation; longer chains are marked rather than walked (render gas // bound — re-audit). func renderLatest(contractAddr address) (address, bool) { current := contractAddr for i := 0; i < RenderLatestHops; i++ { entry, exists := entries[current] if !exists || !entry.Deprecated || entry.Successor == "" { return current, true } current = entry.Successor } // round-3 audit off-by-one: the node reached ON the budget boundary // may itself be terminal — check before declaring the walk incomplete entry, exists := entries[current] if !exists || !entry.Deprecated || entry.Successor == "" { return current, true } return current, false } // Render returns a markdown overview of the most recent entries. // Never panics. // // The rendered page carries an explicit scope disclosure (round-4 audit). // What this registry proves is narrow and easy to over-read: an entry's // ADDRESS is self-registered and therefore verified, but its NAME is an // arbitrary self-asserted label that is neither unique nor checked // against anything. Two entries may carry the same name, and a name may // impersonate a well-known project. A reader who keys on the bold name // rather than the address is trusting something this realm does not // attest. Stating that in the output is the only available mitigation: // the entries are immutable, and name uniqueness is deliberately NOT // enforced because first-come uniqueness on a permissionless immutable // registry would let an attacker burn every valuable name permanently. func Render(path string) string { disclosure := "> [!NOTE]\n" + "> Addresses are self-registered and therefore verified: an entry exists\n" + "> only because that address itself called `Register`. **Names are not.**\n" + "> A name is an arbitrary self-asserted label — it is NOT unique, NOT\n" + "> verified, and may impersonate another project. Identify contracts by\n" + "> address, never by name.\n\n" if entries == nil || len(entries) == 0 { return "# Upgrade Registry\n\n" + disclosure + "No contracts registered.\n" } var b strings.Builder b.WriteString("# Upgrade Registry\n\n") b.WriteString(disclosure) b.WriteString("**Registered contracts:** " + strconv.Itoa(len(entries)) + "\n\n") show := entryAddrs if len(show) > MaxRenderEntries { b.WriteString("_Showing the most recent " + strconv.Itoa(MaxRenderEntries) + " entries of " + strconv.Itoa(len(show)) + ". Older entries are not " + "listed here and remain queryable by address via GetInfo and " + "GetMigrationChain._\n\n") show = show[len(show)-MaxRenderEntries:] } var active []string var deprecated []string for _, addr := range show { entry := entries[addr] if entry == nil { continue } line := "- **" + entry.Name + "** (`" + string(addr) + "`) — owner: `" + string(entry.Owner) + "`" if entry.Deprecated { line += " — **DEPRECATED** -> `" + string(entry.Successor) + "`" latest, complete := renderLatest(addr) if !complete { line += " (long chain; use GetLatest)" } else if latest != entry.Successor { line += " (latest: `" + string(latest) + "`)" } deprecated = append(deprecated, line) } else { active = append(active, line) } } if len(active) > 0 { b.WriteString("## Active\n\n") for _, l := range active { b.WriteString(l + "\n") } b.WriteString("\n") } if len(deprecated) > 0 { b.WriteString("## Deprecated\n\n") for _, l := range deprecated { b.WriteString(l + "\n") } b.WriteString("\n") } return b.String() } // ---------- helpers ---------- func mustGet(addr address) *ContractEntry { entry, ok := entries[addr] if !ok { panic("contract not found: " + string(addr)) } return entry }
  6. #6upgrade_registry_test.gno
  7. #7package upgrade_registry import ( "chain" "strconv" "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // In production the registrant is usually a realm; user addresses give // the same self-registration semantics in tests (Register binds the // entry to the CALLER's address either way). var ( v1 = testutils.TestAddress("contract_v1") v2 = testutils.TestAddress("contract_v2") v3 = testutils.TestAddress("contract_v3") teamEOA = testutils.TestAddress("team") attacker = testutils.TestAddress("attacker") ) // resetState resets all package-level state between tests. func resetState() { entries = make(map[address]*ContractEntry) entryAddrs = []address{} ownerContracts = make(map[address][]address) } // register self-registers addr under name; when owner is non-empty the // nominee accepts, completing the two-step handover. func register(cur realm, addr address, name string, owner address) { testing.SetRealm(testing.NewUserRealm(addr)) Register(cross(cur), name, owner) if owner != "" && owner != addr { testing.SetRealm(testing.NewUserRealm(owner)) AcceptOwnership(cross(cur), addr) } } // ---------- Register (proof of control) ---------- func TestRegister_BindsToCallerAddress(cur realm, t *testing.T) { resetState() register(cur, v1, "vault", teamEOA) e := entries[v1] uassert.Equal(t, string(v1), string(e.Address)) uassert.Equal(t, string(teamEOA), string(e.Owner)) uassert.Equal(t, "vault", e.Name) // the audit's P1: there is no way to register an address you do not // control — the entry address is ALWAYS the caller's own. An // attacker registering gets an entry for themselves, not the victim. testing.SetRealm(testing.NewUserRealm(attacker)) Register(cross(cur), "fake_vault", "") uassert.Equal(t, string(attacker), string(entries[attacker].Address)) _, hijacked := entries[v2] uassert.False(t, hijacked, "no entry may exist for an address that never registered itself") } func TestRegister_OwnerDefaultsToContract(cur realm, t *testing.T) { resetState() register(cur, v1, "vault", "") uassert.Equal(t, string(v1), string(entries[v1].Owner)) } func TestRegister_DuplicateRejected(cur realm, t *testing.T) { resetState() register(cur, v1, "vault", "") testing.SetRealm(testing.NewUserRealm(v1)) uassert.AbortsWithMessage(t, cur, "contract already registered: "+string(v1), func() { Register(cross(cur), "vault_again", "") }, ) } func TestRegister_NameValidation(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(v1)) for _, bad := range []string{"", "Vault", "has space", "tick`name", strings.Repeat("a", MaxNameLen+1)} { uassert.AbortsContains(t, cur, "name must be", func() { Register(cross(cur), bad, "") }, ) } } func TestRegister_InvalidOwnerRejected(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(v1)) uassert.AbortsWithMessage(t, cur, "invalid owner address: junk", func() { Register(cross(cur), "vault", address("junk")) }, ) } // ---------- Deprecate ---------- func TestDeprecate_OwnerOnly(cur realm, t *testing.T) { resetState() register(cur, v1, "vault_v1", teamEOA) register(cur, v2, "vault_v2", teamEOA) testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "only the owner can deprecate this contract", func() { Deprecate(cross(cur), v1, v2) }, ) testing.SetRealm(testing.NewUserRealm(teamEOA)) Deprecate(cross(cur), v1, v2) uassert.Equal(t, string(v2), string(GetLatest(v1))) } func TestDeprecate_SuccessorMustBeRegistered(cur realm, t *testing.T) { resetState() register(cur, v1, "vault_v1", teamEOA) // the audit's dangling-successor + front-running route: pointing at // an unregistered address is refused outright, and a successor entry // can only ever be created by the successor contract itself testing.SetRealm(testing.NewUserRealm(teamEOA)) uassert.AbortsWithMessage(t, cur, "successor is not a registered contract: "+string(v2), func() { Deprecate(cross(cur), v1, v2) }, ) } func TestDeprecate_SelfAndDoubleRejected(cur realm, t *testing.T) { resetState() register(cur, v1, "vault_v1", teamEOA) register(cur, v2, "vault_v2", teamEOA) testing.SetRealm(testing.NewUserRealm(teamEOA)) uassert.AbortsWithMessage(t, cur, "successor cannot be the contract itself", func() { Deprecate(cross(cur), v1, v1) }, ) Deprecate(cross(cur), v1, v2) uassert.AbortsWithMessage(t, cur, "contract is already deprecated", func() { Deprecate(cross(cur), v1, v2) }, ) } func TestDeprecate_CircularChainRejected(cur realm, t *testing.T) { resetState() register(cur, v1, "vault_v1", teamEOA) register(cur, v2, "vault_v2", teamEOA) register(cur, v3, "vault_v3", teamEOA) testing.SetRealm(testing.NewUserRealm(teamEOA)) Deprecate(cross(cur), v1, v2) Deprecate(cross(cur), v2, v3) // closing the loop from the tail must abort uassert.AbortsWithMessage(t, cur, "deprecating "+string(v3)+" to "+string(v1)+" would create a circular migration chain", func() { Deprecate(cross(cur), v3, v1) }, ) } // ---------- chain queries ---------- func TestGetLatest_WalksChain(cur realm, t *testing.T) { resetState() register(cur, v1, "vault_v1", teamEOA) register(cur, v2, "vault_v2", teamEOA) register(cur, v3, "vault_v3", teamEOA) testing.SetRealm(testing.NewUserRealm(teamEOA)) Deprecate(cross(cur), v1, v2) Deprecate(cross(cur), v2, v3) uassert.Equal(t, string(v3), string(GetLatest(v1))) uassert.Equal(t, string(v3), string(GetLatest(v2))) uassert.Equal(t, string(v3), string(GetLatest(v3))) // unknown addresses come back unchanged uassert.Equal(t, string(attacker), string(GetLatest(attacker))) } func TestGetMigrationChain(cur realm, t *testing.T) { resetState() register(cur, v1, "vault_v1", teamEOA) register(cur, v2, "vault_v2", teamEOA) testing.SetRealm(testing.NewUserRealm(teamEOA)) Deprecate(cross(cur), v1, v2) out := GetMigrationChain(v1) uassert.True(t, strings.Contains(out, "vault_v1"), "chain must name v1") uassert.True(t, strings.Contains(out, "[deprecated]"), "chain must tag deprecation") uassert.True(t, strings.Contains(out, "vault_v2"), "chain must name v2") uassert.Equal(t, "not found: "+string(attacker), GetMigrationChain(attacker)) } // ---------- TransferOwnership ---------- func TestTransferOwnership(cur realm, t *testing.T) { resetState() register(cur, v1, "vault", teamEOA) newOwner := testutils.TestAddress("newowner") testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "only the owner can transfer ownership", func() { TransferOwnership(cross(cur), v1, attacker) }, ) testing.SetRealm(testing.NewUserRealm(teamEOA)) uassert.AbortsWithMessage(t, cur, "invalid new owner address: junk", func() { TransferOwnership(cross(cur), v1, address("junk")) }, ) TransferOwnership(cross(cur), v1, newOwner) // nomination alone changes nothing (two-step) uassert.Equal(t, string(teamEOA), string(entries[v1].Owner)) uassert.Equal(t, string(v1), GetOwnerContracts(teamEOA)) testing.SetRealm(testing.NewUserRealm(newOwner)) AcceptOwnership(cross(cur), v1) uassert.Equal(t, string(newOwner), string(entries[v1].Owner)) uassert.Equal(t, "none", GetOwnerContracts(teamEOA)) uassert.Equal(t, string(v1), GetOwnerContracts(newOwner)) } // ---------- render ---------- func TestRender_EmptyState(cur realm, t *testing.T) { resetState() out := Render("") uassert.True(t, strings.HasPrefix(out, "# Upgrade Registry\n\n"), "heading first") uassert.True(t, strings.HasSuffix(out, "No contracts registered.\n"), "empty state must still say the registry is empty") // round-4 audit: the scope disclosure is not conditional on there being // entries — an empty page must not read as an endorsement-free zone. uassert.True(t, strings.Contains(out, "**Names are not.**"), "the name-scope disclosure must appear even in the empty state") } // TestRender_CarriesNameScopeDisclosure is the regression guard for the // round-4 audit finding that all three review passes raised: the rendered // page gives a self-asserted NAME the most visual weight while only the // ADDRESS is actually proven. Names are deliberately never made unique // (first-come uniqueness on an immutable permissionless registry would let // an attacker burn every valuable name forever), so disclosing the scope in // the output is the entire mitigation. It cannot be added after deploy. // // Each assertion below names a distinct claim, so deleting or softening any // one of them fails this test rather than silently degrading the warning. func TestRender_CarriesNameScopeDisclosure(cur realm, t *testing.T) { resetState() register(cur, v1, "vault_v1", teamEOA) // The impersonation this disclosure exists for: a second, unrelated // address registering the SAME name is permitted and renders equally. register(cur, attacker, "vault_v1", "") out := Render("") for _, claim := range []string{ "> [!NOTE]", "Addresses are self-registered and therefore verified", "**Names are not.**", "NOT unique", "may impersonate another project", "address, never by name.", } { uassert.True(t, strings.Contains(out, claim), "rendered disclosure must carry the claim: "+claim) } // Both duplicate-named entries really are present — the disclosure is // describing a live condition, not a hypothetical one. uassert.Equal(t, 2, strings.Count(out, "**vault_v1**"), "duplicate names are permitted and both must render") } func TestRender_Sections(cur realm, t *testing.T) { resetState() register(cur, v1, "vault_v1", teamEOA) register(cur, v2, "vault_v2", teamEOA) testing.SetRealm(testing.NewUserRealm(teamEOA)) Deprecate(cross(cur), v1, v2) out := Render("") uassert.True(t, strings.Contains(out, "## Active"), "active section") uassert.True(t, strings.Contains(out, "## Deprecated"), "deprecated section") uassert.True(t, strings.Contains(out, "DEPRECATED"), "deprecation flag") uassert.True(t, strings.Contains(out, string(v2)), "successor shown") } // ---------- re-audit regressions (2026-09-02) ---------- func TestOwnerNomination_RequiresConsent(cur realm, t *testing.T) { resetState() // re-audit P2: registering with someone else's address as owner must // NOT attach them — no pollution of GetOwnerContracts, no GetInfo // showing them as owner, until they accept victim := teamEOA testing.SetRealm(testing.NewUserRealm(attacker)) Register(cross(cur), "gnoswap_router", victim) uassert.Equal(t, string(attacker), string(entries[attacker].Owner)) uassert.Equal(t, "none", GetOwnerContracts(victim)) uassert.True(t, strings.Contains(GetInfo(attacker), string(attacker)), "info must show the contract itself as owner pre-acceptance") // only the nominee can accept; a bystander cannot testing.SetRealm(testing.NewUserRealm(newOwnerAddr())) uassert.AbortsWithMessage(t, cur, "caller is not the pending owner of "+string(attacker), func() { AcceptOwnership(cross(cur), attacker) }, ) } func newOwnerAddr() address { return testutils.TestAddress("bystander") } func TestDeprecate_SuccessorConsentRequired(cur realm, t *testing.T) { resetState() register(cur, v1, "vault", teamEOA) // re-audit P2: an attacker chains their entry INTO a legit contract, // forging predecessor provenance — refused unless the caller owns // the successor entry too testing.SetRealm(testing.NewUserRealm(attacker)) Register(cross(cur), "vault_v1_old", "") uassert.AbortsWithMessage(t, cur, "successor entry is not owned by the caller: "+string(v1), func() { Deprecate(cross(cur), attacker, v1) }, ) } func TestMigrationChain_MarksTruncation(cur realm, t *testing.T) { resetState() // build a chain longer than MaxChainLen entirely under one owner prev := address("") addrs := make([]address, MaxChainLen+5) for i := range addrs { addrs[i] = testutils.TestAddress("link_" + strconv.Itoa(i)) testing.SetRealm(testing.NewUserRealm(addrs[i])) Register(cross(cur), "link", teamEOA) testing.SetRealm(testing.NewUserRealm(teamEOA)) AcceptOwnership(cross(cur), addrs[i]) if prev != "" { Deprecate(cross(cur), prev, addrs[i]) } prev = addrs[i] } out := GetMigrationChain(addrs[0]) uassert.True(t, strings.Contains(out, "truncated"), "over-length chains must carry an explicit truncation marker") // GetLatest still resolves the true endpoint uassert.Equal(t, string(addrs[len(addrs)-1]), string(GetLatest(addrs[0]))) } func TestTransferNomination_CanBeCleared(cur realm, t *testing.T) { resetState() register(cur, v1, "vault", teamEOA) testing.SetRealm(testing.NewUserRealm(teamEOA)) TransferOwnership(cross(cur), v1, attacker) TransferOwnership(cross(cur), v1, "") // round-4 audit L2: clearing again is a pure no-op, and the emitted // event is now suppressed for it. The return value and the state are // unchanged either way, which is all a unit test can observe here -- // this toolchain's testing stdlib carries no events accessor. That the // second clear emits NOTHING is asserted externally by // tools/test_upgrade_registry_events.sh, which counts the events // `gno test -print-events` prints for this very suite. uassert.Equal(t, "pending ownership nomination cleared", TransferOwnership(cross(cur), v1, "")) uassert.Equal(t, "", string(mustGet(v1).PendingOwner)) testing.SetRealm(testing.NewUserRealm(attacker)) uassert.AbortsWithMessage(t, cur, "caller is not the pending owner of "+string(v1), func() { AcceptOwnership(cross(cur), v1) }, ) } // ---------- round-3 audit regressions (2026-09-02) ---------- func TestRenderLatest_ExactBudgetChainIsComplete(cur realm, t *testing.T) { resetState() // exactly RenderLatestHops deprecated hops + terminal endpoint: // the walk must be reported COMPLETE (round-3 off-by-one) n := RenderLatestHops + 1 addrs := make([]address, n) for i := range addrs { addrs[i] = testutils.TestAddress("hop_" + strconv.Itoa(i)) testing.SetRealm(testing.NewUserRealm(addrs[i])) Register(cross(cur), "hop", teamEOA) testing.SetRealm(testing.NewUserRealm(teamEOA)) AcceptOwnership(cross(cur), addrs[i]) if i > 0 { Deprecate(cross(cur), addrs[i-1], addrs[i]) } } latest, complete := renderLatest(addrs[0]) uassert.True(t, complete, "an exactly-budget chain must be complete") uassert.Equal(t, string(addrs[n-1]), string(latest)) } // ---------- audit gap closures (2026-09-21, apps-pipeline round) ---------- func TestStraySendRejectedEverywhere(cur realm, t *testing.T) { resetState() register(cur, v1, "vault", teamEOA) // audit Y2: the realm holds no banker, so attached coins would // strand forever — every crossing function must abort them msg := "this realm does not accept coins" testing.SetRealm(testing.NewUserRealm(teamEOA)) testing.SetOriginSend(chain.Coins{{Denom: "ugnot", Amount: 5}}) uassert.AbortsWithMessage(t, cur, msg, func() { Register(cross(cur), "paid", "") }) uassert.AbortsWithMessage(t, cur, msg, func() { AcceptOwnership(cross(cur), v1) }) uassert.AbortsWithMessage(t, cur, msg, func() { TransferOwnership(cross(cur), v1, attacker) }) uassert.AbortsWithMessage(t, cur, msg, func() { Deprecate(cross(cur), v1, v2) }) testing.SetOriginSend(nil) } func TestDeprecate_BoundedWalkRefusal(cur realm, t *testing.T) { resetState() // audit Y5.1: the round-3 bounded-walk panic had zero coverage. // Build a chain longer than MaxChainLen, then point a fresh entry // at its head — the verification walk must refuse, not walk it. prev := address("") addrs := make([]address, MaxChainLen+5) for i := range addrs { addrs[i] = testutils.TestAddress("bw_" + strconv.Itoa(i)) testing.SetRealm(testing.NewUserRealm(addrs[i])) Register(cross(cur), "link", teamEOA) testing.SetRealm(testing.NewUserRealm(teamEOA)) AcceptOwnership(cross(cur), addrs[i]) if prev != "" { Deprecate(cross(cur), prev, addrs[i]) } prev = addrs[i] } fresh := testutils.TestAddress("bw_fresh") testing.SetRealm(testing.NewUserRealm(fresh)) Register(cross(cur), "fresh", teamEOA) testing.SetRealm(testing.NewUserRealm(teamEOA)) AcceptOwnership(cross(cur), fresh) uassert.AbortsWithMessage(t, cur, "successor chain too long to verify; cannot deprecate", func() { Deprecate(cross(cur), fresh, addrs[0]) }, ) } func TestRenderLatest_LongChainMarked(cur realm, t *testing.T) { resetState() // audit Y5.2: the incomplete branch and Render's long-chain marker // were untested. Budget-boundary node must be NON-terminal here. n := RenderLatestHops + 3 addrs := make([]address, n) for i := range addrs { addrs[i] = testutils.TestAddress("lc_" + strconv.Itoa(i)) testing.SetRealm(testing.NewUserRealm(addrs[i])) Register(cross(cur), "hop", teamEOA) testing.SetRealm(testing.NewUserRealm(teamEOA)) AcceptOwnership(cross(cur), addrs[i]) if i > 0 { Deprecate(cross(cur), addrs[i-1], addrs[i]) } } _, complete := renderLatest(addrs[0]) uassert.False(t, complete, "an over-budget chain must be reported incomplete") uassert.True(t, strings.Contains(Render(""), "(long chain; use GetLatest)"), "render must mark over-budget chains instead of mislabeling an endpoint") } func TestRender_Pagination(cur realm, t *testing.T) { resetState() // audit Y5.3: the MaxRenderEntries window was untested for i := 0; i < MaxRenderEntries+5; i++ { a := testutils.TestAddress("pg_" + strconv.Itoa(i)) testing.SetRealm(testing.NewUserRealm(a)) Register(cross(cur), "entry", "") } out := Render("") uassert.True(t, strings.Contains(out, "_Showing the most recent "+strconv.Itoa(MaxRenderEntries)+" entries of "+ strconv.Itoa(MaxRenderEntries+5)+"."), "over-window registries must announce the window AND the true total") // round-4 audit: announcing the window is not enough on an immutable // realm — the page must also say where the unlisted entries went, since // a flood of new entries evicts older ones from this view permanently. uassert.True(t, strings.Contains(out, "remain queryable by address via GetInfo"), "the eviction notice must point at the accessors that still serve them") } func TestRenomination_OverwritesPrior(cur realm, t *testing.T) { resetState() register(cur, v1, "vault", teamEOA) a := testutils.TestAddress("nominee_a") b := testutils.TestAddress("nominee_b") // audit Y5.4: a newer nomination must revoke the older one testing.SetRealm(testing.NewUserRealm(teamEOA)) TransferOwnership(cross(cur), v1, a) TransferOwnership(cross(cur), v1, b) testing.SetRealm(testing.NewUserRealm(a)) uassert.AbortsWithMessage(t, cur, "caller is not the pending owner of "+string(v1), func() { AcceptOwnership(cross(cur), v1) }, ) testing.SetRealm(testing.NewUserRealm(b)) AcceptOwnership(cross(cur), v1) uassert.Equal(t, string(b), string(entries[v1].Owner)) } func TestSelfNominationAcceptKeepsIndexClean(cur realm, t *testing.T) { resetState() register(cur, v1, "vault", teamEOA) // audit Y5.5: owner nominating themselves and accepting must not // duplicate or lose the ownerContracts index entry testing.SetRealm(testing.NewUserRealm(teamEOA)) TransferOwnership(cross(cur), v1, teamEOA) AcceptOwnership(cross(cur), v1) uassert.Equal(t, string(teamEOA), string(entries[v1].Owner)) uassert.Equal(t, string(v1), GetOwnerContracts(teamEOA)) } func TestOpsOnUnregisteredAbort(cur realm, t *testing.T) { resetState() // audit Y5.6: the not-found aborts were never asserted ghost := testutils.TestAddress("ghost") msg := "contract not found: " + string(ghost) testing.SetRealm(testing.NewUserRealm(teamEOA)) uassert.AbortsWithMessage(t, cur, msg, func() { AcceptOwnership(cross(cur), ghost) }) uassert.AbortsWithMessage(t, cur, msg, func() { TransferOwnership(cross(cur), ghost, teamEOA) }) uassert.AbortsWithMessage(t, cur, msg, func() { Deprecate(cross(cur), ghost, v1) }) // GetInfo shares mustGet with the three crossing paths above; its // same-realm panic is not catchable by the revive-based Aborts helpers } func TestRegister_OwnerEqualsSelfNoPending(cur realm, t *testing.T) { resetState() // audit Y5.7: registering with your own address as owner must leave // no pending nomination (the explicit owner==contractAddr branch) testing.SetRealm(testing.NewUserRealm(v1)) Register(cross(cur), "vault", v1) uassert.Equal(t, string(v1), string(entries[v1].Owner)) uassert.Equal(t, "", string(entries[v1].PendingOwner)) }
  8. #8/gno.MemPackageType
  9. #9 MPUserAll

Result log

msg:0,success:true,log:,events:[]