package 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 }