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 }