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.

governance.gno

26.12 Kb · 655 lines
  1package nsdata
  2
  3import (
  4	"strconv"
  5	"time"
  6
  7	"chain"
  8
  9	"gno.land/p/nt/avl/v0"
 10)
 11
 12// Governance and emergency response for a realm that can never be
 13// redeployed. Everything here is callable ONLY by a direct wallet
 14// transaction — never through a logic realm — so a compromised or buggy
 15// logic realm can never touch it.
 16//
 17// Three powers, deliberately not held by the same key:
 18//
 19//	admin     — day-to-day authority: grants/revokes logic realms, tunes
 20//	            config. The warm key.
 21//	recovery  — cannot operate the registry at all. Can only rotate
 22//	            admin, cancel a pending change, revoke a logic realm,
 23//	            and pause. MUST be a multisig in production; see the
 24//	            trust note below for why that is not optional.
 25//	guardian  — can ONLY pause. Nothing else. Safe to keep hot (a
 26//	            monitoring bot), because its compromise costs an
 27//	            outage, never the registry.
 28//
 29// GRANTING POWER IS SLOW, REVOKING IT IS INSTANT. Handing authority to a
 30// new admin or a new logic realm goes through a timelock, and so — since
 31// actions.gno — does every admin action in the logic realm that destroys
 32// or moves names or money (deletion, lifecycle and treasury changes,
 33// unfreezing, reassigning, pricing rules, large price moves). Revocation,
 34// cancellation and pause are immediate; undoing a pause takes recovery or
 35// the timelock. An attacker holding one key cannot act instantly: they
 36// must announce and wait, and every step emits an event.
 37//
 38// EACH ROLE CAN ROTATE THE OTHER, AND THE ADMIN CANNOT VETO ITS OWN
 39// REPLACEMENT. A rotation recovery proposes — of the admin, or of itself —
 40// can be cancelled or displaced only by recovery. An admin-initiated
 41// rotation of recovery is the admin's to withdraw and, since the re-audit
 42// of 2026-10-16, recovery's to veto too: recovery is the root (see the
 43// trust note below), and a veto it could not send in its incident sitting
 44// reverted the whole sitting, eviction included. A LOST recovery cancels
 45// nothing, so the admin can still replace it. Proposals live in PER-KIND
 46// slots so an admin rotation and a recovery rotation can never contend for
 47// the same slot — an earlier single-slot design let one in-flight proposal
 48// freeze the entire governance surface, which meant the honest key could
 49// not even propose a response.
 50//
 51// THE TRUST ASYMMETRY, chosen rather than papered over. Both roles rotate
 52// the other on the same timelock, and it used to be that whichever proposed
 53// FIRST matured first and won — so a thief holding the admin key, acting
 54// before anybody noticed, could take recovery too. Since the re-audit of
 55// 2026-10-16 (M-1) RECOVERY WINS: once recovery's eviction is pending,
 56// nothing the admin proposed executes before it, a rotation of recovery the
 57// admin queued earlier included, and the eviction drops all of it. Every
 58// asymmetry hands the mirror-image power to whoever steals the favoured
 59// role, and this one is deliberate, the same as the ledger's (nsnft
 60// governance.gno): a stolen RECOVERY key now beats an honest admin, which
 61// is why recovery must be the cold multisig and the admin the key that is
 62// used.
 63//
 64// The mitigation is therefore operational as well as contractual:
 65//   - recovery MUST be a multisig, so no single stolen key can use it;
 66//   - timelockDelay must be long enough that you will actually notice an
 67//     event within it;
 68//   - watch for GovernanceProposed and ActionProposed and treat one you
 69//     did not initiate as an active incident.
 70//
 71// THE INCIDENT RUNBOOK, in this order (MAINNET.md §1b has it in full).
 72// First RevokeTrustedLogic on every entry of ListTrustedLogic(): guardian,
 73// admin or recovery may, it is instant, and re-granting needs the
 74// timelock, so no stolen key can switch the logic back on. Then Pause, and
 75// Disarm (incident.gno). Then, from recovery: the counter-rotation FIRST and
 76// alone — proposing it drops everything the admin had pending — then
 77// VoidGrantsSince and SetQuarantine (incident.gno), and CancelAllActions /
 78// CancelPending on anything left, which cannot revert; execute the eviction
 79// at maturity, before anything else.
 80//
 81// Pause does NOT stop ExecutePending — it stops registry mutations, which
 82// limits damage but does not stop a rotation. Do not rely on it for that.
 83//
 84// RECOVERY'S EVICTION LANDS FIRST, by rule (re-audit 2026-10-16, M-1). An
 85// eviction used to drop the admin's plans only when it executed, and anyone
 86// chose the order matured proposals ran in: the thief could re-propose a
 87// cancelled grant the block after recovery's sitting, or execute recovery's
 88// own move to a new cold key first, which dropped recovery's eviction. Now:
 89//
 90//	recovery proposing the eviction DROPS everything the admin had pending
 91//	— grants, delay, unpause, a rotation of recovery, every timelocked
 92//	action — so nothing of the admin's is held to spring back if the
 93//	eviction is cancelled; while it is pending (EvictionPending) the admin
 94//	proposes nothing, nothing it proposed may execute, and logic can spend
 95//	none of its actions;
 96//
 97//	a veto holds: what recovery cancels of the admin's, the admin may not
 98//	propose again for one timelock (any action, after an action veto), so
 99//	the sitting cannot be re-proposed around; recovery's eviction landing
100//	clears the vetoes, and nothing else does;
101//
102//	recovery moving ITSELF keeps its eviction: the eviction is protected by
103//	the recovery role, which the new key holds, and recovery's rotation of
104//	itself is recovery's alone to cancel or displace. Only an
105//	admin-initiated replacement of recovery drops the replaced key's
106//	eviction.
107//
108//	recovery's cancellations are idempotent: cancelling what is no longer
109//	pending does nothing, so a thief withdrawing its own proposal first
110//	cannot make recovery's batched sitting revert.
111type pendingOp struct {
112	kind string // "admin" | "recovery" | "grantLogic" | "delay" | "unpause"
113	addr address
114	dur  time.Duration
115	at   time.Time // when it becomes executable
116
117	// protectorRole names the role that has EXCLUSIVE control of this
118	// proposal — "" means either admin or recovery may cancel it. Set to
119	// "recovery" on a recovery-initiated admin rotation, and to "admin"
120	// on an admin-initiated recovery rotation, so the role being replaced
121	// cannot veto its own replacement.
122	protectorRole string
123
124	// by is who proposed it. Recorded so that nobody has to reconstruct
125	// it from events, and so the rotation rule below is checkable.
126	by address
127}
128
129var (
130	recovery address
131	guardian address
132	paused   bool
133
134	// Delay applied to every power-granting change. Starts at zero so
135	// local development and initial wiring are not painful; raise it
136	// before the registry holds anything worth stealing. Changing it is
137	// itself timelocked, or an attacker would simply set it to zero.
138	timelockDelay time.Duration
139
140	// Upper bound on timelockDelay. Without it, one execution of an
141	// absurd delay would stamp every FUTURE proposal with an unreachable
142	// maturity and permanently brick governance in a realm that cannot be
143	// redeployed. Settable, but itself bounded by absoluteMaxTimelock.
144	maxTimelockDelay = 30 * 24 * time.Hour
145
146	// One genuine hard ceiling: a bound that could itself be raised
147	// without limit would not be a bound.
148	absoluteMaxTimelock = 365 * 24 * time.Hour
149
150	// Per-kind proposal slots, keyed by kind. Per-kind rather than a
151	// single slot so that one in-flight proposal cannot freeze the whole
152	// governance surface.
153	pendingOps avl.Tree
154
155	// If this registry is ever abandoned in favour of a successor
156	// deployment, this is how readers discover where it went.
157	successor string
158
159	// kind -> unix second until which the admin may not propose that kind
160	// again, because recovery vetoed it; "action" for timelocked actions.
161	// See the note above pendingOp. Cleared when the admin rotates.
162	vetoUntil avl.Tree
163)
164
165// -- role checks --
166
167func assertIsAdmin(cur realm) {
168	if cur.Previous().Address() != admin {
169		panic("nsdata: restricted to admin")
170	}
171}
172
173func assertIsAdminOrRecovery(cur realm) {
174	a := cur.Previous().Address()
175	if a != admin && a != recovery {
176		panic("nsdata: restricted to admin or recovery")
177	}
178}
179
180func assertCanPause(cur realm) {
181	a := cur.Previous().Address()
182	if a != admin && a != recovery && a != guardian {
183		panic("nsdata: restricted to admin, recovery or guardian")
184	}
185}
186
187// -- emergency stop --
188
189// Pause halts every registry mutation, timelocked actions included.
190// Reads are unaffected, so names stay resolvable while writes are frozen.
191//
192// It deliberately does NOT gate ExecutePending: governance must keep
193// working during an incident — recovery's counter-rotation above all.
194//
195// A pause also drops a pending admin-proposed unpause, so the guardian
196// bot can hold the registry paused against a stolen admin key simply by
197// pausing again whenever it sees UnpauseProposed.
198func Pause(cur realm) {
199	assertCanPause(cur)
200	paused = true
201	if getPendingOp("unpause") != nil {
202		pendingOps.Remove("unpause")
203		chain.Emit("GovernanceInvalidated", "kind", "unpause", "reason", "paused again")
204	}
205	chain.Emit("Paused", "by", cur.Previous().Address().String())
206}
207
208/*
209Unpause restarts the registry at once, and only RECOVERY may do that.
210
211It used to be open to the admin too, which made a pause useless against
212the key most likely to be stolen: the admin could unpause in the same
213multi-message transaction as whatever it did next, so a pause stopped only
214writers that did not hold admin. An audit showed it. The admin can still
215unpause — ProposeUnpause, then the timelock — so a false alarm costs a
216delay rather than the cold key; recovery can cancel that proposal, and any
217new pause drops it. The guardian is excluded as before: a hot key that can
218stop the system must not also be able to restart it.
219*/
220func Unpause(cur realm) {
221	if cur.Previous().Address() != recovery {
222		panic("nsdata: an instant unpause takes the recovery key; the admin may ProposeUnpause and wait out the timelock")
223	}
224	paused = false
225	if getPendingOp("unpause") != nil {
226		pendingOps.Remove("unpause")
227	}
228	chain.Emit("Unpaused", "by", cur.Previous().Address().String())
229}
230
231// ProposeUnpause is the admin's way back from a pause: a proposal like
232// any grant of power, executable by anyone once it matures.
233func ProposeUnpause(cur realm) {
234	assertIsAdmin(cur)
235	if !paused {
236		panic("nsdata: the registry is not paused")
237	}
238	setPending(cur, &pendingOp{kind: "unpause"})
239}
240
241func IsPaused() bool { return paused }
242
243// -- trusted logic realms (a set, not a single slot) --
244
245func IsTrustedLogic(addr address) bool {
246	return trustedLogicSet.Get(addr.String()) != nil
247}
248
249func ListTrustedLogic() string {
250	out := ""
251	trustedLogicSet.Iterate("", "", func(key string, v any) bool {
252		if out != "" {
253			out += ","
254		}
255		out += key
256		return false
257	})
258	return out
259}
260
261// RevokeTrustedLogic is INSTANT and available to the guardian, because
262// cutting off a misbehaving logic realm is the emergency action that must
263// never wait on a timelock.
264func RevokeTrustedLogic(cur realm, logicAddr address) {
265	assertCanPause(cur)
266	trustedLogicSet.Remove(logicAddr.String())
267	chain.Emit("TrustedLogicRevoked", "addr", logicAddr.String(), "by", cur.Previous().Address().String())
268}
269
270// -- timelocked proposals --
271
272// ProposeAdminTransfer starts an admin rotation. When recovery initiates
273// it, only recovery may cancel it — the admin being replaced gets no veto
274// over their own replacement.
275func ProposeAdminTransfer(cur realm, newAdmin address) {
276	assertIsAdminOrRecovery(cur)
277	if !newAdmin.IsValid() {
278		panic("nsdata: invalid address")
279	}
280	role := ""
281	if cur.Previous().Address() == recovery && recovery != admin {
282		role = "recovery"
283	}
284	setPending(cur, &pendingOp{kind: "admin", addr: newAdmin, protectorRole: role})
285	if role == "recovery" {
286		// AN EVICTION DROPS THE ADMIN'S PLANS WHEN IT IS PROPOSED, not only
287		// when it lands: held instead, they all sprang back the moment the
288		// eviction was cancelled — to fix a typo in its target, say — and a
289		// rotation of recovery the admin queued outlived it (re-audit
290		// follow-up, 2026-10-16).
291		dropPendingProtectedBy("recovery", "admin")
292		dropAdminProposals("recovery is replacing the admin")
293	}
294}
295
296// ProposeRecoveryTransfer is the mirror image: when admin initiates it,
297// only admin may cancel it, so a compromised recovery key cannot block
298// its own replacement either.
299func ProposeRecoveryTransfer(cur realm, newRecovery address) {
300	assertIsAdminOrRecovery(cur)
301	if !newRecovery.IsValid() {
302		panic("nsdata: invalid address")
303	}
304	role := ""
305	if cur.Previous().Address() == admin && recovery != admin {
306		role = "admin"
307	}
308	// Recovery's rotation of itself is recovery's alone: unprotected, the
309	// admin could cancel it, or displace it with a rotation of its own that
310	// recovery could then neither cancel nor replace.
311	if cur.Previous().Address() == recovery && recovery != admin {
312		role = "recovery"
313	}
314	setPending(cur, &pendingOp{kind: "recovery", addr: newRecovery, protectorRole: role})
315}
316
317func ProposeTrustedLogicGrant(cur realm, logicAddr address) {
318	assertIsAdmin(cur)
319	if !logicAddr.IsValid() {
320		panic("nsdata: invalid address")
321	}
322	setPending(cur, &pendingOp{kind: "grantLogic", addr: logicAddr})
323}
324
325// ProposeTimelockDelay is bounded on BOTH sides. An unbounded delay would
326// let a single execution stamp every future proposal with an unreachable
327// maturity, permanently disabling governance in an unredeployable realm.
328func ProposeTimelockDelay(cur realm, seconds int64) {
329	assertIsAdmin(cur)
330	if seconds < 0 {
331		panic("nsdata: delay cannot be negative")
332	}
333	d := time.Duration(seconds) * time.Second
334	// Guard the multiplication itself, not just the input: seconds is
335	// attacker-chosen and time.Duration is int64 nanoseconds, so a large
336	// value would overflow to a negative (instantly-mature) delay.
337	if seconds > int64(absoluteMaxTimelock/time.Second) || d < 0 || d > maxTimelockDelay {
338		panic("nsdata: delay out of range")
339	}
340	setPending(cur, &pendingOp{kind: "delay", dur: d})
341}
342
343// SetMaxTimelockDelay raises or lowers the ceiling on timelockDelay.
344// Bounded by absoluteMaxTimelock, and it cannot be set below the delay
345// currently in force.
346func SetMaxTimelockDelay(cur realm, seconds int64) {
347	assertIsAdminOrRecovery(cur)
348	// Bound the input before multiplying: a huge value used to wrap to a
349	// sub-second ceiling and be accepted (audit L-3a).
350	if seconds < 0 || seconds > int64(absoluteMaxTimelock/time.Second) {
351		panic("nsdata: max delay out of range")
352	}
353	d := time.Duration(seconds) * time.Second
354	if d < 0 || d > absoluteMaxTimelock || d < timelockDelay {
355		panic("nsdata: max delay out of range")
356	}
357	maxTimelockDelay = d
358	chain.Emit("MaxTimelockDelaySet", "seconds", strconv.FormatInt(int64(d/time.Second), 10),
359		"by", cur.Previous().Address().String())
360}
361
362func canControl(op *pendingOp, caller address) bool {
363	switch op.protectorRole {
364	case "recovery":
365		return caller == recovery
366	case "admin":
367		// The admin's rotation of recovery: the admin's to withdraw, and
368		// recovery's to veto (see the note at the top).
369		return caller == admin || caller == recovery
370	default:
371		return caller == admin || caller == recovery
372	}
373}
374
375func getPendingOp(kind string) *pendingOp {
376	if v := pendingOps.Get(kind); v != nil {
377		return v.(*pendingOp)
378	}
379	return nil
380}
381
382// EvictionPending reports a pending rotation of the admin that recovery
383// proposed while the two roles are separate keys: recovery replacing an
384// admin it no longer trusts. See the note above pendingOp.
385func EvictionPending() bool {
386	op := getPendingOp("admin")
387	return op != nil && op.protectorRole == "recovery"
388}
389
390// adminAlone: the address is the admin and not also recovery — the case
391// every eviction rule is about. While one key holds both, nothing is.
392func adminAlone(a address) bool { return a == admin && a != recovery }
393
394// assertAdminMayPropose refuses the admin while recovery is replacing it,
395// and for one timelock after recovery vetoed a proposal of this kind.
396func assertAdminMayPropose(caller address, kind string) {
397	if !adminAlone(caller) {
398		return
399	}
400	if EvictionPending() {
401		panic("nsdata: recovery is replacing the admin; the admin proposes nothing until that lands")
402	}
403	if v := vetoUntil.Get(kind); v != nil && time.Now().Unix() < v.(int64) {
404		panic("nsdata: recovery vetoed the admin's " + kind + "; the admin may propose one again from unix " +
405			strconv.FormatInt(v.(int64), 10))
406	}
407}
408
409// veto starts the cooldown on a kind, when recovery has just cancelled
410// something the admin proposed.
411func veto(caller address, kind string, proposer address) {
412	if caller != recovery || caller == admin || !adminAlone(proposer) || timelockDelay <= 0 {
413		return
414	}
415	until := time.Now().Unix() + int64(timelockDelay/time.Second)
416	vetoUntil.Set(kind, until)
417	chain.Emit("GovernanceVetoed", "kind", kind, "until", strconv.FormatInt(until, 10), "by", caller.String())
418}
419
420func setPending(cur realm, op *pendingOp) {
421	caller := cur.Previous().Address()
422	if existing := getPendingOp(op.kind); existing != nil {
423		// You may replace only what you could cancel. Slots are per-kind,
424		// so this contends only with another proposal of the SAME kind.
425		if !canControl(existing, caller) {
426			panic("nsdata: a change of this kind is pending and you may not replace it")
427		}
428		chain.Emit("GovernanceDisplaced", "kind", existing.kind, "by", caller.String())
429	}
430	assertAdminMayPropose(caller, op.kind)
431	op.at = time.Now().Add(timelockDelay)
432	op.by = caller
433	pendingOps.Set(op.kind, op)
434	// `seconds` carries a delay proposal's value, which this event used to
435	// leave out, so "set the delay to zero" and "raise it" looked the same
436	// to a monitor (audit L-3c).
437	chain.Emit("GovernanceProposed",
438		"kind", op.kind,
439		"addr", op.addr.String(),
440		"seconds", strconv.FormatInt(int64(op.dur/time.Second), 10),
441		"executableAt", op.at.Format(time.RFC3339),
442		"by", caller.String())
443}
444
445// CancelPending cancels the pending change of one kind. A proposal with a
446// protectorRole can be cancelled only by that role, so a compromised key
447// cannot veto its own replacement.
448func CancelPending(cur realm, kind string) {
449	assertIsAdminOrRecovery(cur)
450	op := getPendingOp(kind)
451	if op == nil {
452		return // idempotent, so a batched incident sitting cannot be made to revert
453	}
454	if !canControl(op, cur.Previous().Address()) {
455		panic("nsdata: only the " + op.protectorRole + " key may cancel this change")
456	}
457	pendingOps.Remove(kind)
458	veto(cur.Previous().Address(), kind, op.by)
459	chain.Emit("GovernanceCancelled", "kind", kind, "by", cur.Previous().Address().String())
460}
461
462// ExecutePending applies a matured change. Callable by anyone on purpose:
463// execution must not depend on a particular key still being available,
464// since key unavailability is one of the things being recovered from. It
465// only applies what was already proposed and survived its window.
466func ExecutePending(cur realm, kind string) {
467	op := getPendingOp(kind)
468	if op == nil {
469		panic("nsdata: nothing pending of that kind")
470	}
471	if time.Now().Before(op.at) {
472		panic("nsdata: still in the timelock window")
473	}
474	// The ceiling may have been lowered since this was proposed; the
475	// ceiling in force wins (audit L-3b). Checked before anything moves.
476	if op.kind == "delay" && op.dur > maxTimelockDelay {
477		panic("nsdata: that delay is above the ceiling now in force")
478	}
479	// The eviction lands first, whoever picks the order: nothing the admin
480	// proposed runs while recovery is replacing it (see above pendingOp).
481	if op.kind != "admin" && adminAlone(op.by) && EvictionPending() {
482		panic("nsdata: recovery is replacing the admin; the admin's pending changes wait for that, and die with it")
483	}
484	pendingOps.Remove(kind)
485	switch op.kind {
486	case "admin":
487		admin = op.addr
488		// Recovery's eviction landing clears the vetoes it was fighting with;
489		// an admin moving to a key of its own choosing does not.
490		if op.protectorRole == "recovery" {
491			vetoUntil = avl.Tree{}
492		}
493		// The admin that was just replaced cannot leave behind a live
494		// proposal to replace recovery...
495		dropPendingProtectedBy("recovery", "admin")
496		// ...nor anything else only an admin could have proposed. This
497		// used to drop the recovery rotation alone, so an evicted thief's
498		// grantLogic survived the eviction — re-proposed just before it,
499		// it matured a delay later and the thief, holding no role at all,
500		// executed it (anyone may): a realm of their choosing trusted with
501		// every vault write. An audit proved it. The new admin re-proposes
502		// whatever it actually wants.
503		dropAdminProposals("admin rotated")
504	case "recovery":
505		recovery = op.addr
506		// Likewise: a superseded recovery key's pending admin rotation
507		// dies with it, so a stolen key cannot keep acting after the
508		// operator has rotated it out — when the ADMIN replaced it. Recovery
509		// moving itself keeps its eviction, which the recovery role (now the
510		// new key) still protects: executing that rotation first used to be
511		// the thief's way to void the eviction (re-audit M-1 b).
512		if op.protectorRole == "admin" {
513			dropPendingProtectedBy("admin", "recovery")
514		}
515	case "grantLogic":
516		trustedLogicSet.Set(op.addr.String(), true)
517	case "delay":
518		timelockDelay = op.dur
519	case "unpause":
520		paused = false
521	default:
522		panic("nsdata: unknown pending kind")
523	}
524	chain.Emit("GovernanceExecuted", "kind", op.kind, "addr", op.addr.String(),
525		"seconds", strconv.FormatInt(int64(op.dur/time.Second), 10),
526		"proposedBy", op.by.String(), "by", cur.Previous().Address().String())
527}
528
529// dropPendingProtectedBy removes the pending change of `kind` when it is
530// protected by `role` — used when that role's key has just been replaced.
531func dropPendingProtectedBy(kind, role string) {
532	if op := getPendingOp(kind); op != nil && op.protectorRole == role {
533		pendingOps.Remove(kind)
534		chain.Emit("GovernanceInvalidated", "kind", kind, "reason", role+" rotated")
535	}
536}
537
538// adminOnlyKinds are the proposals only the admin can make. Every one of
539// them dies when the admin rotates: whoever proposed it no longer holds
540// the key that could, and an eviction that leaves the evicted key's plans
541// running has not evicted anybody.
542var adminOnlyKinds = []string{"grantLogic", "delay", "unpause"}
543
544func dropAdminProposals(reason string) {
545	for _, k := range adminOnlyKinds {
546		if getPendingOp(k) != nil {
547			pendingOps.Remove(k)
548			chain.Emit("GovernanceInvalidated", "kind", k, "reason", reason)
549		}
550	}
551	// Timelocked admin actions too (actions.gno): every one was proposed
552	// by the key that just lost the role, or is about to.
553	dropAllActions(reason)
554}
555
556func GetPending(kind string) (addr address, executableAt time.Time, protectorRole string) {
557	op := getPendingOp(kind)
558	if op == nil {
559		return "", time.Time{}, ""
560	}
561	return op.addr, op.at, op.protectorRole
562}
563
564// GetPendingDetail is GetPending with the rest of the proposal: the delay
565// a "delay" proposal would install, in seconds — which GetPending could
566// not show, so "set the delay to zero" and "raise it" looked identical —
567// and who proposed it.
568func GetPendingDetail(kind string) (addr address, seconds int64, executableAt time.Time, protectorRole string, by address) {
569	op := getPendingOp(kind)
570	if op == nil {
571		return "", 0, time.Time{}, "", ""
572	}
573	return op.addr, int64(op.dur / time.Second), op.at, op.protectorRole, op.by
574}
575
576func ListPending() string {
577	out := ""
578	pendingOps.Iterate("", "", func(key string, v any) bool {
579		if out != "" {
580			out += ","
581		}
582		out += key
583		return false
584	})
585	return out
586}
587
588// -- instant, low-risk admin settings --
589
590// SetGuardian takes the RECOVERY key, to seat a guardian as much as to
591// replace one.
592//
593// Replacing took recovery from the start: otherwise the first move of
594// anyone who steals the admin key is to point the guardian at themselves,
595// disarming the emergency stop before you can use it. Seating an EMPTY
596// seat used to be the admin's too, and that was the same hole: a stolen
597// admin key seated its own bot, and after its eviction that bot could
598// still pause and revoke, removable only by recovery (re-audit 2026-10-16,
599// L-4). The deploy seats the guardian while its key still holds recovery.
600func SetGuardian(cur realm, addr address) {
601	if cur.Previous().Address() != recovery {
602		if guardian == "" {
603			panic("nsdata: seating a guardian takes the recovery key")
604		}
605		panic("nsdata: replacing an existing guardian requires the recovery key")
606	}
607	guardian = addr
608	chain.Emit("GuardianSet", "addr", addr.String(), "by", cur.Previous().Address().String())
609}
610
611/*
612ClearGuardian stands the pause capability down.
613
614"Removing a power is never the dangerous direction" was the old
615reasoning here, and it is wrong in one specific way: removing this power
616restores the state in which SetGuardian is admin-callable. SetGuardian
617demands the RECOVERY key whenever a guardian is already set, and says
618why — a stolen admin key must not be able to disarm the emergency stop.
619ClearGuardian then SetGuardian was a two-transaction admin-only
620replacement of a live guardian, which defeats exactly that rule.
621
622So standing down a LIVE guardian now takes the same key that replacing
623one takes. Clearing when there is no guardian stays open to either role,
624because then there is nothing to defeat.
625
626The practical stakes are small — assertCanPause admits admin, recovery
627and guardian, so an operator holding recovery keeps Pause and
628RevokeTrustedLogic either way. It is fixed because this realm can never
629be redeployed, and a documented authorization rule that a two-call
630sequence walks around is not something to leave in permanent code.
631*/
632func ClearGuardian(cur realm) {
633	assertIsAdminOrRecovery(cur)
634	if guardian != "" && cur.Previous().Address() != recovery {
635		panic("nsdata: standing down a live guardian takes the recovery key, the same as replacing one")
636	}
637	guardian = ""
638	chain.Emit("GuardianSet", "addr", "", "by", cur.Previous().Address().String())
639}
640
641// SetSuccessor records where this registry moved, if it ever has to be
642// abandoned. Pair it with Pause() so the retired registry cannot drift
643// away from the snapshot its successor was built from.
644func SetSuccessor(cur realm, s string) {
645	assertIsAdminOrRecovery(cur)
646	successor = s
647	chain.Emit("SuccessorSet", "successor", s, "by", cur.Previous().Address().String())
648}
649
650func GetAdmin() address                  { return admin }
651func GetRecovery() address               { return recovery }
652func GetGuardian() address               { return guardian }
653func GetSuccessor() string               { return successor }
654func GetTimelockDelay() time.Duration    { return timelockDelay }
655func GetMaxTimelockDelay() time.Duration { return maxTimelockDelay }