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.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}