incident.gno
6.83 Kb · 194 lines
1package nsdata
2
3import (
4 "strconv"
5 "time"
6
7 "chain"
8)
9
10/*
11THE INCIDENT SWITCHES: the undo for what a stolen admin key set instantly
12(re-audit 2026-10-16, M-2).
13
14The admin's instant powers leave their marks in this realm — credits it
15granted its own wallets, the claim window it opened, the granter's and the
16keeper's seats — and those outlive its eviction. The incident runbook could
17not undo them in time: every logic write passes the trust gate and the
18pause, so while the logic was revoked and the registry paused nothing could
19be disarmed, and the moment recovery unpaused, the thief's wallets redeemed
20in the same block. So these are DIRECT WALLET CALLS on the vault, by the
21governance roles, that work while paused and with every logic realm
22revoked — which is exactly when they are needed — and that only ever take
23something away:
24
25 Disarm admin, recovery or guardian. Deletes the globals whose
26 presence is a power (disarmKeys): the claim window, the
27 granter and the keeper. Instant and idempotent, so the
28 guardian bot may send it without reading first.
29
30 VoidGrantsSince recovery. Everything a logic realm granted at or after
31 `since`, up to this second, is void — nslogic reads its
32 credits through GrantVoided. Windows only accumulate;
33 none is ever lifted, so nothing voided comes back to
34 life. Past maxGrantVoids the two with the smallest gap
35 merge. The admin grants again what was legitimate.
36
37 SetQuarantine recovery. Until `until`, nothing that gives a name away
38 runs — nslogic: credit redemption, RegisterFor, free
39 grants — whatever the claim window says, so the registry
40 can be unpaused before the new admin has finished
41 cleaning up. At most maxQuarantine ahead; recovery may
42 shorten or lift it.
43
44THE REGISTRY STILL DOES NOT DECIDE. The key names below are the logic
45realm's, like the restore machinery's own keys, and a logic realm must keep
46"absent" the safe reading of each: no claim window is a closed one, no
47granter and no keeper is nobody. What a void or a quarantine covers is the
48logic realm's to say; the vault keeps the clocks and the roles.
49*/
50
51// disarmKeys are the globals whose presence is a power and whose absence is
52// the safe reading (nslogic: credits.gno, freetier.gno, keeper.gno).
53var disarmKeys = []string{"claimend", "granter", "keeper"}
54
55const (
56 maxGrantVoids = 8 // void windows kept; the oldest two merge past it
57 maxQuarantine = int64(30 * 24 * 60 * 60) // the furthest a quarantine may reach, in seconds
58)
59
60var (
61 // grantVoids are [from, to] windows, unix seconds inclusive, oldest
62 // first: a grant whose time falls inside any of them is void.
63 grantVoids [][2]int64
64
65 // quarantineUntil is the unix second giveaways may run again; 0 none.
66 quarantineUntil int64
67)
68
69func assertIsRecovery(cur realm) {
70 if a := cur.Previous().Address(); a == "" || a != recovery {
71 panic("nsdata: restricted to recovery")
72 }
73}
74
75// Disarm deletes the claim window, the granter and the keeper at once.
76// Admin, recovery or guardian, as a direct wallet call; it works while the
77// registry is paused and with every logic realm revoked.
78func Disarm(cur realm) {
79 assertCanPause(cur)
80 had := ""
81 for _, k := range disarmKeys {
82 if globals.Get(k) != nil {
83 globals.Remove(k)
84 if had != "" {
85 had += ","
86 }
87 had += k
88 }
89 }
90 chain.Emit("Disarmed", "keys", had, "by", cur.Previous().Address().String())
91}
92
93// VoidGrantsSince voids every grant made at or after `since`, up to now.
94// Recovery only. `since` may be 0 (everything so far) and may not be in the
95// future. The window joins the ones already in force; past maxGrantVoids
96// the two oldest merge, which can only void more, never less.
97func VoidGrantsSince(cur realm, since int64) {
98 assertIsRecovery(cur)
99 now := time.Now().Unix()
100 if since < 0 || since > now {
101 panic("nsdata: a void starts in the past, at 0 or later")
102 }
103 grantVoids = insertVoid(grantVoids, [2]int64{since, now})
104 for len(grantVoids) > maxGrantVoids {
105 // Merge the two windows with the smallest gap between them, so the
106 // fewest legitimate grants fall into the merged span (re-audit
107 // follow-up), and say what was merged.
108 best := 0
109 for i := 1; i+1 < len(grantVoids); i++ {
110 if grantVoids[i+1][0]-grantVoids[i][1] < grantVoids[best+1][0]-grantVoids[best][1] {
111 best = i
112 }
113 }
114 a, b := grantVoids[best], grantVoids[best+1]
115 m := [2]int64{a[0], a[1]}
116 if b[1] > m[1] {
117 m[1] = b[1]
118 }
119 chain.Emit("GrantVoidsMerged", "from", strconv.FormatInt(m[0], 10), "to", strconv.FormatInt(m[1], 10))
120 grantVoids = append(append(append([][2]int64{}, grantVoids[:best]...), m), grantVoids[best+2:]...)
121 }
122 chain.Emit("GrantsVoided", "from", strconv.FormatInt(since, 10), "to", strconv.FormatInt(now, 10),
123 "by", cur.Previous().Address().String())
124}
125
126// insertVoid keeps the windows sorted by start and folds overlapping ones
127// together, so "the gap between two windows" means what it says.
128func insertVoid(ws [][2]int64, w [2]int64) [][2]int64 {
129 out := [][2]int64{}
130 placed := false
131 for _, x := range ws {
132 if !placed && w[0] < x[0] {
133 out = append(out, w)
134 placed = true
135 }
136 out = append(out, x)
137 }
138 if !placed {
139 out = append(out, w)
140 }
141 merged := [][2]int64{}
142 for _, x := range out {
143 if n := len(merged); n > 0 && x[0] <= merged[n-1][1] {
144 if x[1] > merged[n-1][1] {
145 merged[n-1][1] = x[1]
146 }
147 continue
148 }
149 merged = append(merged, x)
150 }
151 return merged
152}
153
154// GrantVoided reports whether a grant made at unix second `at` falls inside
155// a void recovery declared.
156func GrantVoided(at int64) bool {
157 for _, w := range grantVoids {
158 if at >= w[0] && at <= w[1] {
159 return true
160 }
161 }
162 return false
163}
164
165// GrantVoids lists the void windows, "from-to" comma-separated, oldest first.
166func GrantVoids() string {
167 out := ""
168 for i, w := range grantVoids {
169 if i > 0 {
170 out += ","
171 }
172 out += strconv.FormatInt(w[0], 10) + "-" + strconv.FormatInt(w[1], 10)
173 }
174 return out
175}
176
177// SetQuarantine holds every giveaway shut until `until` (unix seconds), at
178// most maxQuarantine ahead; 0 lifts it. Recovery only.
179func SetQuarantine(cur realm, until int64) {
180 assertIsRecovery(cur)
181 now := time.Now().Unix()
182 if until != 0 && (until <= now || until > now+maxQuarantine) {
183 panic("nsdata: a quarantine ends in the future and at most " +
184 strconv.FormatInt(maxQuarantine/86400, 10) + " days ahead; 0 lifts it")
185 }
186 quarantineUntil = until
187 chain.Emit("QuarantineSet", "until", strconv.FormatInt(until, 10), "by", cur.Previous().Address().String())
188}
189
190// QuarantineUntil is the unix second giveaways may run again, 0 for none.
191func QuarantineUntil() int64 { return quarantineUntil }
192
193// Quarantined reports whether a quarantine holds right now.
194func Quarantined() bool { return time.Now().Unix() < quarantineUntil }