admin.gno
8.60 Kb · 307 lines
1package core
2
3import (
4 "chain"
5 "chain/runtime"
6 "strconv"
7 "strings"
8
9 "gno.land/p/nt/avl/v0"
10
11 "gno.land/p/g1pq6pxhgxk2uw5shg5le6glfydpxcu74k0zh069/sinopia/safe/v1"
12 "gno.land/p/g1pq6pxhgxk2uw5shg5le6glfydpxcu74k0zh069/sinopia/types/v1"
13)
14
15// Admin (design §2.5.2, §7.5). The admin can only change the parameters
16// listed in validateOp, each inside a hard cap, after a public timelock;
17// it can pause new activity for a bounded time. It can never move, approve
18// or burn a token, touch a collection's configuration, or receive funds
19// (I-1). With no collection yet there is nothing to protect, so ops execute
20// without delay — that is how the platform is configured after deploy.
21//
22// TODO(mainnet): the initial admin and treasury must be the 2/3 multisig.
23// SA-I6: CreateFee starts at 0 here so tests and bootstrap stay simple;
24// scripts/deploy-onyx.sh sets a non-zero fee.create during bootstrap.
25var (
26 admin = address("g1pq6pxhgxk2uw5shg5le6glfydpxcu74k0zh069")
27 pendingAdmin address
28 fees = types.Fees{PrimaryBps: 250, SecondaryBps: 250, Treasury: address("g1pq6pxhgxk2uw5shg5le6glfydpxcu74k0zh069")}
29 beacon address
30 minters avl.Tree // minter pkgpath -> *minterRecord
31 ops avl.Tree // safe.PadInt(op id) -> *pendingOp
32 opCount int64
33 pauses avl.Tree // scope -> *pauseRecord
34)
35
36// pauseRecord is one scope's pause and its budget (SA-L2): within a window
37// of PauseWindowBlocks a scope can be paused for at most MaxPauseBlocks in
38// total; a pause cannot be renewed while it runs. Worst case two budgets
39// meet at a window boundary (<= 2 x MaxPauseBlocks in a row), never more.
40type pauseRecord struct {
41 windowStart int64
42 used int64 // blocks of budget charged in this window
43 until int64 // exclusive
44}
45
46type minterRecord struct {
47 deprecated bool
48}
49
50type pendingOp struct {
51 id int64
52 op, arg string
53 proposed int64
54}
55
56// Ops and their argument formats.
57const (
58 OpFeeCreate = "fee.create" // ugnot, <= CreateFeeCap
59 OpFeePrimary = "fee.primary" // bps, <= ProtocolFeeCapBps
60 OpFeeSecondary = "fee.secondary" // bps, <= ProtocolFeeCapBps
61 OpTreasury = "treasury" // address
62 OpBeacon = "beacon" // address, or "" to disable new beacon launches
63 OpMinterAdd = "minter.add" // realm pkgpath
64 OpMinterDeprecate = "minter.deprecate" // realm pkgpath: no new collections
65 OpEngineReview = "engine.review" // engine id: reviewed badge (UI only)
66 OpAdminTransfer = "admin.transfer" // address; the new admin must AcceptAdmin
67)
68
69var pauseScopes = []string{"create", "mint", "trade"}
70
71func ProposeOp(cur realm, op, arg string) string {
72 assertNoDeposit(cur)
73 assertAdmin(cur)
74 validateOp(op, arg)
75 opCount++
76 p := &pendingOp{id: opCount, op: op, arg: arg, proposed: height()}
77 ops.Set(safe.PadInt(p.id, cidWidth), p)
78 id := itoa(p.id)
79 chain.Emit("OpProposed", "id", id, "op", op, "arg", arg, "earliest", itoa(earliestExecution(p)))
80 return id
81}
82
83// ExecuteOp applies a proposed op once its timelock has passed. The delay is
84// judged at execution, so an op proposed during bootstrap still waits the
85// full timelock if a collection appeared in the meantime.
86func ExecuteOp(cur realm, opID string) {
87 assertNoDeposit(cur)
88 assertAdmin(cur)
89 p := mustOp(opID)
90 if height() < earliestExecution(p) {
91 panic("timelocked until height " + itoa(earliestExecution(p)))
92 }
93 validateOp(p.op, p.arg)
94 applyOp(p.op, p.arg)
95 ops.Remove(safe.PadInt(p.id, cidWidth))
96 chain.Emit("OpExecuted", "id", opID, "op", p.op, "arg", p.arg)
97}
98
99func CancelOp(cur realm, opID string) {
100 assertNoDeposit(cur)
101 assertAdmin(cur)
102 p := mustOp(opID)
103 ops.Remove(safe.PadInt(p.id, cidWidth))
104 chain.Emit("OpCancelled", "id", opID)
105}
106
107func AcceptAdmin(cur realm) {
108 assertNoDeposit(cur)
109 who := caller(cur)
110 if pendingAdmin == "" || who != pendingAdmin {
111 panic("no pending admin transfer to this address")
112 }
113 chain.Emit("AdminTransferred", "from", admin.String(), "to", who.String())
114 admin = who
115 pendingAdmin = ""
116}
117
118// Pause stops new activity in one scope for the rest of its budget (at
119// most MaxPauseBlocks per PauseWindowBlocks). It never blocks exits (I-2)
120// and cannot be renewed while it runs (SA-L2).
121func Pause(cur realm, scope string) {
122 assertNoDeposit(cur)
123 assertAdmin(cur)
124 assertScope(scope)
125 h := height()
126 r := pauseOf(scope)
127 if h < r.until {
128 panic("already paused until height " + itoa(r.until) + "; a pause cannot be renewed")
129 }
130 if h >= r.windowStart+PauseWindowBlocks {
131 r.windowStart, r.used = h, 0
132 }
133 left := MaxPauseBlocks - r.used
134 if left <= 0 {
135 panic("pause budget of " + scope + " is spent until height " + itoa(r.windowStart+PauseWindowBlocks))
136 }
137 r.used += left
138 r.until = h + left
139 pauses.Set(scope, r)
140 chain.Emit("Paused", "scope", scope, "until", itoa(r.until))
141}
142
143// Unpause ends a pause early and gives the unused blocks back to the budget.
144func Unpause(cur realm, scope string) {
145 assertNoDeposit(cur)
146 assertAdmin(cur)
147 assertScope(scope)
148 h := height()
149 r := pauseOf(scope)
150 if h < r.until {
151 r.used -= r.until - h
152 }
153 r.until = 0
154 chain.Emit("Unpaused", "scope", scope)
155}
156
157func pauseOf(scope string) *pauseRecord {
158 if v := pauses.Get(scope); v != nil {
159 return v.(*pauseRecord)
160 }
161 return &pauseRecord{windowStart: -PauseWindowBlocks}
162}
163
164// Paused reports whether scope ("create", "mint", "trade") is paused now.
165func Paused(scope string) bool {
166 v := pauses.Get(scope)
167 return v != nil && height() < v.(*pauseRecord).until
168}
169
170// PauseBudget is the scope's pause record as JSON: {until, used,
171// windowStart, windowEnd, left}.
172func PauseBudget(scope string) string {
173 assertScope(scope)
174 r := pauseOf(scope)
175 used := r.used
176 if height() >= r.windowStart+PauseWindowBlocks && height() >= r.until {
177 used = 0
178 }
179 w := &safe.JSONWriter{}
180 w.BeginObject()
181 w.IntField("until", r.until)
182 w.IntField("used", used)
183 w.IntField("windowStart", r.windowStart)
184 w.IntField("windowEnd", r.windowStart+PauseWindowBlocks)
185 w.IntField("left", MaxPauseBlocks-used)
186 w.EndObject()
187 return w.String()
188}
189
190func assertNotPaused(scope string) {
191 if Paused(scope) {
192 panic("paused: " + scope)
193 }
194}
195
196func assertScope(scope string) {
197 for _, s := range pauseScopes {
198 if s == scope {
199 return
200 }
201 }
202 panic("pause scope must be one of " + strings.Join(pauseScopes, ", "))
203}
204
205func assertAdmin(cur realm) {
206 if caller(cur) != admin {
207 panic("admin only")
208 }
209}
210
211func bootstrapping() bool { return collCount == 0 }
212
213func earliestExecution(p *pendingOp) int64 {
214 if bootstrapping() {
215 return p.proposed
216 }
217 return p.proposed + TimelockBlocks
218}
219
220func mustOp(opID string) *pendingOp {
221 v := ops.Get(safe.PadInt(safe.ParseID("opID", opID), cidWidth))
222 if v == nil {
223 panic("unknown op " + opID)
224 }
225 return v.(*pendingOp)
226}
227
228// validateOp is the closed list of admin powers (I-1).
229func validateOp(op, arg string) {
230 switch op {
231 case OpFeeCreate:
232 n := parseAmount(arg)
233 if n > CreateFeeCap {
234 panic("create fee above cap " + itoa(CreateFeeCap))
235 }
236 case OpFeePrimary, OpFeeSecondary:
237 safe.AssertBps(op, parseAmount(arg), ProtocolFeeCapBps)
238 case OpTreasury, OpAdminTransfer:
239 if !address(arg).IsValid() {
240 panic(op + ": invalid address")
241 }
242 if op == OpTreasury {
243 assertSpendable(op, address(arg), "", "") // SA-L5
244 }
245 case OpBeacon:
246 if arg != "" && !address(arg).IsValid() {
247 panic(op + ": invalid address")
248 }
249 case OpMinterAdd:
250 safe.AssertText("minter", arg, 256)
251 if !strings.HasPrefix(arg, runtime.ChainDomain()+"/r/") || strings.Contains(arg, "#") {
252 panic("minter must be a realm path")
253 }
254 if minters.Has(arg) {
255 panic("minter already added")
256 }
257 case OpMinterDeprecate:
258 if !minters.Has(arg) {
259 panic("unknown minter")
260 }
261 case OpEngineReview:
262 mustEngine(arg)
263 default:
264 panic("unknown op " + strconv.Quote(op))
265 }
266}
267
268func applyOp(op, arg string) {
269 switch op {
270 case OpFeeCreate:
271 fees.CreateFee = parseAmount(arg)
272 case OpFeePrimary:
273 fees.PrimaryBps = parseAmount(arg)
274 case OpFeeSecondary:
275 fees.SecondaryBps = parseAmount(arg)
276 case OpTreasury:
277 fees.Treasury = address(arg)
278 case OpBeacon:
279 beacon = address(arg)
280 case OpMinterAdd:
281 minters.Set(arg, &minterRecord{})
282 case OpMinterDeprecate:
283 minters.Get(arg).(*minterRecord).deprecated = true
284 case OpEngineReview:
285 mustEngine(arg).reviewed = true
286 case OpAdminTransfer:
287 pendingAdmin = address(arg)
288 }
289}
290
291func assertActiveMinter(path string) {
292 v := minters.Get(path)
293 if v == nil {
294 panic("caller " + strconv.Quote(path) + " is not an approved minter")
295 }
296 if v.(*minterRecord).deprecated {
297 panic("minter " + path + " is deprecated")
298 }
299}
300
301func parseAmount(s string) int64 {
302 n, err := strconv.ParseInt(s, 10, 64)
303 if err != nil || n < 0 {
304 panic("expected a non-negative integer, got " + strconv.Quote(s))
305 }
306 return n
307}