upgrade_registry.gno
19.34 Kb · 547 lines
1package upgrade_registry
2
3import (
4 "chain"
5 "chain/runtime/unsafe"
6 "strconv"
7 "strings"
8)
9
10// ContractEntry represents a registered contract and its upgrade status.
11//
12// An entry's Address is ALWAYS the address that called Register — a
13// contract can only register itself. That call is the proof of control:
14// nobody can claim an address they don't command, so a Deprecate
15// redirect on an entry always originates from whoever genuinely
16// controlled the contract. Entries are permanent by design: migration
17// history is what consumers rely on, so it must not be erasable (and a
18// delete would reopen re-registration squatting).
19//
20// Ownership is two-step (re-audit 2026-09-02): Register/Transfer only
21// NOMINATE an owner; the nominee must AcceptOwnership. Until then the
22// entry is owned by the contract itself, so nobody's address can be
23// attached as "owner" without their consent.
24type ContractEntry struct {
25 Address address
26 Owner address // current manager (accepted)
27 PendingOwner address // nominated, not yet accepted; empty if none
28 Name string
29 Deprecated bool
30 Successor address // address of the upgraded contract, empty if current
31}
32
33const (
34 MaxNameLen = 64
35
36 // MaxChainLen bounds two WALKS, and is NOT a bound on how long a
37 // migration chain can become (round-4 audit: the previous comment
38 // named only the first of the two uses, which read as though chain
39 // length itself were capped at 50):
40 //
41 // 1. GetMigrationChain's traversal and output — a chain longer than
42 // this is explicitly marked truncated rather than presented as
43 // ending early.
44 // 2. Deprecate's cycle-verification walk, which refuses rather than
45 // walking unbounded.
46 //
47 // Neither bounds TOTAL chain length, because Deprecate's walk runs
48 // FORWARD from the new successor and never sees the chain BEHIND the
49 // predecessor. Its cost is proportional to the chain AHEAD of the
50 // successor, which is:
51 //
52 // - zero hops when the successor is terminal — the ordinary
53 // tail-append. The walk breaks immediately, so tail-appending grows
54 // a chain without limit. The realm's own
55 // TestMigrationChain_MarksTruncation builds MaxChainLen+5 links this
56 // way and is expected to succeed.
57 // - O(chain ahead) when the successor is ITSELF already deprecated — a
58 // head-append. Deprecate does NOT reject an already-deprecated
59 // successor (it validates only existence and ownership), so this is
60 // a permitted call, and past MaxChainLen hops it refuses. See
61 // TestDeprecate_BoundedWalkRefusal.
62 //
63 // This is why GetLatest documents an UNBOUNDED cost (see its COST
64 // WARNING) — that warning is accurate, not conservative.
65 //
66 // Extending a chain requires owning both ends of THE ONE NEW LINK at
67 // call time (see Deprecate, which checks exactly that and never
68 // re-verifies earlier links). That is a per-call consent check, not an
69 // invariant of the finished chain: an owner who transfers away only the
70 // TAIL entry hands the recipient the ability to extend it, and so to
71 // move GetLatest for every entry behind it, without that recipient ever
72 // having owned the root. Acquiring the tail still takes the two-step
73 // nominate/accept handoff from its owner, so no chain can be lengthened
74 // without some consenting owner along it.
75 MaxChainLen = 50
76
77 // Render bounds (re-audit): rendering is paginated and the per-row
78 // "latest" annotation walks a bounded number of hops, so an
79 // attacker-built long chain cannot blow up the realm page.
80 MaxRenderEntries = 100
81 RenderLatestHops = 10
82)
83
84// NOTE (re-audit): there is deliberately NO global entry cap, and the
85// reason is the second clause below, NOT the first.
86//
87// Entries are one-per-address by construction (self-registration), but
88// round-4 audit correction: that does NOT make spam expensive. Addresses
89// are cheap — anyone may deploy unlimited realms under their own
90// personal-address namespace, and each deployed path derives a distinct
91// address — so entry count is effectively unbounded regardless. The
92// earlier wording presented per-address uniqueness as a spam control; it
93// is not one.
94//
95// The cap is omitted because a hard cap, combined with the
96// registered-successor rule, would let an attacker saturate the registry
97// and permanently disable deprecation for every legitimate entry. That
98// failure is permanent and global; unbounded growth is neither. Growth
99// is also paid for by the registrant, not by this realm's operator.
100//
101// Consequence accepted here and NOT mitigated: Render shows the most
102// recent MaxRenderEntries entries, so a registrant who floods that many
103// entries pushes older ones off this realm's rendered page permanently.
104// The data remains queryable by address via GetInfo/GetMigrationChain.
105
106var (
107 entries map[address]*ContractEntry
108 entryAddrs []address // insertion-ordered for deterministic Render
109 ownerContracts map[address][]address
110)
111
112func init() {
113 entries = make(map[address]*ContractEntry)
114 entryAddrs = []address{}
115 ownerContracts = make(map[address][]address)
116}
117
118// rejectStraySend aborts when coins are attached to a call (audit Y2):
119// this realm handles no funds and holds no banker, so an attached send
120// would strand on the realm address forever. Aborting reverts the
121// transfer back to the sender. Fails open for realm-routed calls, whose
122// 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}
128
129// isValidName restricts names to lowercase alphanumeric with
130// underscores. Names appear in rendered markdown and query output, so
131// no delimiter or markdown character may enter one.
132func isValidName(name string) bool {
133 if name == "" || len(name) > MaxNameLen {
134 return false
135 }
136 for _, c := range name {
137 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
138 return false
139 }
140 }
141 return true
142}
143
144func 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 break
150 }
151 }
152 if len(ownerContracts[owner]) == 0 {
153 delete(ownerContracts, owner)
154 }
155}
156
157// ---------- write operations ----------
158
159// Register adds the CALLING contract to the registry — the entry's
160// 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 no
162// power until it calls AcceptOwnership (nobody can be made an owner
163// without consent — re-audit). Pass "" to manage from the contract
164// itself — but note (audit Y4): entries are permanent, so an entry
165// managed by a contract that has no code path for calling this
166// registry again is FROZEN as active forever: never deprecatable,
167// never transferable. Realms registering from init() should nominate
168// 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 = owner
185 }
186
187 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 registration
196 // is the contract itself; `pendingOwner` is the nomination, empty when
197 // 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" in
200 // 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}
209
210// AcceptOwnership completes a nominated ownership: only the pending
211// owner can accept, and only acceptance moves the entry (and the
212// 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.Owner
221 ownerIndexRemove(entry.Owner, contractAddr)
222 ownerContracts[c] = append(ownerContracts[c], contractAddr)
223 entry.Owner = c
224 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}
232
233// TransferOwnership NOMINATES a new owner for a registry entry; the
234// 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 an
245 // event for it is indistinguishable from a real clearing to an
246 // indexer). The return value is unconditional either way — this
247 // changes what is OBSERVED, not what happens.
248 cleared := entry.PendingOwner
249 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 = newOwner
263 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}
270
271// Deprecate marks a contract as deprecated and points to its successor.
272// Only the entry's owner can deprecate. The successor must itself be a
273// REGISTERED entry — registration is self-proving, so a successor can
274// 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: without
276// consent, an attacker could chain their entry INTO a legitimate
277// contract, forging "official predecessor" provenance). Consent is
278// checked AT CALL TIME: transferring the successor entry away later
279// does not unlink an existing chain — the attestation is that both
280// 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 }
300
301 // Prevent circular chains: walk from successor and ensure we don't
302 // loop back to contractAddr. Successors are immutable once set, so
303 // a check over the existing chain is complete.
304 visited := make(map[address]bool)
305 visited[contractAddr] = true
306 walk := successorAddr
307 for hops := 0; ; hops++ {
308 if hops > MaxChainLen {
309 // bounded-walk discipline (round-3 audit): a successor chain
310 // this long is pathological; refuse rather than walk unbounded
311 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 break
320 }
321 visited[walk] = true
322 walk = e.Successor
323 }
324
325 entry.Deprecated = true
326 entry.Successor = successorAddr
327 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}
334
335// ---------- read-only queries ----------
336
337// GetLatest follows the migration chain from a contract address and
338// returns the latest non-deprecated address. Safe against circular refs.
339//
340// COST WARNING (audit Y3): unlike the write-path walks, this read is
341// deliberately UNBOUNDED in hops so it always resolves the true
342// endpoint. Chain length is attacker-buildable, so an on-chain
343// integrator resolving an UNTRUSTED address inside its own transaction
344// must treat the gas cost as O(chain length) — wrap it, bound the
345// input set, or resolve off-chain via query.
346func GetLatest(contractAddr address) address {
347 visited := make(map[address]bool)
348 current := contractAddr
349
350 for {
351 entry, exists := entries[current]
352 if !exists {
353 return current
354 }
355 if !entry.Deprecated || entry.Successor == "" {
356 return current
357 }
358 if visited[current] {
359 return current // break on circular
360 }
361 visited[current] = true
362 current = entry.Successor
363 }
364}
365
366// GetMigrationChain returns the full upgrade path starting from the
367// given address, at most MaxChainLen hops. A chain longer than the
368// bound is explicitly marked as truncated (re-audit: silent truncation
369// presented a mid-chain node as the endpoint).
370func GetMigrationChain(contractAddr address) string {
371 visited := make(map[address]bool)
372 current := contractAddr
373 var parts []string
374
375 for {
376 if len(parts) >= MaxChainLen {
377 parts = append(parts, "... (truncated at "+strconv.Itoa(MaxChainLen)+
378 " hops; use GetLatest for the endpoint)")
379 break
380 }
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 break
388 }
389 if visited[current] {
390 parts = append(parts, string(current)+" (circular)")
391 break
392 }
393 visited[current] = true
394
395 tag := ""
396 if entry.Deprecated {
397 tag = " [deprecated]"
398 }
399 parts = append(parts, string(entry.Address)+" ("+entry.Name+")"+tag)
400
401 if !entry.Deprecated || entry.Successor == "" {
402 break
403 }
404 current = entry.Successor
405 }
406
407 return strings.Join(parts, " -> ")
408}
409
410// 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) + " | " + status
418}
419
420// GetOwnerContracts returns all contract addresses whose ACCEPTED owner
421// 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}
433
434// ---------- render ----------
435
436// renderLatest walks at most RenderLatestHops hops for the per-row
437// annotation; longer chains are marked rather than walked (render gas
438// bound — re-audit).
439func renderLatest(contractAddr address) (address, bool) {
440 current := contractAddr
441 for i := 0; i < RenderLatestHops; i++ {
442 entry, exists := entries[current]
443 if !exists || !entry.Deprecated || entry.Successor == "" {
444 return current, true
445 }
446 current = entry.Successor
447 }
448 // round-3 audit off-by-one: the node reached ON the budget boundary
449 // may itself be terminal — check before declaring the walk incomplete
450 entry, exists := entries[current]
451 if !exists || !entry.Deprecated || entry.Successor == "" {
452 return current, true
453 }
454 return current, false
455}
456
457// 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's
462// ADDRESS is self-registered and therefore verified, but its NAME is an
463// arbitrary self-asserted label that is neither unique nor checked
464// against anything. Two entries may carry the same name, and a name may
465// impersonate a well-known project. A reader who keys on the bold name
466// rather than the address is trusting something this realm does not
467// attest. Stating that in the output is the only available mitigation:
468// the entries are immutable, and name uniqueness is deliberately NOT
469// enforced because first-come uniqueness on a permissionless immutable
470// 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"
478
479 if entries == nil || len(entries) == 0 {
480 return "# Upgrade Registry\n\n" + disclosure + "No contracts registered.\n"
481 }
482
483 var b strings.Builder
484 b.WriteString("# Upgrade Registry\n\n")
485 b.WriteString(disclosure)
486 b.WriteString("**Registered contracts:** " + strconv.Itoa(len(entries)) + "\n\n")
487
488 show := entryAddrs
489 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 }
496
497 var active []string
498 var deprecated []string
499
500 for _, addr := range show {
501 entry := entries[addr]
502 if entry == nil {
503 continue
504 }
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 }
519
520 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 }
527
528 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 }
535
536 return b.String()
537}
538
539// ---------- helpers ----------
540
541func mustGet(addr address) *ContractEntry {
542 entry, ok := entries[addr]
543 if !ok {
544 panic("contract not found: " + string(addr))
545 }
546 return entry
547}