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.

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}