1package upgrade_registry23import (4 "chain"5 "chain/runtime/unsafe"6 "strconv"7 "strings"8)910// ContractEntry represents a registered contract and its upgrade status.11//12// An entry's Address is ALWAYS the address that called Register — a13// contract can only register itself. That call is the proof of control:14// nobody can claim an address they don't command, so a Deprecate15// redirect on an entry always originates from whoever genuinely16// controlled the contract. Entries are permanent by design: migration17// history is what consumers rely on, so it must not be erasable (and a18// delete would reopen re-registration squatting).19//20// Ownership is two-step (re-audit 2026-09-02): Register/Transfer only21// NOMINATE an owner; the nominee must AcceptOwnership. Until then the22// entry is owned by the contract itself, so nobody's address can be23// attached as "owner" without their consent.24type ContractEntry struct {25 Address address26 Owner address // current manager (accepted)27 PendingOwner address // nominated, not yet accepted; empty if none28 Name string29 Deprecated bool30 Successor address // address of the upgraded contract, empty if current31}3233const (34 MaxNameLen = 643536 // MaxChainLen bounds two WALKS, and is NOT a bound on how long a37 // migration chain can become (round-4 audit: the previous comment38 // named only the first of the two uses, which read as though chain39 // length itself were capped at 50):40 //41 // 1. GetMigrationChain's traversal and output — a chain longer than42 // this is explicitly marked truncated rather than presented as43 // ending early.44 // 2. Deprecate's cycle-verification walk, which refuses rather than45 // walking unbounded.46 //47 // Neither bounds TOTAL chain length, because Deprecate's walk runs48 // FORWARD from the new successor and never sees the chain BEHIND the49 // predecessor. Its cost is proportional to the chain AHEAD of the50 // successor, which is:51 //52 // - zero hops when the successor is terminal — the ordinary53 // tail-append. The walk breaks immediately, so tail-appending grows54 // a chain without limit. The realm's own55 // TestMigrationChain_MarksTruncation builds MaxChainLen+5 links this56 // way and is expected to succeed.57 // - O(chain ahead) when the successor is ITSELF already deprecated — a58 // head-append. Deprecate does NOT reject an already-deprecated59 // successor (it validates only existence and ownership), so this is60 // a permitted call, and past MaxChainLen hops it refuses. See61 // TestDeprecate_BoundedWalkRefusal.62 //63 // This is why GetLatest documents an UNBOUNDED cost (see its COST64 // WARNING) — that warning is accurate, not conservative.65 //66 // Extending a chain requires owning both ends of THE ONE NEW LINK at67 // call time (see Deprecate, which checks exactly that and never68 // re-verifies earlier links). That is a per-call consent check, not an69 // invariant of the finished chain: an owner who transfers away only the70 // TAIL entry hands the recipient the ability to extend it, and so to71 // move GetLatest for every entry behind it, without that recipient ever72 // having owned the root. Acquiring the tail still takes the two-step73 // nominate/accept handoff from its owner, so no chain can be lengthened74 // without some consenting owner along it.75 MaxChainLen = 507677 // Render bounds (re-audit): rendering is paginated and the per-row78 // "latest" annotation walks a bounded number of hops, so an79 // attacker-built long chain cannot blow up the realm page.80 MaxRenderEntries = 10081 RenderLatestHops = 1082)8384// NOTE (re-audit): there is deliberately NO global entry cap, and the85// reason is the second clause below, NOT the first.86//87// Entries are one-per-address by construction (self-registration), but88// round-4 audit correction: that does NOT make spam expensive. Addresses89// are cheap — anyone may deploy unlimited realms under their own90// personal-address namespace, and each deployed path derives a distinct91// address — so entry count is effectively unbounded regardless. The92// earlier wording presented per-address uniqueness as a spam control; it93// is not one.94//95// The cap is omitted because a hard cap, combined with the96// registered-successor rule, would let an attacker saturate the registry97// and permanently disable deprecation for every legitimate entry. That98// failure is permanent and global; unbounded growth is neither. Growth99// is also paid for by the registrant, not by this realm's operator.100//101// Consequence accepted here and NOT mitigated: Render shows the most102// recent MaxRenderEntries entries, so a registrant who floods that many103// entries pushes older ones off this realm's rendered page permanently.104// The data remains queryable by address via GetInfo/GetMigrationChain.105106var (107 entries map[address]*ContractEntry108 entryAddrs []address // insertion-ordered for deterministic Render109 ownerContracts map[address][]address110)111112func init() {113 entries = make(map[address]*ContractEntry)114 entryAddrs = []address{}115 ownerContracts = make(map[address][]address)116}117118// rejectStraySend aborts when coins are attached to a call (audit Y2):119// this realm handles no funds and holds no banker, so an attached send120// would strand on the realm address forever. Aborting reverts the121// transfer back to the sender. Fails open for realm-routed calls, whose122// attached send lands on the intermediary realm, never here.123func rejectStraySend(cur realm) {124 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {125 panic("this realm does not accept coins")126 }127}128129// isValidName restricts names to lowercase alphanumeric with130// underscores. Names appear in rendered markdown and query output, so131// no delimiter or markdown character may enter one.132func isValidName(name string) bool {133 if name == "" || len(name) > MaxNameLen {134 return false135 }136 for _, c := range name {137 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {138 return false139 }140 }141 return true142}143144func ownerIndexRemove(owner, contractAddr address) {145 old := ownerContracts[owner]146 for i, addr := range old {147 if addr == contractAddr {148 ownerContracts[owner] = append(old[:i], old[i+1:]...)149 break150 }151 }152 if len(ownerContracts[owner]) == 0 {153 delete(ownerContracts, owner)154 }155}156157// ---------- write operations ----------158159// Register adds the CALLING contract to the registry — the entry's160// address is the caller's own address, which is the proof of control.161// owner NOMINATES a manager (team EOA or governance realm); it holds no162// power until it calls AcceptOwnership (nobody can be made an owner163// without consent — re-audit). Pass "" to manage from the contract164// itself — but note (audit Y4): entries are permanent, so an entry165// managed by a contract that has no code path for calling this166// registry again is FROZEN as active forever: never deprecatable,167// never transferable. Realms registering from init() should nominate168// an EOA or governance manager instead.169func Register(cur realm, name string, owner address) string {170 rejectStraySend(cur)171 if !isValidName(name) {172 panic("name must be 1-" + strconv.Itoa(MaxNameLen) +173 " chars, lowercase alphanumeric with underscores only")174 }175 contractAddr := cur.Previous().Address()176 if _, exists := entries[contractAddr]; exists {177 panic("contract already registered: " + string(contractAddr))178 }179 pending := address("")180 if owner != "" && owner != contractAddr {181 if !owner.IsValid() {182 panic("invalid owner address: " + string(owner))183 }184 pending = owner185 }186187 entries[contractAddr] = &ContractEntry{188 Address: contractAddr,189 Owner: contractAddr,190 PendingOwner: pending,191 Name: name,192 }193 entryAddrs = append(entryAddrs, contractAddr)194 ownerContracts[contractAddr] = append(ownerContracts[contractAddr], contractAddr)195 // `owner` is the EFFECTIVE owner after this event, which at registration196 // is the contract itself; `pendingOwner` is the nomination, empty when197 // the entry is self-managed. Emitting both is what makes the key `owner`198 // mean the same thing in every event this realm emits (round-4 audit Y2:199 // omitting it here left `owner` readable as "the authorizing actor" in200 // some events and "the new owner" in others).201 chain.Emit("Register",202 "contract", string(contractAddr),203 "name", name,204 "owner", string(contractAddr),205 "pendingOwner", string(pending),206 )207 return "registered " + name + " at " + string(contractAddr)208}209210// AcceptOwnership completes a nominated ownership: only the pending211// owner can accept, and only acceptance moves the entry (and the212// ownerContracts index) to them.213func AcceptOwnership(cur realm, contractAddr address) string {214 rejectStraySend(cur)215 entry := mustGet(contractAddr)216 c := cur.Previous().Address()217 if entry.PendingOwner == "" || c != entry.PendingOwner {218 panic("caller is not the pending owner of " + string(contractAddr))219 }220 previousOwner := entry.Owner221 ownerIndexRemove(entry.Owner, contractAddr)222 ownerContracts[c] = append(ownerContracts[c], contractAddr)223 entry.Owner = c224 entry.PendingOwner = ""225 chain.Emit("OwnershipAccepted",226 "contract", string(contractAddr),227 "owner", string(c),228 "previousOwner", string(previousOwner),229 )230 return "ownership accepted by " + string(c)231}232233// TransferOwnership NOMINATES a new owner for a registry entry; the234// nominee must AcceptOwnership to take control (two-step — re-audit).235// Passing "" clears a pending nomination.236func TransferOwnership(cur realm, contractAddr, newOwner address) string {237 rejectStraySend(cur)238 entry := mustGet(contractAddr)239 if entry.Owner != cur.Previous().Address() {240 panic("only the owner can transfer ownership")241 }242 if newOwner == "" {243 // Emit only when a nomination actually existed (round-4 audit L2:244 // clearing an entry that has no pending owner is a no-op, and an245 // event for it is indistinguishable from a real clearing to an246 // indexer). The return value is unconditional either way — this247 // changes what is OBSERVED, not what happens.248 cleared := entry.PendingOwner249 entry.PendingOwner = ""250 if cleared != "" {251 chain.Emit("OwnershipNominationCleared",252 "contract", string(contractAddr),253 "owner", string(entry.Owner),254 "clearedNominee", string(cleared),255 )256 }257 return "pending ownership nomination cleared"258 }259 if !newOwner.IsValid() {260 panic("invalid new owner address: " + string(newOwner))261 }262 entry.PendingOwner = newOwner263 chain.Emit("OwnershipNominated",264 "contract", string(contractAddr),265 "owner", string(entry.Owner),266 "pendingOwner", string(newOwner),267 )268 return "ownership nominated to " + string(newOwner) + " (pending acceptance)"269}270271// Deprecate marks a contract as deprecated and points to its successor.272// Only the entry's owner can deprecate. The successor must itself be a273// REGISTERED entry — registration is self-proving, so a successor can274// never be a dangling pointer or an address squatted by a third party —275// and the successor entry must be OWNED BY THE CALLER (re-audit: without276// consent, an attacker could chain their entry INTO a legitimate277// contract, forging "official predecessor" provenance). Consent is278// checked AT CALL TIME: transferring the successor entry away later279// does not unlink an existing chain — the attestation is that both280// ends shared an owner when the deprecation was recorded.281func Deprecate(cur realm, contractAddr, successorAddr address) string {282 rejectStraySend(cur)283 entry := mustGet(contractAddr)284 if entry.Owner != cur.Previous().Address() {285 panic("only the owner can deprecate this contract")286 }287 if entry.Deprecated {288 panic("contract is already deprecated")289 }290 if successorAddr == contractAddr {291 panic("successor cannot be the contract itself")292 }293 succ, exists := entries[successorAddr]294 if !exists {295 panic("successor is not a registered contract: " + string(successorAddr))296 }297 if succ.Owner != cur.Previous().Address() {298 panic("successor entry is not owned by the caller: " + string(successorAddr))299 }300301 // Prevent circular chains: walk from successor and ensure we don't302 // loop back to contractAddr. Successors are immutable once set, so303 // a check over the existing chain is complete.304 visited := make(map[address]bool)305 visited[contractAddr] = true306 walk := successorAddr307 for hops := 0; ; hops++ {308 if hops > MaxChainLen {309 // bounded-walk discipline (round-3 audit): a successor chain310 // this long is pathological; refuse rather than walk unbounded311 panic("successor chain too long to verify; cannot deprecate")312 }313 if visited[walk] {314 panic("deprecating " + string(contractAddr) + " to " +315 string(successorAddr) + " would create a circular migration chain")316 }317 e, exists := entries[walk]318 if !exists || !e.Deprecated || e.Successor == "" {319 break320 }321 visited[walk] = true322 walk = e.Successor323 }324325 entry.Deprecated = true326 entry.Successor = successorAddr327 chain.Emit("Deprecate",328 "contract", string(contractAddr),329 "name", entry.Name,330 "successor", string(successorAddr),331 )332 return "deprecated " + entry.Name + ", successor: " + string(successorAddr)333}334335// ---------- read-only queries ----------336337// GetLatest follows the migration chain from a contract address and338// returns the latest non-deprecated address. Safe against circular refs.339//340// COST WARNING (audit Y3): unlike the write-path walks, this read is341// deliberately UNBOUNDED in hops so it always resolves the true342// endpoint. Chain length is attacker-buildable, so an on-chain343// integrator resolving an UNTRUSTED address inside its own transaction344// must treat the gas cost as O(chain length) — wrap it, bound the345// input set, or resolve off-chain via query.346func GetLatest(contractAddr address) address {347 visited := make(map[address]bool)348 current := contractAddr349350 for {351 entry, exists := entries[current]352 if !exists {353 return current354 }355 if !entry.Deprecated || entry.Successor == "" {356 return current357 }358 if visited[current] {359 return current // break on circular360 }361 visited[current] = true362 current = entry.Successor363 }364}365366// GetMigrationChain returns the full upgrade path starting from the367// given address, at most MaxChainLen hops. A chain longer than the368// bound is explicitly marked as truncated (re-audit: silent truncation369// presented a mid-chain node as the endpoint).370func GetMigrationChain(contractAddr address) string {371 visited := make(map[address]bool)372 current := contractAddr373 var parts []string374375 for {376 if len(parts) >= MaxChainLen {377 parts = append(parts, "... (truncated at "+strconv.Itoa(MaxChainLen)+378 " hops; use GetLatest for the endpoint)")379 break380 }381 entry, exists := entries[current]382 if !exists {383 if len(parts) == 0 {384 return "not found: " + string(contractAddr)385 }386 parts = append(parts, string(current)+" (unregistered)")387 break388 }389 if visited[current] {390 parts = append(parts, string(current)+" (circular)")391 break392 }393 visited[current] = true394395 tag := ""396 if entry.Deprecated {397 tag = " [deprecated]"398 }399 parts = append(parts, string(entry.Address)+" ("+entry.Name+")"+tag)400401 if !entry.Deprecated || entry.Successor == "" {402 break403 }404 current = entry.Successor405 }406407 return strings.Join(parts, " -> ")408}409410// GetInfo returns a one-line summary for a contract.411func GetInfo(contractAddr address) string {412 entry := mustGet(contractAddr)413 status := "active"414 if entry.Deprecated {415 status = "deprecated -> " + string(entry.Successor)416 }417 return entry.Name + " | owner: " + string(entry.Owner) + " | " + status418}419420// GetOwnerContracts returns all contract addresses whose ACCEPTED owner421// is the given address (nominations don't count until accepted).422func GetOwnerContracts(owner address) string {423 addrs, exists := ownerContracts[owner]424 if !exists || len(addrs) == 0 {425 return "none"426 }427 parts := make([]string, len(addrs))428 for i, a := range addrs {429 parts[i] = string(a)430 }431 return strings.Join(parts, ", ")432}433434// ---------- render ----------435436// renderLatest walks at most RenderLatestHops hops for the per-row437// annotation; longer chains are marked rather than walked (render gas438// bound — re-audit).439func renderLatest(contractAddr address) (address, bool) {440 current := contractAddr441 for i := 0; i < RenderLatestHops; i++ {442 entry, exists := entries[current]443 if !exists || !entry.Deprecated || entry.Successor == "" {444 return current, true445 }446 current = entry.Successor447 }448 // round-3 audit off-by-one: the node reached ON the budget boundary449 // may itself be terminal — check before declaring the walk incomplete450 entry, exists := entries[current]451 if !exists || !entry.Deprecated || entry.Successor == "" {452 return current, true453 }454 return current, false455}456457// Render returns a markdown overview of the most recent entries.458// Never panics.459//460// The rendered page carries an explicit scope disclosure (round-4 audit).461// What this registry proves is narrow and easy to over-read: an entry's462// ADDRESS is self-registered and therefore verified, but its NAME is an463// arbitrary self-asserted label that is neither unique nor checked464// against anything. Two entries may carry the same name, and a name may465// impersonate a well-known project. A reader who keys on the bold name466// rather than the address is trusting something this realm does not467// attest. Stating that in the output is the only available mitigation:468// the entries are immutable, and name uniqueness is deliberately NOT469// enforced because first-come uniqueness on a permissionless immutable470// registry would let an attacker burn every valuable name permanently.471func Render(path string) string {472 disclosure := "> [!NOTE]\n" +473 "> Addresses are self-registered and therefore verified: an entry exists\n" +474 "> only because that address itself called `Register`. **Names are not.**\n" +475 "> A name is an arbitrary self-asserted label — it is NOT unique, NOT\n" +476 "> verified, and may impersonate another project. Identify contracts by\n" +477 "> address, never by name.\n\n"478479 if entries == nil || len(entries) == 0 {480 return "# Upgrade Registry\n\n" + disclosure + "No contracts registered.\n"481 }482483 var b strings.Builder484 b.WriteString("# Upgrade Registry\n\n")485 b.WriteString(disclosure)486 b.WriteString("**Registered contracts:** " + strconv.Itoa(len(entries)) + "\n\n")487488 show := entryAddrs489 if len(show) > MaxRenderEntries {490 b.WriteString("_Showing the most recent " + strconv.Itoa(MaxRenderEntries) +491 " entries of " + strconv.Itoa(len(show)) + ". Older entries are not " +492 "listed here and remain queryable by address via GetInfo and " +493 "GetMigrationChain._\n\n")494 show = show[len(show)-MaxRenderEntries:]495 }496497 var active []string498 var deprecated []string499500 for _, addr := range show {501 entry := entries[addr]502 if entry == nil {503 continue504 }505 line := "- **" + entry.Name + "** (`" + string(addr) + "`) — owner: `" + string(entry.Owner) + "`"506 if entry.Deprecated {507 line += " — **DEPRECATED** -> `" + string(entry.Successor) + "`"508 latest, complete := renderLatest(addr)509 if !complete {510 line += " (long chain; use GetLatest)"511 } else if latest != entry.Successor {512 line += " (latest: `" + string(latest) + "`)"513 }514 deprecated = append(deprecated, line)515 } else {516 active = append(active, line)517 }518 }519520 if len(active) > 0 {521 b.WriteString("## Active\n\n")522 for _, l := range active {523 b.WriteString(l + "\n")524 }525 b.WriteString("\n")526 }527528 if len(deprecated) > 0 {529 b.WriteString("## Deprecated\n\n")530 for _, l := range deprecated {531 b.WriteString(l + "\n")532 }533 b.WriteString("\n")534 }535536 return b.String()537}538539// ---------- helpers ----------540541func mustGet(addr address) *ContractEntry {542 entry, ok := entries[addr]543 if !ok {544 panic("contract not found: " + string(addr))545 }546 return entry547}548AcceptOwnership(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, contractAddr string) string
Deprecate(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, contractAddr string, successorAddr string) string
GetInfo(contractAddr string) string
GetLatest(contractAddr string) string
GetMigrationChain(contractAddr string) string
GetOwnerContracts(owner string) string
Register(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, name string, owner string) string
Render(path string) string
TransferOwnership(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, contractAddr string, newOwner string) string
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.