upgrade_registry.gno
13.61 Kb · 428 lines
1package upgrade_registry
2
3import (
4 "chain/runtime/unsafe"
5 "strconv"
6 "strings"
7)
8
9// ContractEntry represents a registered contract and its upgrade status.
10//
11// An entry's Address is ALWAYS the address that called Register — a
12// contract can only register itself. That call is the proof of control:
13// nobody can claim an address they don't command, so a Deprecate
14// redirect on an entry always originates from whoever genuinely
15// controlled the contract. Entries are permanent by design: migration
16// history is what consumers rely on, so it must not be erasable (and a
17// delete would reopen re-registration squatting).
18//
19// Ownership is two-step (re-audit 2026-09-02): Register/Transfer only
20// NOMINATE an owner; the nominee must AcceptOwnership. Until then the
21// entry is owned by the contract itself, so nobody's address can be
22// attached as "owner" without their consent.
23type ContractEntry struct {
24 Address address
25 Owner address // current manager (accepted)
26 PendingOwner address // nominated, not yet accepted; empty if none
27 Name string
28 Deprecated bool
29 Successor address // address of the upgraded contract, empty if current
30}
31
32const (
33 MaxNameLen = 64
34 MaxChainLen = 50 // GetMigrationChain traversal/output bound (marked when hit)
35
36 // Render bounds (re-audit): rendering is paginated and the per-row
37 // "latest" annotation walks a bounded number of hops, so an
38 // attacker-built long chain cannot blow up the realm page.
39 MaxRenderEntries = 100
40 RenderLatestHops = 10
41)
42
43// NOTE (re-audit): there is deliberately NO global entry cap. Entries
44// are one-per-address by construction (self-registration), so spam
45// costs funded accounts and gas; a hard cap combined with the
46// registered-successor rule would let an attacker saturate the
47// registry and permanently disable deprecation for every legitimate
48// entry.
49
50var (
51 entries map[address]*ContractEntry
52 entryAddrs []address // insertion-ordered for deterministic Render
53 ownerContracts map[address][]address
54)
55
56func init() {
57 entries = make(map[address]*ContractEntry)
58 entryAddrs = []address{}
59 ownerContracts = make(map[address][]address)
60}
61
62// rejectStraySend aborts when coins are attached to a call (audit Y2):
63// this realm handles no funds and holds no banker, so an attached send
64// would strand on the realm address forever. Aborting reverts the
65// transfer back to the sender. Fails open for realm-routed calls, whose
66// attached send lands on the intermediary realm, never here.
67func rejectStraySend(cur realm) {
68 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
69 panic("this realm does not accept coins")
70 }
71}
72
73// isValidName restricts names to lowercase alphanumeric with
74// underscores. Names appear in rendered markdown and query output, so
75// no delimiter or markdown character may enter one.
76func isValidName(name string) bool {
77 if name == "" || len(name) > MaxNameLen {
78 return false
79 }
80 for _, c := range name {
81 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
82 return false
83 }
84 }
85 return true
86}
87
88func ownerIndexRemove(owner, contractAddr address) {
89 old := ownerContracts[owner]
90 for i, addr := range old {
91 if addr == contractAddr {
92 ownerContracts[owner] = append(old[:i], old[i+1:]...)
93 break
94 }
95 }
96 if len(ownerContracts[owner]) == 0 {
97 delete(ownerContracts, owner)
98 }
99}
100
101// ---------- write operations ----------
102
103// Register adds the CALLING contract to the registry — the entry's
104// address is the caller's own address, which is the proof of control.
105// owner NOMINATES a manager (team EOA or governance realm); it holds no
106// power until it calls AcceptOwnership (nobody can be made an owner
107// without consent — re-audit). Pass "" to manage from the contract
108// itself — but note (audit Y4): entries are permanent, so an entry
109// managed by a contract that has no code path for calling this
110// registry again is FROZEN as active forever: never deprecatable,
111// never transferable. Realms registering from init() should nominate
112// an EOA or governance manager instead.
113func Register(cur realm, name string, owner address) string {
114 rejectStraySend(cur)
115 if !isValidName(name) {
116 panic("name must be 1-" + strconv.Itoa(MaxNameLen) +
117 " chars, lowercase alphanumeric with underscores only")
118 }
119 contractAddr := cur.Previous().Address()
120 if _, exists := entries[contractAddr]; exists {
121 panic("contract already registered: " + string(contractAddr))
122 }
123 pending := address("")
124 if owner != "" && owner != contractAddr {
125 if !owner.IsValid() {
126 panic("invalid owner address: " + string(owner))
127 }
128 pending = owner
129 }
130
131 entries[contractAddr] = &ContractEntry{
132 Address: contractAddr,
133 Owner: contractAddr,
134 PendingOwner: pending,
135 Name: name,
136 }
137 entryAddrs = append(entryAddrs, contractAddr)
138 ownerContracts[contractAddr] = append(ownerContracts[contractAddr], contractAddr)
139 return "registered " + name + " at " + string(contractAddr)
140}
141
142// AcceptOwnership completes a nominated ownership: only the pending
143// owner can accept, and only acceptance moves the entry (and the
144// ownerContracts index) to them.
145func AcceptOwnership(cur realm, contractAddr address) string {
146 rejectStraySend(cur)
147 entry := mustGet(contractAddr)
148 c := cur.Previous().Address()
149 if entry.PendingOwner == "" || c != entry.PendingOwner {
150 panic("caller is not the pending owner of " + string(contractAddr))
151 }
152 ownerIndexRemove(entry.Owner, contractAddr)
153 ownerContracts[c] = append(ownerContracts[c], contractAddr)
154 entry.Owner = c
155 entry.PendingOwner = ""
156 return "ownership accepted by " + string(c)
157}
158
159// TransferOwnership NOMINATES a new owner for a registry entry; the
160// nominee must AcceptOwnership to take control (two-step — re-audit).
161// Passing "" clears a pending nomination.
162func TransferOwnership(cur realm, contractAddr, newOwner address) string {
163 rejectStraySend(cur)
164 entry := mustGet(contractAddr)
165 if entry.Owner != cur.Previous().Address() {
166 panic("only the owner can transfer ownership")
167 }
168 if newOwner == "" {
169 entry.PendingOwner = ""
170 return "pending ownership nomination cleared"
171 }
172 if !newOwner.IsValid() {
173 panic("invalid new owner address: " + string(newOwner))
174 }
175 entry.PendingOwner = newOwner
176 return "ownership nominated to " + string(newOwner) + " (pending acceptance)"
177}
178
179// Deprecate marks a contract as deprecated and points to its successor.
180// Only the entry's owner can deprecate. The successor must itself be a
181// REGISTERED entry — registration is self-proving, so a successor can
182// never be a dangling pointer or an address squatted by a third party —
183// and the successor entry must be OWNED BY THE CALLER (re-audit: without
184// consent, an attacker could chain their entry INTO a legitimate
185// contract, forging "official predecessor" provenance). Consent is
186// checked AT CALL TIME: transferring the successor entry away later
187// does not unlink an existing chain — the attestation is that both
188// ends shared an owner when the deprecation was recorded.
189func Deprecate(cur realm, contractAddr, successorAddr address) string {
190 rejectStraySend(cur)
191 entry := mustGet(contractAddr)
192 if entry.Owner != cur.Previous().Address() {
193 panic("only the owner can deprecate this contract")
194 }
195 if entry.Deprecated {
196 panic("contract is already deprecated")
197 }
198 if successorAddr == contractAddr {
199 panic("successor cannot be the contract itself")
200 }
201 succ, exists := entries[successorAddr]
202 if !exists {
203 panic("successor is not a registered contract: " + string(successorAddr))
204 }
205 if succ.Owner != cur.Previous().Address() {
206 panic("successor entry is not owned by the caller: " + string(successorAddr))
207 }
208
209 // Prevent circular chains: walk from successor and ensure we don't
210 // loop back to contractAddr. Successors are immutable once set, so
211 // a check over the existing chain is complete.
212 visited := make(map[address]bool)
213 visited[contractAddr] = true
214 walk := successorAddr
215 for hops := 0; ; hops++ {
216 if hops > MaxChainLen {
217 // bounded-walk discipline (round-3 audit): a successor chain
218 // this long is pathological; refuse rather than walk unbounded
219 panic("successor chain too long to verify; cannot deprecate")
220 }
221 if visited[walk] {
222 panic("deprecating " + string(contractAddr) + " to " +
223 string(successorAddr) + " would create a circular migration chain")
224 }
225 e, exists := entries[walk]
226 if !exists || !e.Deprecated || e.Successor == "" {
227 break
228 }
229 visited[walk] = true
230 walk = e.Successor
231 }
232
233 entry.Deprecated = true
234 entry.Successor = successorAddr
235 return "deprecated " + entry.Name + ", successor: " + string(successorAddr)
236}
237
238// ---------- read-only queries ----------
239
240// GetLatest follows the migration chain from a contract address and
241// returns the latest non-deprecated address. Safe against circular refs.
242//
243// COST WARNING (audit Y3): unlike the write-path walks, this read is
244// deliberately UNBOUNDED in hops so it always resolves the true
245// endpoint. Chain length is attacker-buildable, so an on-chain
246// integrator resolving an UNTRUSTED address inside its own transaction
247// must treat the gas cost as O(chain length) — wrap it, bound the
248// input set, or resolve off-chain via query.
249func GetLatest(contractAddr address) address {
250 visited := make(map[address]bool)
251 current := contractAddr
252
253 for {
254 entry, exists := entries[current]
255 if !exists {
256 return current
257 }
258 if !entry.Deprecated || entry.Successor == "" {
259 return current
260 }
261 if visited[current] {
262 return current // break on circular
263 }
264 visited[current] = true
265 current = entry.Successor
266 }
267}
268
269// GetMigrationChain returns the full upgrade path starting from the
270// given address, at most MaxChainLen hops. A chain longer than the
271// bound is explicitly marked as truncated (re-audit: silent truncation
272// presented a mid-chain node as the endpoint).
273func GetMigrationChain(contractAddr address) string {
274 visited := make(map[address]bool)
275 current := contractAddr
276 var parts []string
277
278 for {
279 if len(parts) >= MaxChainLen {
280 parts = append(parts, "... (truncated at "+strconv.Itoa(MaxChainLen)+
281 " hops; use GetLatest for the endpoint)")
282 break
283 }
284 entry, exists := entries[current]
285 if !exists {
286 if len(parts) == 0 {
287 return "not found: " + string(contractAddr)
288 }
289 parts = append(parts, string(current)+" (unregistered)")
290 break
291 }
292 if visited[current] {
293 parts = append(parts, string(current)+" (circular)")
294 break
295 }
296 visited[current] = true
297
298 tag := ""
299 if entry.Deprecated {
300 tag = " [deprecated]"
301 }
302 parts = append(parts, string(entry.Address)+" ("+entry.Name+")"+tag)
303
304 if !entry.Deprecated || entry.Successor == "" {
305 break
306 }
307 current = entry.Successor
308 }
309
310 return strings.Join(parts, " -> ")
311}
312
313// GetInfo returns a one-line summary for a contract.
314func GetInfo(contractAddr address) string {
315 entry := mustGet(contractAddr)
316 status := "active"
317 if entry.Deprecated {
318 status = "deprecated -> " + string(entry.Successor)
319 }
320 return entry.Name + " | owner: " + string(entry.Owner) + " | " + status
321}
322
323// GetOwnerContracts returns all contract addresses whose ACCEPTED owner
324// is the given address (nominations don't count until accepted).
325func GetOwnerContracts(owner address) string {
326 addrs, exists := ownerContracts[owner]
327 if !exists || len(addrs) == 0 {
328 return "none"
329 }
330 parts := make([]string, len(addrs))
331 for i, a := range addrs {
332 parts[i] = string(a)
333 }
334 return strings.Join(parts, ", ")
335}
336
337// ---------- render ----------
338
339// renderLatest walks at most RenderLatestHops hops for the per-row
340// annotation; longer chains are marked rather than walked (render gas
341// bound — re-audit).
342func renderLatest(contractAddr address) (address, bool) {
343 current := contractAddr
344 for i := 0; i < RenderLatestHops; i++ {
345 entry, exists := entries[current]
346 if !exists || !entry.Deprecated || entry.Successor == "" {
347 return current, true
348 }
349 current = entry.Successor
350 }
351 // round-3 audit off-by-one: the node reached ON the budget boundary
352 // may itself be terminal — check before declaring the walk incomplete
353 entry, exists := entries[current]
354 if !exists || !entry.Deprecated || entry.Successor == "" {
355 return current, true
356 }
357 return current, false
358}
359
360// Render returns a markdown overview of the most recent entries.
361// Never panics.
362func Render(path string) string {
363 if entries == nil || len(entries) == 0 {
364 return "# Upgrade Registry\n\nNo contracts registered.\n"
365 }
366
367 var b strings.Builder
368 b.WriteString("# Upgrade Registry\n\n")
369 b.WriteString("**Registered contracts:** " + strconv.Itoa(len(entries)) + "\n\n")
370
371 show := entryAddrs
372 if len(show) > MaxRenderEntries {
373 b.WriteString("_Showing the most recent " + strconv.Itoa(MaxRenderEntries) +
374 " entries._\n\n")
375 show = show[len(show)-MaxRenderEntries:]
376 }
377
378 var active []string
379 var deprecated []string
380
381 for _, addr := range show {
382 entry := entries[addr]
383 if entry == nil {
384 continue
385 }
386 line := "- **" + entry.Name + "** (`" + string(addr) + "`) — owner: `" + string(entry.Owner) + "`"
387 if entry.Deprecated {
388 line += " — **DEPRECATED** -> `" + string(entry.Successor) + "`"
389 latest, complete := renderLatest(addr)
390 if !complete {
391 line += " (long chain; use GetLatest)"
392 } else if latest != entry.Successor {
393 line += " (latest: `" + string(latest) + "`)"
394 }
395 deprecated = append(deprecated, line)
396 } else {
397 active = append(active, line)
398 }
399 }
400
401 if len(active) > 0 {
402 b.WriteString("## Active\n\n")
403 for _, l := range active {
404 b.WriteString(l + "\n")
405 }
406 b.WriteString("\n")
407 }
408
409 if len(deprecated) > 0 {
410 b.WriteString("## Deprecated\n\n")
411 for _, l := range deprecated {
412 b.WriteString(l + "\n")
413 }
414 b.WriteString("\n")
415 }
416
417 return b.String()
418}
419
420// ---------- helpers ----------
421
422func mustGet(addr address) *ContractEntry {
423 entry, ok := entries[addr]
424 if !ok {
425 panic("contract not found: " + string(addr))
426 }
427 return entry
428}