admin.gno
16.70 Kb · 430 lines
1package nslogic
2
3import (
4 "strconv"
5 "strings"
6
7 "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
8)
9
10// assertIsAdmin reads nsdata.GetAdmin() fresh every call — correct even
11// immediately after a TransferAdmin, and correct for a freshly-deployed
12// logic realm with no local state of its own to go stale.
13//
14// Note: admin authority to swap WHICH realm is trustedLogic, or to
15// transfer admin itself, is deliberately NOT exposed here — those live
16// only in nsdata's own admin.gno, callable solely by a direct wallet
17// call from the current admin address, never proxied through logic.
18// That's the one part of the system a compromised or buggy logic realm
19// cannot touch.
20func assertIsAdmin(cur realm) {
21 if cur.Previous().Address() != nsdata.GetAdmin() {
22 panic("nslogic: restricted to project admin")
23 }
24}
25
26// SetLabelPattern no longer changes anything, and refuses anything but
27// the rule this realm actually implements.
28//
29// It used to compile an arbitrary regex and store it, with validateLabel
30// reading it back. That flexibility cost 15 GNOT of permanent storage
31// per deploy — see the measurements in validate.gno — for a rule that
32// has never changed and should not change casually.
33//
34// It is kept rather than deleted so that an admin who reaches for it
35// gets told what happened instead of a "function not found". And it
36// refuses a different pattern rather than accepting one it will not
37// enforce: a stored string describing rules the code does not apply is
38// the exact silent divergence the caching version was written to avoid.
39func SetLabelPattern(cur realm, pattern string) {
40 assertNoSend(cur)
41 assertIsAdmin(cur)
42 emitAdmin(cur, "SetLabelPattern", "pattern", pattern)
43 if pattern != labelRule {
44 panic("nslogic: the label rule is fixed in this realm (" + labelRule +
45 "). Changing it means deploying a new logic realm — which is deliberate, " +
46 "because a stored pattern the code does not enforce is worse than no setting at all.")
47 }
48 nsdata.SetLabelPatternRecord(cross(cur), pattern)
49}
50
51func SetLabelLengthLimits(cur realm, minLen, maxLen int) {
52 assertNoSend(cur)
53 assertIsAdmin(cur)
54 emitAdmin(cur, "SetLabelLengthLimits", "min", fmtI(minLen), "max", fmtI(maxLen))
55 if minLen < 1 || maxLen < minLen {
56 panic("nslogic: invalid length bounds")
57 }
58 nsdata.SetLabelLengthRecord(cross(cur), minLen, maxLen)
59}
60
61// SetDefaultNamePrice sets the fallback price in micro-USD, used by any
62// domain that has not set its own base. BOUNDED (timelock.gno): a raise up
63// to four times every ten minutes, a cut up to 25% once an hour, or a
64// timelocked proposal for anything else.
65func SetDefaultNamePrice(cur realm, microUsd int64) {
66 assertNoSend(cur)
67 assertIsAdmin(cur)
68 emitAdmin(cur, "SetDefaultNamePrice", "microUsd", fmtInt(microUsd))
69 if microUsd < 0 || microUsd > maxPriceUSD {
70 panic("nslogic: a price is 0.." + strconv.FormatInt(maxPriceUSD, 10) + " micro-USD")
71 }
72 v := strconv.FormatInt(microUsd, 10)
73 old := DefaultPriceUSD()
74 authorizeTweak(cur, keyDefaultPrice, old, microUsd, microUsd > old, actionID("SetDefaultNamePrice", v))
75 setGlobal(cur, keyDefaultPrice, v)
76}
77
78// SetUsdRate records how many ugnot one USD buys — the bridge between
79// the USD price list and settlement on this chain.
80//
81// It is a MANUAL rate, not a market feed: it sits where it was last
82// written. A stale rate during a sharp move sells names at the wrong
83// price, and no amount of contract logic fixes that — keeping it current
84// is an operational duty. Isolated as one global so a future logic realm
85// can swap in a real oracle without touching the vault or restating a
86// single price.
87//
88// BOUNDED (timelock.gno): a rate of 1 ugnot to the dollar used to be one
89// call away from selling every name for dust. ASYMMETRIC (Ely,
90// 2026-10-16): a higher rate — GNOT fell, names cost more GNOT — moves up
91// to four times every ten minutes, so a 75% crash is followed in one
92// update; a lower rate — the direction that sells names cheap — moves at
93// most 25%, once an hour. Anything larger is a timelocked proposal.
94//
95// THE KEEPER may call this too (keeper.gno), and nothing else: the same
96// bound and the same hourly slot, never the timelock, and KeeperAction on
97// every move. Anybody who is neither gets the admin refusal, as before.
98func SetUsdRate(cur realm, ugnotPerUsd int64) {
99 assertNoSend(cur)
100 if cur.Previous().Address() != nsdata.GetAdmin() {
101 keeperSetRate(cur, ugnotPerUsd)
102 return
103 }
104 assertIsAdmin(cur)
105 emitAdmin(cur, "SetUsdRate", "ugnotPerUsd", fmtInt(ugnotPerUsd))
106 if ugnotPerUsd <= 0 || ugnotPerUsd > maxRate {
107 panic("nslogic: a rate is 1.." + strconv.FormatInt(maxRate, 10) + " ugnot per USD")
108 }
109 v := strconv.FormatInt(ugnotPerUsd, 10)
110 old := RateUgnotPerUsd()
111 authorizeTweak(cur, keyRate, old, ugnotPerUsd, ugnotPerUsd > old, actionID("SetUsdRate", v))
112 setGlobal(cur, keyRate, v)
113}
114
115// SetTermLength / SetGracePeriod retune the lifecycle itself. Both were
116// hardcoded constants in the vault until 2026-08-21 — meaning the 1-year
117// term and 90-day grace could never have been changed, on a realm that
118// is never redeployed. There was no reason to accept that.
119// maxLifecycleDays bounds both setters. time.Duration is int64
120// NANOSECONDS, so `days * 24h` overflows above ~106,751 days and wraps to
121// a NEGATIVE duration — which would install a negative term and brick
122// every registration and renewal in the registry. Guarding the input is
123// what keeps the multiplication in range.
124const maxLifecycleDays = 36500 // 100 years
125
126// BOTH ARE TIMELOCKED (timelock.gno). A grace period of zero made every
127// name in grace reclaimable by anybody on the spot — owners who could
128// still have renewed lost their names to whoever registered first — and a
129// term change reprices every renewal and moves every holder's tenure.
130func SetTermLength(cur realm, days int64) {
131 assertNoSend(cur)
132 assertIsAdmin(cur)
133 emitAdmin(cur, "SetTermLength", "days", fmtInt(days))
134 if days <= 0 || days > maxLifecycleDays {
135 panic("nslogic: term out of range (1..36500 days)")
136 }
137 term := days * 24 * 60 * 60
138 // Keep gracePeriod <= termLength. Anchoring a renewal to the previous
139 // due date is only coherent while a renewal cannot happen more than
140 // one full term after it; the vault clamps forward defensively, but
141 // the combination should not be reachable in the first place.
142 if nsdata.GetGracePeriod() > term {
143 panic("nslogic: term must be at least the current grace period")
144 }
145 requireAction(cur, actionID("SetTermLength", strconv.FormatInt(days, 10)))
146 nsdata.SetTermLengthRecord(cross(cur), term)
147}
148
149func SetGracePeriod(cur realm, days int64) {
150 assertNoSend(cur)
151 assertIsAdmin(cur)
152 emitAdmin(cur, "SetGracePeriod", "days", fmtInt(days))
153 if days < 0 || days > maxLifecycleDays {
154 panic("nslogic: grace period out of range (0..36500 days)")
155 }
156 grace := days * 24 * 60 * 60
157 if grace > nsdata.GetTermLength() {
158 panic("nslogic: grace period may not exceed the term length")
159 }
160 requireAction(cur, actionID("SetGracePeriod", strconv.FormatInt(days, 10)))
161 nsdata.SetGracePeriodRecord(cross(cur), grace)
162}
163
164// SetTokenURIBase re-points TokenURI at a different renderer, so the NFT
165// artwork can be replaced without touching the vault.
166func SetTokenURIBase(cur realm, base string) {
167 assertNoSend(cur)
168 assertIsAdmin(cur)
169 emitAdmin(cur, "SetTokenURIBase", "base", base)
170 nsdata.SetTokenURIBaseRecord(cross(cur), base)
171}
172
173func SetExtraLimits(cur realm, keyLen, valueLen, entries int) {
174 assertNoSend(cur)
175 assertIsAdmin(cur)
176 emitAdmin(cur, "SetExtraLimits", "keyLen", fmtI(keyLen), "valueLen", fmtI(valueLen), "entries", fmtI(entries))
177 // NEVER BELOW WHAT THIS REALM ITSELF WRITES: the vault's own defaults
178 // (32-byte keys, 512-byte values, 24 entries). One instant call to a
179 // tiny limit used to make every later global write revert, the keeper's
180 // rate and the instant revocations among them (re-audit L-2).
181 if keyLen < minExtraKeyLen || valueLen < minExtraValueLen || entries < minExtraEntries {
182 panic("nslogic: extra limits are at least " + fmtI(minExtraKeyLen) + " / " + fmtI(minExtraValueLen) +
183 " / " + fmtI(minExtraEntries) + ", the sizes this realm writes")
184 }
185 nsdata.SetExtraLimitsRecord(cross(cur), keyLen, valueLen, entries)
186}
187
188const (
189 minExtraKeyLen = 32
190 minExtraValueLen = 512
191 minExtraEntries = 24
192)
193
194// SetGlobalEntryLimit sets how many keys the vault's globals may hold —
195// its own cap now, apart from the per-record extras SetExtraLimits bounds.
196func SetGlobalEntryLimit(cur realm, entries int) {
197 assertNoSend(cur)
198 assertIsAdmin(cur)
199 emitAdmin(cur, "SetGlobalEntryLimit", "entries", fmtI(entries))
200 if entries < 1 || entries > 1024 {
201 panic("nslogic: the globals cap must be 1-1024")
202 }
203 nsdata.SetGlobalEntryLimitRecord(cross(cur), entries)
204}
205
206// SetAccountLimits bounds the vault's per-address store: field name,
207// value, and fields per address. Small numbers, because whoever's
208// transaction writes a field pays for it.
209func SetAccountLimits(cur realm, keyLen, valueLen, entries int) {
210 assertNoSend(cur)
211 assertIsAdmin(cur)
212 emitAdmin(cur, "SetAccountLimits", "keyLen", fmtI(keyLen), "valueLen", fmtI(valueLen), "entries", fmtI(entries))
213 // Never below what this realm writes (the vault's defaults, 16/128/8):
214 // a tiny limit refused every later grant, redemption and referral
215 // count, deletes included — L-2's bug on the per-address store
216 // (review follow-up).
217 if keyLen < 16 || keyLen > 64 || valueLen < 128 || valueLen > 1024 || entries < 8 || entries > 64 {
218 panic("nslogic: account limits must be 16-64 / 128-1024 / 8-64")
219 }
220 nsdata.SetAccountLimitsRecord(cross(cur), keyLen, valueLen, entries)
221}
222
223/*
224SetMarketFee is nsmarket's fee control, living here because this is the
225realm the vault trusts.
226
227nsmarket.SetFee could never work: it wrote through nsdata.SetGlobalRecord
228from a realm deliberately outside the trusted set, so it passed its own
229admin check and then panicked inside the vault for every caller. Rather
230than grant nsmarket vault privileges — which would surrender the property
231that realm is built around — the setter moved to the realm that already
232has the grant, and the validation moved with it.
233
234The spec grammar is nsmarket's, restated here rather than imported so
235this realm does not take a dependency on the marketplace to set a number:
236an amount in ugnot ("1000000"), or a percentage ("%2.5"); "0" for none,
237because "" removes the key and nsmarket then charges its default of one
238GNOT. A malformed spec would otherwise be read on every sale and resolve
239silently to zero.
240
241A CHANGE THAT COULD RAISE THE FEE IS TIMELOCKED. The fee is read at sale
242time, so a raise lands on listings sellers made under the old one — up to
243half of their sale, moved to the treasury, with no notice. A timelocked
244raise is the notice: sellers see ActionProposed and may unlist. Lowering
245it, or switching it off, stays instant.
246*/
247func SetMarketFee(cur realm, spec string) {
248 assertNoSend(cur)
249 assertIsAdmin(cur)
250 emitAdmin(cur, "SetMarketFee", "spec", spec)
251 assertValidFeeSpec(spec)
252 spec = strings.TrimSpace(spec)
253 if feeCouldRise(nsdata.GetGlobal(keyMarketFee), spec) {
254 requireAction(cur, actionID("SetMarketFee", spec))
255 }
256 setGlobal(cur, keyMarketFee, spec)
257}
258
259const (
260 keyMarketFee = "marketfee"
261 // nsmarket's fee when the global is unset: one GNOT, flat. Restated
262 // rather than imported, like the grammar, so this realm takes no
263 // dependency on the marketplace to set a number.
264 defaultMarketFee = "1000000"
265)
266
267// feeCouldRise reports whether moving from the stored spec `old` to `next`
268// could raise the fee on any sale. Both are read the way nsmarket reads
269// them: unset means the default. A move to zero never rises; a change of
270// kind — flat to percentage or back — rises on some price, so it counts.
271func feeCouldRise(old, next string) bool {
272 of, ob := feeParts(old)
273 nf, nb := feeParts(next)
274 switch {
275 case nf == 0 && nb == 0:
276 return false
277 case of == 0 && ob == 0:
278 return true
279 case ob == 0 && nb == 0:
280 return nf > of
281 case of == 0 && nf == 0:
282 return nb > ob
283 }
284 return true
285}
286
287// feeParts splits a spec into (flat ugnot, basis points), the way
288// nsmarket.parseFee does; unset is the default fee, and an unreadable spec
289// is zero — which is also how nsmarket charges it.
290func feeParts(spec string) (flat, bps int64) {
291 spec = strings.TrimSpace(spec)
292 if spec == "" {
293 spec = defaultMarketFee
294 }
295 if !strings.HasPrefix(spec, "%") {
296 n, err := strconv.ParseInt(spec, 10, 64)
297 if err != nil || n < 0 {
298 return 0, 0
299 }
300 return n, 0
301 }
302 body := spec[1:]
303 whole, frac := body, ""
304 if dot := strings.Index(body, "."); dot >= 0 {
305 whole, frac = body[:dot], body[dot+1:]
306 }
307 w, err := strconv.ParseInt("0"+whole, 10, 64)
308 if err != nil {
309 return 0, 0
310 }
311 bps = w * 100
312 if frac != "" {
313 f, err := strconv.ParseInt("0"+frac, 10, 64)
314 if err != nil || len(frac) > 1 {
315 return 0, 0
316 }
317 bps += f * 10
318 }
319 return 0, bps
320}
321
322// assertValidFeeSpec mirrors nsmarket.parseFee. Percentages are carried
323// as basis points so a fee is expressible without floating point
324// anywhere near money.
325func assertValidFeeSpec(spec string) {
326 spec = strings.TrimSpace(spec)
327 if spec == "" {
328 return
329 }
330 if !strings.HasPrefix(spec, "%") {
331 n, err := strconv.ParseInt(spec, 10, 64)
332 if err != nil || n < 0 {
333 panic("nslogic: a fee is an amount in ugnot or a percentage like %2.5 — got " + spec)
334 }
335 return
336 }
337 body := spec[1:]
338 whole, frac := body, ""
339 if dot := strings.Index(body, "."); dot >= 0 {
340 whole, frac = body[:dot], body[dot+1:]
341 }
342 // ONE decimal, matching nsmarket.parseFee exactly. I wrote two here
343 // first, which made this validator MORE permissive than the parser
344 // that has to read the value back — and a spec nsmarket refuses is
345 // read as a zero fee on every sale, silently, forever. A validator
346 // looser than its consumer is worse than no validator: it converts a
347 // rejected input into a silent revenue leak.
348 if len(frac) > 1 {
349 panic("nslogic: a fee percentage carries at most one decimal — got " + spec)
350 }
351 for _, part := range []string{whole, frac} {
352 for i := 0; i < len(part); i++ {
353 if part[i] < '0' || part[i] > '9' {
354 panic("nslogic: a fee percentage must be digits — got " + spec)
355 }
356 }
357 }
358 if whole == "" && frac == "" {
359 panic("nslogic: a fee percentage needs a number — got " + spec)
360 }
361 // Basis points the same way parseFee computes them: whole*100 plus
362 // one tenth-digit worth ten. Not two digits, and not a 100% ceiling
363 // — nsmarket caps at maxFeeBps, which is 50%.
364 w, _ := strconv.ParseInt("0"+whole, 10, 64)
365 bp := w * 100
366 if frac != "" {
367 f, _ := strconv.ParseInt("0"+frac, 10, 64)
368 bp += f * 10
369 }
370 if bp <= 0 || bp > 5000 {
371 panic("nslogic: a fee percentage must be above zero and at most 50 — got " + spec)
372 }
373}
374
375// SetGlobal writes an arbitrary project-wide key into the vault's
376// package-level extension slot — the escape hatch for global state that
377// does not exist yet, and for keys other realms read (cardfx). It refuses
378// every key a setter here owns: the escape hatch used to walk round every
379// bound and timelock on them. See managedGlobal.
380func SetGlobal(cur realm, key, value string) {
381 assertNoSend(cur)
382 assertIsAdmin(cur)
383 emitAdmin(cur, "SetGlobal", "key", key, "value", value)
384 if managedGlobal(key) {
385 panic("nslogic: " + key + " has its own setter, with its own rules; use that")
386 }
387 setGlobal(cur, key, value)
388}
389
390func setGlobal(cur realm, key, value string) {
391 /* THE RESTORE SEAL IS NOT AN ORDINARY GLOBAL.
392 restore.gno says sealing is "IRREVERSIBLE… There is no argument
393 that unseals it". One admin call writing key "restore" used to
394 reopen it. The vault now refuses both keys through its general
395 setter and refuses to change a seal through its reserved one, so a
396 logic swap cannot drop the guard; this check stays so the refusal
397 names the right door. */
398 if key == keyRestore || key == keyRestoredCount {
399 panic("nslogic: " + key + " is written by the restore machinery, not by SetGlobal")
400 }
401 if err := nsdata.SetGlobalRecord(cross(cur), key, value); err != nil {
402 panic(err)
403 }
404}
405
406// SetTreasury moves the revenue-receiving role — independent of
407// nsdata's TransferAdmin, same reasoning as the original build's
408// admin/treasury separation.
409//
410// TIMELOCKED. It took effect at once and said nothing, so a stolen admin
411// key redirected every later sale — names, renewals, skins, market fees —
412// in one call (audit M4). Now it is announced and waits, and recovery can
413// cancel it.
414func SetTreasury(cur realm, newTreasury address) {
415 assertNoSend(cur)
416 assertIsAdmin(cur)
417 emitAdmin(cur, "SetTreasury", "addr", newTreasury.String())
418 if !newTreasury.IsValid() {
419 panic("nslogic: invalid treasury address")
420 }
421 requireAction(cur, actionID("SetTreasury", newTreasury.String()))
422 nsdata.SetTreasuryRecord(cross(cur), newTreasury)
423}
424
425// SelfAddress reports this deployed realm's own address — needed once,
426// at wiring time, so admin knows what to pass to
427// nsdata.ProposeTrustedLogicGrant.
428func SelfAddress(cur realm) address {
429 return cur.Address()
430}