timelock.gno
4.80 Kb · 185 lines
1// Package timelock is a simplified port of OpenZeppelin's TimelockController.
2//
3// Operations are scheduled with a delay (measured in blocks). A scheduled
4// operation becomes executable only once the chain height reaches the
5// operation's ready height. Until then it can be cancelled by anyone (this
6// simplified port omits role-based access control). Once executed or
7// cancelled, an operation is terminal.
8//
9// Solidity mapping:
10// - block.number -> runtime.ChainHeight()
11// - msg.sender -> unsafe.PreviousRealm().Address()
12// - require(...) -> panic(...)
13// - events -> chain.Emit(...)
14// - mapping(id => op) -> avl.Tree keyed by zero-padded id
15package timelock
16
17import (
18 "strconv"
19
20 "chain"
21 "chain/runtime"
22 "chain/runtime/unsafe"
23
24 "gno.land/p/nt/avl/v0"
25 "gno.land/p/nt/ufmt/v0"
26)
27
28// State labels for an operation.
29const (
30 StatePending = "Pending" // scheduled, not yet ready
31 StateReady = "Ready" // ready height reached, awaiting execution
32 StateExecuted = "Executed" // terminal
33 StateCancelled = "Cancelled" // terminal
34)
35
36// Operation is a single scheduled action.
37type Operation struct {
38 ID int
39 Action string
40 Proposer address
41 Scheduled int64 // chain height at scheduling
42 ReadyHeight int64 // executable once ChainHeight() >= ReadyHeight
43 Done bool // executed
44 Cancelled bool
45}
46
47var (
48 ops = avl.NewTree() // key: paddedKey(id) -> *Operation
49 nextID = 1
50)
51
52// Schedule registers a new operation to run after delayBlocks blocks and
53// returns its id. delayBlocks must be >= 0.
54func Schedule(cur realm, action string, delayBlocks int) int {
55 if action == "" {
56 panic("timelock: empty action")
57 }
58 if delayBlocks < 0 {
59 panic("timelock: negative delay")
60 }
61
62 h := runtime.ChainHeight()
63 id := nextID
64 nextID++
65
66 op := &Operation{
67 ID: id,
68 Action: action,
69 Proposer: unsafe.PreviousRealm().Address(),
70 Scheduled: h,
71 ReadyHeight: h + int64(delayBlocks),
72 }
73 ops.Set(paddedKey(id), op)
74
75 chain.Emit("Scheduled",
76 "id", strconv.Itoa(id),
77 "action", action,
78 "readyHeight", strconv.FormatInt(op.ReadyHeight, 10),
79 )
80 return id
81}
82
83// Execute runs a ready operation. It panics if the operation is unknown,
84// already terminal, or not yet ready.
85func Execute(cur realm, id int) {
86 op := mustGet(id)
87 if op.Done {
88 panic("timelock: already executed")
89 }
90 if op.Cancelled {
91 panic("timelock: operation cancelled")
92 }
93 if runtime.ChainHeight() < op.ReadyHeight {
94 panic("timelock: operation not ready")
95 }
96 op.Done = true
97
98 chain.Emit("Executed", "id", strconv.Itoa(id), "action", op.Action)
99}
100
101// Cancel drops a pending or ready operation before it executes. It panics if
102// the operation is unknown or already terminal.
103func Cancel(cur realm, id int) {
104 op := mustGet(id)
105 if op.Done {
106 panic("timelock: already executed")
107 }
108 if op.Cancelled {
109 panic("timelock: already cancelled")
110 }
111 op.Cancelled = true
112
113 chain.Emit("Cancelled", "id", strconv.Itoa(id), "action", op.Action)
114}
115
116// mustGet returns the operation or panics if absent.
117func mustGet(id int) *Operation {
118 v := ops.Get(paddedKey(id))
119 if v == nil {
120 panic("timelock: unknown operation")
121 }
122 return v.(*Operation)
123}
124
125// --- pure/read-only helpers (safe to unit-test) ---
126
127// paddedKey returns a fixed-width, lexicographically sortable key for an id so
128// avl iteration yields operations in id order.
129func paddedKey(id int) string {
130 s := strconv.Itoa(id)
131 for len(s) < 12 {
132 s = "0" + s
133 }
134 return s
135}
136
137// StateOf returns the display state of op at the given chain height.
138func StateOf(op *Operation, height int64) string {
139 switch {
140 case op.Cancelled:
141 return StateCancelled
142 case op.Done:
143 return StateExecuted
144 case height >= op.ReadyHeight:
145 return StateReady
146 default:
147 return StatePending
148 }
149}
150
151// BlocksRemaining returns how many blocks remain before op is ready (0 if
152// already ready or past).
153func BlocksRemaining(op *Operation, height int64) int64 {
154 if height >= op.ReadyHeight {
155 return 0
156 }
157 return op.ReadyHeight - height
158}
159
160// Render implements the standard realm markdown view.
161func Render(path string) string {
162 h := runtime.ChainHeight()
163 out := "# Timelock Controller\n\n"
164 out += ufmt.Sprintf("Current chain height: **%d**\n\n", h)
165
166 if ops.Size() == 0 {
167 out += "_No operations scheduled yet._\n\n"
168 out += "Call `Schedule(action, delayBlocks)` to queue one.\n"
169 return out
170 }
171
172 out += ufmt.Sprintf("Total operations: **%d**\n\n", ops.Size())
173 out += "| ID | Action | State | Ready @ | Blocks left | Proposer |\n"
174 out += "|---:|--------|-------|--------:|------------:|----------|\n"
175
176 ops.Iterate("", "", func(_ string, v any) bool {
177 op := v.(*Operation)
178 state := StateOf(op, h)
179 left := BlocksRemaining(op, h)
180 out += ufmt.Sprintf("| %d | %s | %s | %d | %d | %s |\n",
181 op.ID, op.Action, state, op.ReadyHeight, left, op.Proposer.String())
182 return false
183 })
184 return out
185}