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.

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}