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

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}