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}