timelock.gno
11.87 Kb · 316 lines
1package nslogic
2
3import (
4 "math"
5 "strconv"
6 "strings"
7 "time"
8
9 "gno.land/p/nt/avl/v0"
10 "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
11)
12
13/*
14THE ADMIN KEY IS NOT THE REGISTRY ANY MORE.
15
16Until this file, every admin call here was instant, and an audit used them
17to take a paid name from its holder, make every name in grace reclaimable,
18delete live names and redirect all revenue — in one transaction, with a
1948-hour timelock in force and nothing ever proposed (audit H-1). The
20vault's timelock guarded only the trust set and the roles.
21
22So the calls are now in three classes:
23
24 TIMELOCKED — anything that destroys or moves names or money, or
25 rewrites pricing rules a single edit could zero: DeleteDomain,
26 SetTermLength, SetGracePeriod, SetTreasury, ReassignDomain,
27 UnfreezeName and UnfreezeNames (up to 64 names behind one action),
28 SetGranter and SetKeeper (naming one; removing stays
29 instant), SetDomainLengthRules, SetDomainNameOverrides,
30 SetGlobalLengthRules, SetPremiumNames, OpenRestore, and SetMarketFee
31 whenever the change could raise a fee. The admin proposes the exact call in the vault —
32 nsdata.ProposeAction("<Function> <arg> <arg>…") — waits out the
33 vault's delay, during which recovery can cancel it, and then makes
34 the call, which spends the proposal. A refusal names the exact id to
35 propose.
36
37 BOUNDED — the price levers that must move day to day: SetUsdRate,
38 SetDefaultNamePrice, SetDomainBasePrice, SetClaimMaxPrice. Instant
39 inside a bound that depends on WHICH WAY the change moves a name's
40 cost (Ely, 2026-10-16); anything bigger, or sooner, goes through the
41 timelock the same way.
42
43 CHEAPER — the direction a thief needs: the rate down, a price cut,
44 the credit cap raised. At most cheapMaxPct of the current value at
45 once, at most once per cheapCooldown (an hour). A stolen key can
46 move each lever a quarter an hour, in public — the rate and a base
47 price compound, so a name's price can fall by about 44% in the first
48 hour (0.75 x 0.75), and to 0.5625^h of list after h hours — rather
49 than take every name for dust in one block. Two levers per name, not
50 three: an unpriced domain's first own price shares the default's
51 clocks (authorizeTweakOn).
52
53 DEARER — the rate up after GNOT crashes, a price raised, the credit
54 cap cut. Up to dearMaxFactor at once (four times: GNOT falling 75%
55 is followed in ONE update), at most once per dearCooldown (ten
56 minutes). Its theft costs an outage — names too dear to buy — never
57 a name or a coin. Which is only true while no lever is tied to
58 another: the credit cap used to fall back to the default price, so a
59 dearer default walked the cap the cheap way, four times every ten
60 minutes (re-audit 2026-10-16, H-1). It stands alone now, and unset it
61 covers nothing (credits.gno).
62
63 Each direction keeps its own clock, so a dearer step in between does
64 not reopen the cheaper hour. The guardian bot revoking the logic realm
65 on the first AdminAction it did not expect is what keeps that hour
66 short. The KEEPER (keeper.gno) moves the rate inside the same bounds,
67 on the same clocks as the admin, and never through the timelock.
68
69 INSTANT — everything that cannot take anything from anybody: freezing
70 (the protective direction), opening and closing domains, the block
71 list, free-tier and credit grants, limits, TTLs, art, and ClearKeeper.
72
73THE CLOCK IS THE VAULT'S, deliberately: a new logic realm cannot shorten a
74delay it does not hold, and the delay itself only changes through the
75vault's own timelock.
76*/
77
78// actionID is the canonical name of one call: the function and its
79// arguments, separated by single spaces, an empty argument written "".
80// It is what the admin proposes and what the call spends, so a proposal
81// authorizes exactly one change with exactly those arguments.
82func actionID(fn string, args ...string) string {
83 id := fn
84 for _, a := range args {
85 if a == "" {
86 a = `""`
87 }
88 if strings.ContainsAny(a, " \t\n\r") {
89 panic("nslogic: an argument to a timelocked call may not contain spaces: " + a)
90 }
91 id += " " + a
92 }
93 return id
94}
95
96// assertActionReady explains, before anything is written, why a
97// timelocked call cannot run yet. The vault's ConsumeAction is the
98// authority; this only makes the refusal say what to do.
99func assertActionReady(id string) {
100 if nsdata.EvictionPending() {
101 panic("nslogic: recovery is replacing the admin; timelocked changes wait until that lands, and the admin's proposals die with it")
102 }
103 at, expires, _ := nsdata.GetAction(id)
104 now := time.Now().Unix()
105 switch {
106 case at == 0:
107 panic("nslogic: this change waits out the vault timelock. Propose exactly `" + id +
108 "` with nsdata.ProposeAction from the admin key, then call again once it matures; recovery may cancel it until then")
109 case now < at:
110 panic("nslogic: `" + id + "` is proposed and matures at unix " + strconv.FormatInt(at, 10))
111 case now >= expires:
112 panic("nslogic: `" + id + "` expired at unix " + strconv.FormatInt(expires, 10) + "; propose it again")
113 }
114 if !nsdata.ActionReady(id) {
115 panic("nslogic: `" + id + "` was proposed by a previous admin; propose it again")
116 }
117}
118
119// requireAction lets a timelocked call through only on a matured proposal
120// for exactly this call, and spends it.
121func requireAction(cur realm, id string) {
122 assertActionReady(id)
123 nsdata.ConsumeAction(cross(cur), id)
124}
125
126// -- bounded instant changes --
127
128const (
129 // A move that makes names CHEAPER (the rate down, a price cut, the
130 // credit cap raised) is at most this much of the current value, and a
131 // setting moves that way instantly at most once per cheapCooldown.
132 cheapMaxPct = 25
133 cheapCooldown = 60 * 60
134
135 // A move that makes names DEARER may go up to dearMaxFactor times (or
136 // down to 1/dearMaxFactor, for the credit cap) at once — four: GNOT
137 // falling 75% is followed in one update — at most once per dearCooldown.
138 dearMaxFactor = 4
139 dearCooldown = 10 * 60
140
141 // The highest rate SetUsdRate accepts: a million GNOT to the dollar.
142 // Like maxPriceUSD, a ceiling no real market reaches, low enough that
143 // price times rate cannot overflow unnoticed (see NamePrice).
144 maxRate = int64(1_000_000_000_000)
145)
146
147/*
148lastTweak is the ONE piece of state this realm keeps: when each bounded
149setting last moved, one clock per direction. Not a record — losing it
150costs nothing but one early tweak — so it lives here rather than in the
151vault, and a logic swap simply starts it empty.
152*/
153var lastTweak avl.Tree
154
155func clockKey(setting string, dearer bool) string {
156 if dearer {
157 return setting + ":dear"
158 }
159 return setting + ":cheap"
160}
161
162/*
163authorizeTweak lets a bounded setting move from `old` to `next` instantly
164when the move stays inside its direction's bound and that direction's clock
165has rested; otherwise it needs a matured proposal of `id`, which it spends.
166`dearer` says whether the move makes names dearer — for the rate and the
167prices that is a rise, for the credit cap a fall — and the caller, which
168knows what its setting means, decides it. A move to or from zero is never
169an instant step: a free domain, or a price restored from free, is a
170decision.
171*/
172func authorizeTweak(cur realm, setting string, old, next int64, dearer bool, id string) {
173 authorizeTweakOn(cur, []string{setting}, old, next, dearer, id)
174}
175
176/*
177authorizeTweakOn is authorizeTweak for a move that rides more than one
178clock: every clock named must have rested, and all of them are stamped.
179
180An unpriced domain's base price IS the default, so its first price of its
181own is measured against the default — after the default has already been
182cut, on a clock of its own. Rate, default and domain then each took 25% in
183one block: 0.75^3 of the price, 58% off, not the two levers (about 44%)
184this file promises (re-audit 2026-10-16, L-1). So that first own price
185rides the default's clocks too (domain.gno): in any hour, either the
186default or an unpriced domain's first own price moves, never both.
187*/
188func authorizeTweakOn(cur realm, settings []string, old, next int64, dearer bool, id string) {
189 if next == old {
190 return
191 }
192 now := time.Now().Unix()
193 if nsdata.ActionReady(id) {
194 nsdata.ConsumeAction(cross(cur), id)
195 for _, st := range settings {
196 lastTweak.Set(clockKey(st, dearer), now)
197 }
198 return
199 }
200 setting := settings[0]
201 if !withinStep(old, next, dearer) {
202 panic("nslogic: " + stepRefusal(setting, old, next, dearer) +
203 "; anything larger waits out the vault timelock: propose `" + id +
204 "` with nsdata.ProposeAction and call again once it matures")
205 }
206 for _, st := range settings {
207 if !clockRested(st, dearer, now) {
208 panic("nslogic: " + restRefusal(st, dearer) +
209 "; wait, or put this through the vault timelock: propose `" + id + "` with nsdata.ProposeAction")
210 }
211 }
212 for _, st := range settings {
213 lastTweak.Set(clockKey(st, dearer), now)
214 }
215}
216
217// withinStep is the bound on its own. New values are capped (maxPriceUSD,
218// maxRate); an old value stored before the caps, or by a restore, is not,
219// so anything too large to multiply safely is simply not an instant step.
220func withinStep(old, next int64, dearer bool) bool {
221 if old <= 0 || next <= 0 || old > math.MaxInt64/100 || next > math.MaxInt64/100 {
222 return false
223 }
224 if dearer {
225 hi, lo := next, old
226 if lo > hi {
227 hi, lo = lo, hi
228 }
229 return hi <= lo*dearMaxFactor
230 }
231 d := next - old
232 if d < 0 {
233 d = -d
234 }
235 return d*100 <= old*cheapMaxPct
236}
237
238// inheritClocks gives `to` every last-move time of `from` that is later
239// than its own, in both directions: a setting that starts out as another
240// one's value starts on that one's clocks.
241func inheritClocks(to, from string) {
242 for _, dearer := range []bool{true, false} {
243 f := lastTweak.Get(clockKey(from, dearer))
244 if f == nil {
245 continue
246 }
247 if t := lastTweak.Get(clockKey(to, dearer)); t == nil || t.(int64) < f.(int64) {
248 lastTweak.Set(clockKey(to, dearer), f.(int64))
249 }
250 }
251}
252
253func clockRested(setting string, dearer bool, now int64) bool {
254 rest := int64(cheapCooldown)
255 if dearer {
256 rest = dearCooldown
257 }
258 v := lastTweak.Get(clockKey(setting, dearer))
259 return v == nil || now-v.(int64) >= rest
260}
261
262func stepRefusal(setting string, old, next int64, dearer bool) string {
263 span := strconv.FormatInt(old, 10) + " to " + strconv.FormatInt(next, 10)
264 if dearer {
265 return setting + " may make names dearer by at most " + strconv.Itoa(dearMaxFactor) + "x at once (" + span + ")"
266 }
267 return setting + " may make names cheaper by at most " + strconv.Itoa(cheapMaxPct) + "% at once (" + span + ")"
268}
269
270func restRefusal(setting string, dearer bool) string {
271 if dearer {
272 return setting + " made names dearer less than ten minutes ago"
273 }
274 return setting + " made names cheaper less than an hour ago"
275}
276
277// LastTweak reports when a bounded setting last moved instantly in each
278// direction (unix seconds, 0 for never), so the panel can say when the
279// next step each way is due: dearer after ten minutes, cheaper after an hour.
280func LastTweak(setting string) (dearer, cheaper int64) {
281 if v := lastTweak.Get(clockKey(setting, true)); v != nil {
282 dearer = v.(int64)
283 }
284 if v := lastTweak.Get(clockKey(setting, false)); v != nil {
285 cheaper = v.(int64)
286 }
287 return dearer, cheaper
288}
289
290// -- keys only their own setters may write --
291
292/*
293managedGlobal reports whether SetGlobal must refuse a key, because a
294setter in this realm owns it — bounded, timelocked or validated. The
295escape hatch wrote any key, so it walked straight round every one of those
296rules: SetGlobal("rateUgnotPerUsd", "1") was a one-call way to sell every
297name for dust.
298*/
299func managedGlobal(key string) bool {
300 switch key {
301 case keyDefaultPrice, keyRate, keyMarketFee, keyGranter, keyKeeper, keyGlobalLens,
302 keyRefCut, keyRefDisc, keyRefWin, keyMaxTerm, keyFreeMin, keyFreeMax,
303 keyClaimEnd, keyClaimMax, keyRestore, keyRestoredCount, "labdomain":
304 return true
305 }
306 return strings.HasPrefix(key, keyPremium) || strings.HasPrefix(key, blockKeyStem)
307}
308
309// managedDomainKey is the same rule for SetDomainField.
310func managedDomainKey(key string) bool {
311 switch key {
312 case keyPrice, keyLens, keyNames, keyOpen, keyFreeDomain, keyMaxTerm:
313 return true
314 }
315 return false
316}