actions.gno
8.24 Kb · 261 lines
1package nsdata
2
3import (
4 "strconv"
5 "strings"
6 "time"
7
8 "chain"
9
10 "gno.land/p/nt/avl/v0"
11)
12
13/*
14TIMELOCKED ADMIN ACTIONS: the vault's clock behind the logic realm's most
15dangerous calls.
16
17THE GAP THIS CLOSES. The timelock above guarded two things — granting a
18logic realm and rotating a role — and everything the logic realm let the
19admin do was instant. An audit took a paid name from its holder, made
20names in grace reclaimable by anybody, deleted live names and redirected
21all revenue, each in one multi-message transaction with a 48-hour delay
22in force and nothing ever proposed. No monitor could act between the
23steps.
24
25THE SHAPE. The policy stays in the logic realm — which calls are
26dangerous, and what exactly a call's arguments are — and the delay lives
27here, where no logic swap can shorten it:
28
29 ProposeAction(id) admin only, as a direct wallet call; stamped with
30 the timelock delay in force; emits ActionProposed.
31 CancelAction(id) admin or recovery, instantly; CancelAllActions for
32 an incident.
33 ConsumeAction(id) trusted logic only; panics unless the action has
34 matured and has not expired; removes it.
35
36An id names one call and its arguments, canonically — nslogic uses
37"<Function> <arg> <arg>…" — so a proposal authorizes exactly one change,
38and the event a monitor sees is the change itself rather than a hash it
39has to trust. The vault does not interpret ids at all.
40
41A matured action stays executable for actionWindow and is void after it,
42so a proposal nobody acted on cannot be executed by surprise a year later.
43At most maxPendingActions wait at once; expired ones make room. Every
44pending action dies when the admin rotates (dropAdminProposals): an
45evicted key's plans do not outlive the key.
46*/
47
48type pendingAction struct {
49 at int64 // unix second it becomes executable
50 expires int64 // unix second it stops being executable
51 by address
52}
53
54var (
55 pendingActions avl.Tree // id -> *pendingAction
56
57 maxPendingActions = 32
58
59 // Seconds a matured action stays executable. Settable within bounds by
60 // admin or recovery: shortening it only voids proposals sooner, and
61 // lengthening it cannot make anything mature faster.
62 actionWindow int64 = 7 * 24 * 60 * 60
63)
64
65const (
66 maxActionIDLen = 2048
67 minActionWindow = int64(60 * 60)
68 maxActionWindowS = int64(90 * 24 * 60 * 60)
69)
70
71func getAction(id string) *pendingAction {
72 if v := pendingActions.Get(id); v != nil {
73 return v.(*pendingAction)
74 }
75 return nil
76}
77
78func assertActionID(id string) {
79 if id == "" || len(id) > maxActionIDLen {
80 panic("nsdata: an action id is 1-" + strconv.Itoa(maxActionIDLen) + " bytes")
81 }
82 if strings.ContainsAny(id, "\n\r") {
83 panic("nsdata: an action id may not contain newlines")
84 }
85}
86
87// ProposeAction starts the clock on one admin action. Proposing an id
88// already pending restarts its clock: the latest proposal is the one that
89// must survive its window.
90func ProposeAction(cur realm, id string) {
91 assertIsAdmin(cur)
92 assertActionID(id)
93 // Not while recovery is replacing the admin, nor for one timelock after
94 // recovery vetoed one of its actions (governance.gno, re-audit M-1).
95 assertAdminMayPropose(cur.Previous().Address(), "action")
96 now := time.Now().Unix()
97 if getAction(id) == nil {
98 pruneExpiredActions(now)
99 if pendingActions.Size() >= maxPendingActions {
100 panic("nsdata: " + strconv.Itoa(maxPendingActions) + " actions are already pending; cancel some first")
101 }
102 }
103 at := now + int64(timelockDelay/time.Second)
104 a := &pendingAction{at: at, expires: at + actionWindow, by: cur.Previous().Address()}
105 pendingActions.Set(id, a)
106 chain.Emit("ActionProposed", "id", id,
107 "executableAt", strconv.FormatInt(a.at, 10),
108 "expiresAt", strconv.FormatInt(a.expires, 10),
109 "by", a.by.String())
110}
111
112// CancelAction withdraws one pending action. Admin or recovery: the admin
113// changing its mind, or recovery vetoing a key it no longer trusts.
114func CancelAction(cur realm, id string) {
115 assertIsAdminOrRecovery(cur)
116 a := getAction(id)
117 if a == nil {
118 return // idempotent: see CancelPending
119 }
120 pendingActions.Remove(id)
121 veto(cur.Previous().Address(), "action", a.by)
122 chain.Emit("ActionCancelled", "id", id, "by", cur.Previous().Address().String())
123}
124
125// CancelAllActions withdraws every pending action at once — the incident
126// call, when nobody wants to read a list before acting. From recovery it
127// is a veto: the admin proposes no action for one timelock.
128func CancelAllActions(cur realm) {
129 assertIsAdminOrRecovery(cur)
130 n := dropAllActions("cancelled")
131 if n > 0 {
132 veto(cur.Previous().Address(), "action", admin)
133 }
134 chain.Emit("ActionsCancelled", "count", strconv.Itoa(n), "by", cur.Previous().Address().String())
135}
136
137/*
138ConsumeAction spends a matured action. Trusted logic only, so it is also
139gated by the pause: a paused registry executes nothing, timelocked or not.
140
141It refuses an action proposed by anybody but the CURRENT admin. Rotation
142already drops them all; this is the second lock on the same door, because
143a matured proposal is a loaded instruction and the vault should not rely
144on one rule to unload it.
145*/
146func ConsumeAction(cur realm, id string) {
147 assertIsTrustedLogic(cur)
148 if EvictionPending() {
149 panic("nsdata: recovery is replacing the admin; timelocked actions wait for that, and die with it")
150 }
151 a := getAction(id)
152 if a == nil {
153 panic("nsdata: action `" + id + "` was never proposed; the admin proposes it with ProposeAction and it waits out the timelock")
154 }
155 now := time.Now().Unix()
156 if now < a.at {
157 panic("nsdata: action `" + id + "` is in its timelock until unix " + strconv.FormatInt(a.at, 10))
158 }
159 if now >= a.expires {
160 panic("nsdata: action `" + id + "` expired at unix " + strconv.FormatInt(a.expires, 10) + "; propose it again")
161 }
162 if a.by != admin {
163 panic("nsdata: action `" + id + "` was proposed by a previous admin")
164 }
165 pendingActions.Remove(id)
166 chain.Emit("ActionExecuted", "id", id, "by", cur.Previous().Address().String())
167}
168
169// ActionReady reports whether an action could be consumed right now.
170func ActionReady(id string) bool {
171 a := getAction(id)
172 if a == nil {
173 return false
174 }
175 now := time.Now().Unix()
176 return now >= a.at && now < a.expires && a.by == admin && !EvictionPending()
177}
178
179// GetAction reports a pending action: when it matures, when it expires,
180// and who proposed it. All zero when nothing by that id is pending.
181func GetAction(id string) (executableAt, expiresAt int64, by address) {
182 a := getAction(id)
183 if a == nil {
184 return 0, 0, ""
185 }
186 return a.at, a.expires, a.by
187}
188
189// ListActions pages through pending action ids, ONE PER LINE: an id may
190// contain commas (a price rule does), never a newline.
191func ListActions(after string, limit int) (ids, next string) {
192 if limit <= 0 || limit > maxPage {
193 limit = maxPage
194 }
195 var b strings.Builder
196 n := 0
197 last, more := "", false
198 pendingActions.Iterate("", "", func(key string, v any) bool {
199 if after != "" && key <= after {
200 return false
201 }
202 if n >= limit {
203 more = true
204 return true
205 }
206 if n > 0 {
207 b.WriteString("\n")
208 }
209 b.WriteString(key)
210 last = key
211 n++
212 return false
213 })
214 if !more {
215 last = ""
216 }
217 return b.String(), last
218}
219
220func GetActionWindow() int64 { return actionWindow }
221
222// SetActionWindow sets how long a matured action stays executable,
223// between an hour and ninety days.
224func SetActionWindow(cur realm, seconds int64) {
225 assertIsAdminOrRecovery(cur)
226 if seconds < minActionWindow || seconds > maxActionWindowS {
227 panic("nsdata: the action window is 3600-7776000 seconds")
228 }
229 actionWindow = seconds
230 chain.Emit("ActionWindowSet", "seconds", strconv.FormatInt(seconds, 10),
231 "by", cur.Previous().Address().String())
232}
233
234// pruneExpiredActions frees the slots of actions nobody can execute any
235// more. Bounded by maxPendingActions.
236func pruneExpiredActions(now int64) {
237 var dead []string
238 pendingActions.Iterate("", "", func(key string, v any) bool {
239 if now >= v.(*pendingAction).expires {
240 dead = append(dead, key)
241 }
242 return false
243 })
244 for _, k := range dead {
245 pendingActions.Remove(k)
246 chain.Emit("ActionExpired", "id", k)
247 }
248}
249
250func dropAllActions(reason string) int {
251 var ids []string
252 pendingActions.Iterate("", "", func(key string, v any) bool {
253 ids = append(ids, key)
254 return false
255 })
256 for _, k := range ids {
257 pendingActions.Remove(k)
258 chain.Emit("ActionInvalidated", "id", k, "reason", reason)
259 }
260 return len(ids)
261}