Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}