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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}