Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

timelock_guardian.gno

24.40 Kb · 757 lines
  1package timelock_guardian
  2
  3import (
  4	"chain"
  5	"chain/runtime/unsafe"
  6	"strconv"
  7	"strings"
  8	"time"
  9)
 10
 11// TargetConfig binds a named target to the only address allowed to
 12// schedule actions against it, an enforced minimum delay, and an
 13// optional guardian who can veto pending actions. Without this binding
 14// a timelock attests nothing: anyone could schedule their own
 15// short-delay action against any name and "execute" it.
 16type TargetConfig struct {
 17	Name     string
 18	Owner    address
 19	Guardian address // empty = no guardian
 20	MinDelay int64   // seconds; every action for this target waits at least this
 21	// PendingOwner is the offered-but-not-accepted new owner (fix Y5:
 22	// ownership moves in two steps, so a stranger can never have a
 23	// target — and its quota slot and pending obligations — dumped on
 24	// them without consenting).
 25	PendingOwner address
 26}
 27
 28// Action represents a scheduled operation that can only execute after
 29// its delay has elapsed, and only within its grace window.
 30//
 31// State model (re-audit 2026-09-02): only PENDING and EXECUTED actions
 32// are stored. Executed records are permanent attestations consumers
 33// check via IsExecuted. Cancelled, vetoed, and expired actions are
 34// REAPED from state — their history lives in emitted events — so the
 35// live-action cap bounds live exposure and can never be consumed
 36// permanently by schedule/cancel cycling.
 37type Action struct {
 38	ID           string
 39	Target       string
 40	Creator      address
 41	Data         string // encoded call data or description of the action
 42	ScheduledAt  time.Time
 43	ExecuteAfter time.Time
 44	ExpiresAt    time.Time // ExecuteAfter + GracePeriod; not executable after
 45	Executed     bool
 46}
 47
 48const (
 49	// MinDelayFloor is the smallest minimum delay a target may register.
 50	// A timelock with a 1-second delay protects nothing.
 51	MinDelayFloor = int64(60)
 52
 53	// MaxDelay bounds both registered minimum delays and per-action
 54	// delays. Also the overflow guard: MaxDelay seconds in nanoseconds
 55	// is far below int64 range, so ExecuteAfter arithmetic cannot wrap
 56	// into the past.
 57	MaxDelay = int64(10 * 365 * 24 * 3600) // 10 years
 58
 59	// GracePeriod is how long an action stays executable once ready.
 60	// After it, the action expires: something scheduled and forgotten
 61	// cannot be sprung on a target years later.
 62	GracePeriod = int64(30 * 24 * 3600) // 30 days
 63
 64	// Quotas are PER-OWNER only (re-audit round 3): any global cap is a
 65	// shared resource a few sybil accounts can exhaust forever (10y
 66	// delays defeat expiry sweeping), bricking every other tenant. With
 67	// per-owner quotas an attacker only ever consumes their own budget;
 68	// state growth is priced in gas and funded accounts.
 69	MaxTargetsPerOwner  = 10
 70	MaxPendingPerTarget = 20
 71	MaxTargetNameLen    = 64
 72	MaxDataLen          = 2000
 73	MaxRenderActions    = 100
 74	// RenderIndexCap bounds the render-ordering index (fix Y4): the
 75	// index self-trims to this size, so reaping an entry from it is a
 76	// bounded scan no matter how many actions the realm has ever seen.
 77	// Records older than the window stay in state (GetAction/IsExecuted
 78	// are map reads and permanent); they only leave the front page.
 79	RenderIndexCap = 2 * MaxRenderActions
 80)
 81
 82var (
 83	targets      map[string]*TargetConfig
 84	targetNames  []string // insertion-ordered for deterministic iteration
 85	ownerTargets map[address]int
 86	actions      map[string]*Action
 87	actionIDs    []string // insertion-ordered render window, self-trimmed to RenderIndexCap
 88	// pending holds each target's LIVE pending action IDs in insertion
 89	// order (fix Y4): every list is bounded by MaxPendingPerTarget, so
 90	// scans, sweeps, and removals are bounded per call regardless of how
 91	// much state OTHER tenants have grown — queue position in a global
 92	// list is no longer a shared resource.
 93	pending map[string][]string
 94	// pendingTargets lists the targets holding >= 1 live pending action,
 95	// maintained with O(1) swap-remove via ptIndex (round-2 fix Y-1): no
 96	// write path ever scans a list another tenant can grow, and
 97	// GetPending/Render iterate only targets that actually have work.
 98	pendingTargets []string
 99	ptIndex        map[string]int
100	nextID         int
101)
102
103func init() {
104	targets = make(map[string]*TargetConfig)
105	targetNames = []string{}
106	ownerTargets = make(map[address]int)
107	actions = make(map[string]*Action)
108	actionIDs = []string{}
109	pending = make(map[string][]string)
110	pendingTargets = []string{}
111	ptIndex = make(map[string]int)
112	nextID = 1
113}
114
115func now() time.Time {
116	return time.Now()
117}
118
119// ---------- helpers ----------
120
121// rejectStraySend aborts when coins are attached to a call (fix Y6):
122// this realm handles no funds and holds no banker, so an attached send
123// would strand on the realm address forever. Aborting reverts the
124// transfer back to the sender. Fails open for realm-routed calls, whose
125// attached send lands on the intermediary realm, never here.
126func rejectStraySend(cur realm) {
127	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
128		panic("this realm does not accept coins")
129	}
130}
131
132func mustGetTarget(name string) *TargetConfig {
133	t, ok := targets[name]
134	if !ok {
135		panic("target not found: " + name)
136	}
137	return t
138}
139
140func mustGet(id string) *Action {
141	a, ok := actions[id]
142	if !ok {
143		panic("action not found: " + id)
144	}
145	return a
146}
147
148func isValidName(name string) bool {
149	if name == "" || len(name) > MaxTargetNameLen {
150		return false
151	}
152	for _, c := range name {
153		if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
154			return false
155		}
156	}
157	return true
158}
159
160func expired(a *Action) bool {
161	return !a.Executed && now().After(a.ExpiresAt)
162}
163
164func removeID(list []string, id string) []string {
165	for i, x := range list {
166		if x == id {
167			return append(list[:i], list[i+1:]...)
168		}
169	}
170	return list
171}
172
173func addPendingTarget(target string) {
174	if _, ok := ptIndex[target]; ok {
175		return
176	}
177	ptIndex[target] = len(pendingTargets)
178	pendingTargets = append(pendingTargets, target)
179}
180
181// removePendingTarget drops a target from the with-pendings list in
182// O(1) by swapping the last entry into its slot. Order afterwards is
183// deterministic (a pure function of the operation history), which is
184// all iteration needs.
185func removePendingTarget(target string) {
186	i, ok := ptIndex[target]
187	if !ok {
188		return
189	}
190	last := len(pendingTargets) - 1
191	moved := pendingTargets[last]
192	pendingTargets[i] = moved
193	ptIndex[moved] = i
194	pendingTargets = pendingTargets[:last]
195	delete(ptIndex, target)
196}
197
198// dropPending removes id from its target's pending list. Bounded: the
199// list never exceeds MaxPendingPerTarget.
200func dropPending(target, id string) {
201	l := removeID(pending[target], id)
202	if len(l) == 0 {
203		delete(pending, target)
204		removePendingTarget(target)
205	} else {
206		pending[target] = l
207	}
208}
209
210// reap removes a non-executed action from state entirely; its history
211// is the emitted event. Executed records are permanent attestations:
212// reap refuses them outright (round-2 hardening), so no future caller
213// can erase one by mistake.
214func reap(id string) {
215	a := actions[id]
216	if a != nil && a.Executed {
217		return
218	}
219	if a != nil {
220		dropPending(a.Target, id)
221	}
222	delete(actions, id)
223	actionIDs = removeID(actionIDs, id)
224}
225
226// sweepExpired reaps the expired pending actions of ONE target (fix
227// Y4/Y2: the old global budget-windowed sweep let long-delay entries at
228// the front of a shared queue starve everything behind them). A
229// target's list is bounded by MaxPendingPerTarget, so the sweep is a
230// bounded scan. Expiry history is the emitted event.
231func sweepExpired(target string) {
232	var live []string
233	for _, id := range pending[target] {
234		a := actions[id]
235		if a == nil {
236			continue
237		}
238		if expired(a) {
239			chain.Emit("timelock_expired", "id", id, "target", a.Target)
240			delete(actions, id)
241			actionIDs = removeID(actionIDs, id)
242			continue
243		}
244		live = append(live, id)
245	}
246	if len(live) == 0 {
247		delete(pending, target)
248		removePendingTarget(target)
249	} else {
250		pending[target] = live
251	}
252}
253
254// pendingCount is the length of the target's live list. The counter it
255// replaces (fix Y4) could desync from the lists it mirrored; a length
256// cannot. It may briefly include not-yet-swept expired actions; those
257// are reaped by the next Schedule's sweep or by anyone's Expire, and in
258// the worst case an owner briefly under-uses their own quota — never
259// another tenant's.
260func pendingCount(target string) int {
261	return len(pending[target])
262}
263
264// sanitize makes attacker-controlled text safe to embed in markdown and
265// single-line summaries: backticks, pipes, newlines, link syntax, and
266// raw HTML brackets (fix Y3: gnoweb has no HTML sanitization layer, so
267// a literal <script> tag must never reach the page) are replaced so a
268// Data string cannot break out of its cell, inject rows or markup, or
269// render a live link.
270func sanitize(s string) string {
271	r := strings.NewReplacer(
272		"`", "'", "|", "/", "\n", " ", "\r", " ",
273		"[", "(", "]", ")", "*", "·", "_", "-",
274		"<", "(", ">", ")",
275	)
276	return r.Replace(s)
277}
278
279// truncate shortens s to at most n runes without splitting a multibyte
280// character. n is clamped to a minimum of 4 (re-audit: n<=2 sliced out
281// of bounds).
282func truncate(s string, n int) string {
283	if n < 4 {
284		n = 4
285	}
286	runes := []rune(s)
287	if len(runes) <= n {
288		return s
289	}
290	return string(runes[:n-3]) + "..."
291}
292
293// ---------- target registration ----------
294
295// RegisterTarget creates a named target. The caller becomes its owner —
296// the only address that may schedule actions against it. minDelay is
297// the enforced floor for every action's delay. guardian may be empty
298// (no guardian) or an address empowered to veto pending actions.
299func RegisterTarget(cur realm, name string, minDelay int64, guardian address) {
300	rejectStraySend(cur)
301	owner := cur.Previous().Address()
302	if !isValidName(name) {
303		panic("target name must be 1-" + strconv.Itoa(MaxTargetNameLen) +
304			" chars, lowercase alphanumeric with underscores only")
305	}
306	if _, exists := targets[name]; exists {
307		panic("target already registered: " + name)
308	}
309	if ownerTargets[owner] >= MaxTargetsPerOwner {
310		panic("per-owner target limit reached")
311	}
312	if minDelay < MinDelayFloor {
313		panic("minimum delay must be at least " + strconv.FormatInt(MinDelayFloor, 10) + " seconds")
314	}
315	if minDelay > MaxDelay {
316		panic("minimum delay exceeds the maximum of " + strconv.FormatInt(MaxDelay, 10) + " seconds")
317	}
318	if guardian != "" && !guardian.IsValid() {
319		panic("invalid guardian address: " + string(guardian))
320	}
321
322	targets[name] = &TargetConfig{
323		Name:     name,
324		Owner:    owner,
325		Guardian: guardian,
326		MinDelay: minDelay,
327	}
328	targetNames = append(targetNames, name)
329	ownerTargets[owner]++
330	chain.Emit("timelock_target_registered", "target", name,
331		"owner", string(owner), "guardian", string(guardian),
332		"min_delay", strconv.FormatInt(minDelay, 10))
333}
334
335// SetGuardian changes (or clears, with "") the target's guardian.
336// Owner only, and REFUSED while the target has pending actions: the
337// guardian's veto power exists precisely to check the owner during a
338// delay window, so the owner must not be able to strip it mid-window.
339//
340// KNOWN LIMIT (documented, round-3 audit): the owner can cancel all
341// pending actions, change the guardian, and reschedule — the price is
342// a full fresh MinDelay on every rescheduled action, and every step
343// emits an event (cancellations + the guardian change below), so
344// observers always get MinDelay of warning under the new guardian
345// regime. Guardians protect open windows, not the owner's future.
346func SetGuardian(cur realm, targetName string, guardian address) {
347	rejectStraySend(cur)
348	t := mustGetTarget(targetName)
349	if cur.Previous().Address() != t.Owner {
350		panic("only the target owner can set the guardian")
351	}
352	// expired-but-unswept actions protect nothing; sweep them so they
353	// cannot block a guardian rotation (round-2 hardening)
354	sweepExpired(targetName)
355	if pendingCount(targetName) > 0 {
356		panic("cannot change the guardian while actions are pending")
357	}
358	if guardian != "" && !guardian.IsValid() {
359		panic("invalid guardian address: " + string(guardian))
360	}
361	t.Guardian = guardian
362	chain.Emit("timelock_guardian_changed", "target", targetName,
363		"guardian", string(guardian))
364}
365
366// TransferTargetOwnership OFFERS a target to a new owner; the nominee
367// must AcceptTargetOwnership to complete it (fix Y5: a one-step
368// transfer let anyone fill a stranger's per-owner quota and dump
369// pending obligations — with an attacker-chosen guardian — on an
370// address that never asked). Owner only. Pass "" to clear a pending
371// offer. Nothing changes hands until the nominee accepts.
372func TransferTargetOwnership(cur realm, targetName string, newOwner address) {
373	rejectStraySend(cur)
374	t := mustGetTarget(targetName)
375	if cur.Previous().Address() != t.Owner {
376		panic("only the target owner can transfer ownership")
377	}
378	if newOwner == "" {
379		t.PendingOwner = ""
380		chain.Emit("timelock_ownership_offer_cleared", "target", targetName)
381		return
382	}
383	if !newOwner.IsValid() {
384		panic("invalid new owner address: " + string(newOwner))
385	}
386	if newOwner == t.Owner {
387		panic("new owner is already the owner")
388	}
389	t.PendingOwner = newOwner
390	chain.Emit("timelock_ownership_offered", "target", targetName,
391		"pending_owner", string(newOwner))
392}
393
394// AcceptTargetOwnership completes a pending ownership offer; only the
395// nominee can accept. The nominee's quota is checked HERE — consent
396// time — so an offer can never overfill an account that did not agree
397// to carry it.
398func AcceptTargetOwnership(cur realm, targetName string) {
399	rejectStraySend(cur)
400	t := mustGetTarget(targetName)
401	a := cur.Previous().Address()
402	if t.PendingOwner == "" || a != t.PendingOwner {
403		panic("caller is not the pending owner")
404	}
405	if ownerTargets[a] >= MaxTargetsPerOwner {
406		panic("accepting would exceed the per-owner target limit")
407	}
408	ownerTargets[t.Owner]--
409	if ownerTargets[t.Owner] <= 0 {
410		delete(ownerTargets, t.Owner)
411	}
412	ownerTargets[a]++
413	t.Owner = a
414	t.PendingOwner = ""
415	chain.Emit("timelock_ownership_transferred", "target", targetName,
416		"new_owner", string(a))
417}
418
419// ---------- write operations ----------
420
421// Schedule creates a new timelocked action against a registered target.
422// Only the target's owner may schedule. The delay must be at least the
423// target's registered minimum and at most MaxDelay. Returns the action ID.
424func Schedule(cur realm, targetName, data string, delay int64) string {
425	rejectStraySend(cur)
426	t := mustGetTarget(targetName)
427	caller := cur.Previous().Address()
428	if caller != t.Owner {
429		panic("only the target owner can schedule actions for " + targetName)
430	}
431	if data == "" {
432		panic("data must not be empty")
433	}
434	if len(data) > MaxDataLen {
435		panic("data exceeds " + strconv.Itoa(MaxDataLen) + " bytes")
436	}
437	if delay < t.MinDelay {
438		panic("delay below the target's minimum of " + strconv.FormatInt(t.MinDelay, 10) + " seconds")
439	}
440	if delay > MaxDelay {
441		panic("delay exceeds the maximum of " + strconv.FormatInt(MaxDelay, 10) + " seconds")
442	}
443
444	sweepExpired(targetName)
445	if pendingCount(targetName) >= MaxPendingPerTarget {
446		panic("too many pending actions for target " + targetName)
447	}
448
449	id := "action_" + strconv.Itoa(nextID)
450	nextID++
451
452	ts := now()
453	execAfter := ts.Add(time.Duration(delay) * time.Second)
454	actions[id] = &Action{
455		ID:           id,
456		Target:       targetName,
457		Creator:      caller,
458		Data:         data,
459		ScheduledAt:  ts,
460		ExecuteAfter: execAfter,
461		ExpiresAt:    execAfter.Add(time.Duration(GracePeriod) * time.Second),
462	}
463	actionIDs = append(actionIDs, id)
464	// self-trim (fix Y4): the render index stays bounded, so reaping an
465	// entry from it can never become an unbounded scan. Trimmed entries
466	// remain in the actions map; only front-page ordering forgets them.
467	if len(actionIDs) > RenderIndexCap {
468		actionIDs = append([]string{}, actionIDs[len(actionIDs)-MaxRenderActions:]...)
469	}
470	pending[targetName] = append(pending[targetName], id)
471	addPendingTarget(targetName)
472	// full-attribute event (round-3 audit): reaped records must be
473	// reconstructible from events alone
474	chain.Emit("timelock_scheduled", "id", id, "target", targetName,
475		"creator", string(caller), "data", data,
476		"delay", strconv.FormatInt(delay, 10),
477		"execute_after", execAfter.Format(time.RFC3339),
478		"expires_at", execAfter.Add(time.Duration(GracePeriod)*time.Second).Format(time.RFC3339))
479	return id
480}
481
482// Execute marks an action as executed. Anyone can call this — the
483// timelock is the protection, not the executor's identity. The action
484// must exist, be pending, its delay elapsed, and its grace window not
485// yet expired.
486func Execute(cur realm, actionID string) string {
487	rejectStraySend(cur)
488	a := mustGet(actionID)
489	if a.Executed {
490		panic("action already executed")
491	}
492	if now().Before(a.ExecuteAfter) {
493		remaining := a.ExecuteAfter.Sub(now())
494		panic("too early: " + strconv.FormatInt(int64(remaining/time.Second), 10) + "s remaining")
495	}
496	if now().After(a.ExpiresAt) {
497		panic("action expired: the grace window of " +
498			strconv.FormatInt(GracePeriod, 10) + "s after readiness has passed")
499	}
500
501	a.Executed = true
502	dropPending(a.Target, actionID)
503	chain.Emit("timelock_executed", "id", actionID, "target", a.Target)
504	return "executed: " + actionID
505}
506
507// Cancel removes a pending action. Only the target's CURRENT owner can
508// cancel (re-audit: the scheduling creator's rights must not survive an
509// ownership transfer). The record is reaped; history is the event.
510func Cancel(cur realm, actionID string) string {
511	rejectStraySend(cur)
512	a := mustGet(actionID)
513	t := mustGetTarget(a.Target)
514	if cur.Previous().Address() != t.Owner {
515		panic("only the target owner can cancel")
516	}
517	if a.Executed {
518		panic("cannot cancel an already-executed action")
519	}
520	if expired(a) {
521		panic("action expired: nothing to cancel (use Expire)")
522	}
523
524	chain.Emit("timelock_cancelled", "id", actionID, "target", a.Target)
525	reap(actionID)
526	return "cancelled: " + actionID
527}
528
529// Veto cancels a pending action as the target's guardian. This is the
530// guardian's whole power: it can stop a scheduled action during the
531// delay window, never create or execute one. The record is reaped;
532// history is the event.
533func Veto(cur realm, actionID string) string {
534	rejectStraySend(cur)
535	a := mustGet(actionID)
536	t := mustGetTarget(a.Target)
537	if t.Guardian == "" {
538		panic("target has no guardian: " + a.Target)
539	}
540	if cur.Previous().Address() != t.Guardian {
541		panic("only the target guardian can veto")
542	}
543	if a.Executed {
544		panic("cannot veto an already-executed action")
545	}
546	if expired(a) {
547		panic("action expired: nothing to veto (use Expire)")
548	}
549
550	chain.Emit("timelock_vetoed", "id", actionID, "target", a.Target)
551	reap(actionID)
552	return "vetoed: " + actionID
553}
554
555// Expire reaps a provably expired action. Permissionless (fix Y2): an
556// expired action decides nothing — reaping it only writes down what the
557// clock already decided — so anyone may free the quota slot it holds.
558// This is the recovery valve that makes a wedged target impossible:
559// before it, a phantom expired entry consumed quota and blocked
560// SetGuardian until a global sweep happened to reach it; now its own
561// target's owner — or anyone else — reaps it directly.
562func Expire(cur realm, actionID string) string {
563	rejectStraySend(cur)
564	a := mustGet(actionID)
565	if a.Executed {
566		panic("action already executed")
567	}
568	if !expired(a) {
569		panic("action is not expired")
570	}
571	chain.Emit("timelock_expired", "id", actionID, "target", a.Target)
572	reap(actionID)
573	return "expired: " + actionID
574}
575
576// ---------- read-only queries ----------
577
578// GetTarget returns a formatted summary of a registered target.
579func GetTarget(targetName string) string {
580	t := mustGetTarget(targetName)
581	var b strings.Builder
582	b.WriteString("Target: " + t.Name + "\n")
583	b.WriteString("Owner: " + string(t.Owner) + "\n")
584	guardian := "none"
585	if t.Guardian != "" {
586		guardian = string(t.Guardian)
587	}
588	b.WriteString("Guardian: " + guardian + "\n")
589	b.WriteString("Min delay: " + strconv.FormatInt(t.MinDelay, 10) + "s\n")
590	if t.PendingOwner != "" {
591		b.WriteString("Pending owner: " + string(t.PendingOwner) + "\n")
592	}
593	return b.String()
594}
595
596// GetAction returns a formatted summary of a single pending or executed
597// action. Cancelled/vetoed/expired actions are reaped — their history
598// is in emitted events.
599func GetAction(actionID string) string {
600	a := mustGet(actionID)
601	return formatAction(a)
602}
603
604// GetPending returns the IDs of all pending, non-expired actions,
605// grouped by target, insertion-ordered within a target. Only targets
606// that actually hold live pendings are visited (round-2 fix Y-1), so
607// the scan cannot be inflated by registrations alone.
608func GetPending() string {
609	var results []string
610	for _, name := range pendingTargets {
611		for _, id := range pending[name] {
612			a := actions[id]
613			if a == nil || a.Executed || expired(a) {
614				continue
615			}
616			results = append(results, a.ID)
617		}
618	}
619	if len(results) == 0 {
620		return "none"
621	}
622	return strings.Join(results, ", ")
623}
624
625// IsReady returns true if the action exists, is pending, its delay has
626// elapsed, and it has not expired.
627func IsReady(actionID string) bool {
628	a, ok := actions[actionID]
629	if !ok || a.Executed || expired(a) {
630		return false
631	}
632	return !now().Before(a.ExecuteAfter)
633}
634
635// IsExecuted returns true if the action exists and was executed. This is
636// the consumer-side check: combined with target registration it attests
637// that the target's registered owner scheduled the action, it waited at
638// least the registered minimum delay, no guardian vetoed it, and it was
639// executed within its grace window. Executed records are permanent.
640func IsExecuted(actionID string) bool {
641	a, ok := actions[actionID]
642	return ok && a.Executed
643}
644
645// ---------- render ----------
646
647// Render returns a markdown overview. Never panics. Cancelled, vetoed,
648// and expired actions are reaped from state; their history is in
649// events. The page shows the most recent actions only (the ordering
650// index is bounded — fix Y4); older executed records stay queryable
651// via GetAction/IsExecuted forever.
652func Render(path string) string {
653	if actions == nil || len(actions) == 0 {
654		return "# Timelock Guardian\n\nNo actions scheduled.\n"
655	}
656
657	// The Pending section reads the live per-target lists, so flooding
658	// the realm with executed churn can never push a still-live pending
659	// action off the page during its delay window (round-2 fix Y-2).
660	var pendingList []*Action
661	truncated := false
662	for _, name := range pendingTargets {
663		for _, id := range pending[name] {
664			a := actions[id]
665			if a == nil || a.Executed || expired(a) {
666				continue
667			}
668			if len(pendingList) >= MaxRenderActions {
669				truncated = true
670				break
671			}
672			pendingList = append(pendingList, a)
673		}
674		if truncated {
675			break
676		}
677	}
678
679	// The Executed section reads the bounded render index: recent
680	// attestations only; older ones stay queryable via GetAction.
681	var executedList []*Action
682	show := actionIDs
683	if len(show) > MaxRenderActions {
684		show = show[len(show)-MaxRenderActions:]
685		truncated = true
686	}
687	for _, id := range show {
688		a := actions[id]
689		if a == nil || !a.Executed {
690			continue
691		}
692		executedList = append(executedList, a)
693	}
694
695	var b strings.Builder
696	b.WriteString("# Timelock Guardian\n\n")
697	if truncated {
698		b.WriteString("_Showing at most " + strconv.Itoa(MaxRenderActions) + " actions per section._\n\n")
699	}
700
701	section := func(title string, list []*Action) {
702		if len(list) == 0 {
703			return
704		}
705		b.WriteString("## " + title + " (" + strconv.Itoa(len(list)) + ")\n\n")
706		for _, a := range list {
707			b.WriteString(renderActionRow(a))
708		}
709		b.WriteString("\n")
710	}
711	section("Pending", pendingList)
712	section("Executed", executedList)
713
714	return b.String()
715}
716
717// ---------- formatting helpers ----------
718
719func formatAction(a *Action) string {
720	var b strings.Builder
721	b.WriteString("ID: " + a.ID + "\n")
722	b.WriteString("Target: " + sanitize(a.Target) + "\n")
723	b.WriteString("Creator: " + string(a.Creator) + "\n")
724	b.WriteString("Data: " + sanitize(a.Data) + "\n")
725	b.WriteString("Scheduled: " + a.ScheduledAt.Format(time.RFC3339) + "\n")
726	b.WriteString("Execute after: " + a.ExecuteAfter.Format(time.RFC3339) + "\n")
727	b.WriteString("Expires: " + a.ExpiresAt.Format(time.RFC3339) + "\n")
728
729	switch {
730	case a.Executed:
731		b.WriteString("Status: executed\n")
732	case expired(a):
733		b.WriteString("Status: expired\n")
734	case now().Before(a.ExecuteAfter):
735		remaining := a.ExecuteAfter.Sub(now())
736		b.WriteString("Status: locked (" + strconv.FormatInt(int64(remaining/time.Second), 10) + "s remaining)\n")
737	default:
738		b.WriteString("Status: ready\n")
739	}
740	return b.String()
741}
742
743func renderActionRow(a *Action) string {
744	data := truncate(sanitize(a.Data), 40)
745
746	status := "pending"
747	switch {
748	case a.Executed:
749		status = "executed"
750	case expired(a):
751		status = "expired"
752	case !now().Before(a.ExecuteAfter):
753		status = "**ready**"
754	}
755
756	return "- **" + a.ID + "** | target: `" + sanitize(a.Target) + "` | " + data + " | " + status + "\n"
757}