write.gno
35.53 Kb · 979 lines
1package nsdata
2
3import (
4 "unicode/utf8"
5 "errors"
6 "math"
7 "strings"
8 "time"
9
10 "chain"
11
12 "gno.land/p/nt/avl/v0"
13 "gno.land/r/nym-mnsrehearsal001/nsnft/v1"
14)
15
16// Every function here is gated to trustedLogic and does exactly what it
17// is told. Authorization beyond "is this the current logic realm" is
18// deliberately NOT duplicated here: the vault stores, the logic realm
19// decides. Re-checking policy in an unfixable realm sounds safer but
20// bakes today's rules in permanently — a future feature that legitimately
21// needs an admin to touch someone's record would be unbuildable.
22
23// -- domains --
24
25func RegisterDomainRecord(cur realm, label string, owner address, now int64) error {
26 assertIsTrustedLogic(cur)
27 if !owner.IsValid() {
28 return errStr("invalid owner address")
29 }
30 if err := assertLabelKeySafe(label); err != nil {
31 return err
32 }
33 if d, ok := getDomain(label); ok {
34 if d.frozen {
35 return errStr("domain is frozen")
36 }
37 if !isReclaimable(d.expires, now) {
38 return errStr("domain already registered and not yet reclaimable")
39 }
40 // No token to burn and no reverse pointer to clear: domains are
41 // records, not tokens, and `primary` only ever held name ids.
42 domains.Remove(label)
43 }
44 domains.Set(label, &Domain{
45 owner: owner, registered: now, expires: now + termLength,
46 extra: avl.Tree{},
47 })
48 emitBy(cur, "DomainRegistered", "label", label, "owner", owner.String(), "expires", i64(now+termLength))
49 return nil
50}
51
52/*
53ReassignDomainRecord moves a domain to a new owner and leaves its
54configuration exactly where it was.
55
56This is the durable fix nslogic.ReassignDomain's comment was waiting for.
57The old path moved a domain through the name-transfer machinery, whose
58wipe exists to clear a SELLER'S payment addresses — and so it silently
59reopened closed domains, dropped their pricing to the default and released
60their term caps, during the key-compromise recovery it exists for. A
61domain's extras are its settings, not a previous holder's data, and there
62is no previous holder: domains never reach a third party.
63
64TTL resets, because it is the one field that is a caching promise made by
65whoever held the record.
66*/
67func ReassignDomainRecord(cur realm, label string, to address) error {
68 assertIsTrustedLogic(cur)
69 if !to.IsValid() {
70 return errStr("invalid owner address")
71 }
72 d, ok := getDomain(label)
73 if !ok {
74 return errStr("unknown domain")
75 }
76 d.owner = to
77 d.ttl = 0
78 emitBy(cur, "DomainReassigned", "label", label, "to", to.String())
79 return nil
80}
81
82func RenewDomainRecord(cur realm, label string, caller address, now int64) error {
83 assertIsTrustedLogic(cur)
84 d, ok := getDomain(label)
85 if !ok {
86 return errStr("unknown domain")
87 }
88 if isReclaimable(d.expires, now) {
89 return errStr("domain expired beyond grace period")
90 }
91 if caller != d.owner {
92 return errStr("restricted to domain owner")
93 }
94 d.expires = advanceTerm(d.expires, now)
95 emitBy(cur, "DomainRenewed", "label", label, "expires", i64(d.expires))
96 return nil
97}
98
99// advanceTerm extends a term by exactly one term-length from its
100// PREVIOUS due date — never from `now`, because the grace period must
101// not silently extend the term somebody paid for.
102//
103// It no longer returns a streak. The streak used to be maintained here,
104// with a guard against renewing repeatedly in one block to inflate it;
105// that whole class of problem disappeared when the streak became a
106// function of the registration date instead of a stored counter. See
107// streakOf in auth.gno.
108func advanceTerm(expires, now int64) int64 {
109 // Anchoring to the previous due date is only safe while a renewal
110 // cannot happen more than one term after it — i.e. while
111 // gracePeriod <= termLength. That invariant held silently under the
112 // old hardcoded constants and stopped holding the moment both became
113 // settable: with a grace period longer than the term, a renewal paid
114 // late in grace would return an expiry ALREADY IN THE PAST, leaving
115 // the payer with a name that is still expired. Clamp forward so a
116 // paid renewal always buys a live term, while keeping the anchor
117 // behaviour in every normal case.
118 // Saturate rather than wrap: renewing a far-future expiry used to
119 // overflow and SHORTEN it to now + term (audit L-2).
120 if expires > math.MaxInt64-termLength {
121 return math.MaxInt64
122 }
123 next := expires + termLength
124 if next <= now {
125 next = now + termLength
126 }
127 return next
128}
129
130func SetDomainFrozen(cur realm, label string, frozen bool) {
131 assertIsTrustedLogic(cur)
132 mustGetDomain(label).frozen = frozen
133 emitBy(cur, "DomainFrozen", "label", label, "frozen", boolStr(frozen))
134}
135
136func SetDomainExpiryRecord(cur realm, label string, expires int64) {
137 assertIsTrustedLogic(cur)
138 mustGetDomain(label).expires = expires
139 emitBy(cur, "DomainExpirySet", "label", label, "expires", i64(expires))
140}
141
142// SetDomainTTLRecord sets how long a resolver may cache answers about
143// this domain, in seconds, where 0 means never cache. See Domain.ttl.
144func SetDomainTTLRecord(cur realm, label string, ttl int64) error {
145 assertIsTrustedLogic(cur)
146 if ttl < 0 {
147 return errStr("ttl may not be negative")
148 }
149 d, ok := getDomain(label)
150 if !ok {
151 return errStr("unknown domain")
152 }
153 d.ttl = ttl
154 emitBy(cur, "DomainTTLSet", "label", label, "ttl", i64(ttl))
155 return nil
156}
157
158// SetDomainExtraRecord writes one arbitrary key on a domain — pricing,
159// or whatever a future logic realm needs. An empty value deletes the
160// key, so storage can be reclaimed.
161func SetDomainExtraRecord(cur realm, label, key, value string) error {
162 assertIsTrustedLogic(cur)
163 d, ok := getDomain(label)
164 if !ok {
165 return errStr("unknown domain")
166 }
167 if err := setExtra(&d.extra, key, value); err != nil {
168 return err
169 }
170 emitBy(cur, "DomainExtraSet", "label", label, "key", key, "value", value)
171 return nil
172}
173
174// DeleteDomainRecord destroys a domain and the names beneath it: it
175// burns every token, drops every reverse-resolution pointer into them,
176// removes the records, and finally removes the domain itself.
177//
178// WHY IT EXISTS. Retiring a domain after a brand complaint, recovering
179// the storage deposit on a domain that never sold, and clearing out fad
180// domains nobody renewed. A logic realm cannot provide any of this,
181// because the vault owns the trees and exposed no delete path — the only
182// Remove call for a domain lived inside RegisterDomainRecord as the
183// evict-and-replace step of re-registration.
184//
185// IT DELIBERATELY WORKS ON A DOMAIN WITH LIVE NAMES UNDER IT. The case
186// this exists for is a demand to remove a domain entirely, where waiting
187// for names to lapse is not an option. Compensating the holders is
188// handled off chain and is deliberately not a contract concern.
189//
190// WHERE THIS DEPARTS FROM THE SPEC, stated plainly because it is a real
191// difference: MAINNET.md §3d asks for all of it "in one call". It is
192// bounded instead, deleting at most maxDeletePerCall names per call and
193// reporting whether it finished. An unbounded loop over a tree that may
194// hold any number of names is a transaction that stops fitting in a
195// block once a domain gets popular — which would mean the domains most
196// likely to attract a complaint are exactly the ones that could never be
197// deleted, in a realm that can never be fixed. The caller repeats until
198// `done`; for any domain of realistic size that is one call, so the
199// no-preconditions requirement is preserved. What is NOT preserved is
200// atomicity: a multi-call deletion is visible half-done between calls.
201// Freeze the domain first if that matters, so nothing new lands under it
202// meanwhile. Not Pause(): a paused registry deletes nothing at all, since
203// this passes the same trust gate as every write.
204//
205// Calling it on a domain that does not exist is a no-op reporting done,
206// so a repeat loop terminates cleanly rather than panicking on the call
207// that follows the last one.
208func DeleteDomainRecord(cur realm, label string) (removed int64, done bool) {
209 assertIsTrustedLogic(cur)
210 d, ok := getDomain(label)
211 if !ok {
212 return 0, true
213 }
214
215 // Collect first, mutate second. Removing entries from the tree while
216 // iterating it is undefined, and this loop exists precisely to remove
217 // entries.
218 start, end := domainRange(label)
219 var keys []string
220 names.Iterate(start, end, func(key string, v any) bool {
221 if len(keys) >= maxDeletePerCall {
222 return true
223 }
224 keys = append(keys, key)
225 return false
226 })
227
228 for _, key := range keys {
229 v := names.Get(key)
230 if v == nil {
231 continue
232 }
233 n := v.(*Name)
234 // The name label is whatever follows "domain*" in the storage
235 // key — the record no longer stores its own label.
236 nameLabel := key[len(label)+1:]
237 tid := nameTokenID(nameLabel, label)
238 _ = n
239 owner := nsnft.OwnerOf(tid)
240 clearPrimaryIfPointingAt(owner, tid)
241 // Every name has a token, but a registry swapped in mid-migration
242 // could meet a record whose token was already dealt with. Burning a
243 // token that does not exist panics and would strand the domain
244 // half-deleted, so ask first.
245 if owner != "" {
246 nsnft.Burn(cross(cur), tid)
247 }
248 names.Remove(key)
249 removed++
250 }
251
252 // Anything left means the cap was hit; the domain record has to stay
253 // so the next call can still find its name span.
254 more := false
255 names.Iterate(start, end, func(key string, v any) bool {
256 more = true
257 return true
258 })
259 if more {
260 emitBy(cur, "DomainDeleted", "label", label, "removed", i64(removed), "done", "false")
261 return removed, false
262 }
263
264 _ = d
265 domains.Remove(label)
266 emitBy(cur, "DomainDeleted", "label", label, "removed", i64(removed), "done", "true")
267 return removed, true
268}
269
270// -- names --
271
272/*
273RegisterNameRecord creates `label*domain` and mints its token in nsnft.
274
275`paid` false registers a FREE-TIER name: the token is minted with the free
276hold already set, so there is never an instant in which it could be
277approved and sold before a lock arrived. There is no longer any such thing
278as a name without a token — see Name.
279
280RECLAIM burns the previous holder's token first. The owner is read from
281the ledger, never from the record, because the record has no owner.
282
283AN ORPHAN TOKEN IS REFUSED. A token that exists with no record behind it
284means a registry swap is mid-migration or something has gone wrong; either
285way, registering over it would mint a second claim to a name somebody
286already holds. RestoreNameRecord is the door for adopting one.
287*/
288func RegisterNameRecord(cur realm, label, domainLabel string, owner address, now int64, paid bool) error {
289 assertIsTrustedLogic(cur)
290 if !owner.IsValid() {
291 return errStr("invalid owner address")
292 }
293 if err := assertLabelKeySafe(label); err != nil {
294 return err
295 }
296 d, ok := getDomain(domainLabel)
297 if !ok {
298 return errStr("unknown domain")
299 }
300 if d.frozen {
301 return errStr("domain is frozen")
302 }
303 if isReclaimable(d.expires, now) {
304 return errStr("domain has lapsed past its grace period")
305 }
306 treeKey := nameTreeKey(label, domainLabel)
307 tid := nameTokenID(label, domainLabel)
308 var reclaimedFrom address
309 if n, ok := getName(label, domainLabel); ok {
310 if n.frozen {
311 return errStr("name is frozen")
312 }
313 if !isReclaimable(n.expires, now) {
314 return errStr("name already registered and not yet reclaimable")
315 }
316 // Evict the previous holder completely. The reverse pointer goes
317 // first: leaving it would make the old owner resolve to a name the
318 // reclaimer now owns.
319 prev := nsnft.OwnerOf(tid)
320 reclaimedFrom = prev
321 clearPrimaryIfPointingAt(prev, tid)
322 if prev != "" {
323 nsnft.Burn(cross(cur), tid)
324 }
325 names.Remove(treeKey)
326 } else if nsnft.OwnerOf(tid) != "" {
327 return errStr("a token for " + tid + " exists with no record behind it; restore it rather than registering over it")
328 }
329
330 nsnft.Mint(cross(cur), owner, tid, holdBits(paid, false))
331 names.Set(treeKey, &Name{
332 registered: now, expires: now + termLength,
333 epoch: nsnft.Epoch(tid), since: now, extra: avl.Tree{},
334 })
335 setPrimaryIfNone(owner, tid)
336 emitBy(cur, "NameRegistered", "tid", tid, "owner", owner.String(),
337 "expires", i64(now+termLength), "paid", boolStr(paid), "reclaimedFrom", reclaimedFrom.String())
338 return nil
339}
340
341func holdBits(paid, frozen bool) int {
342 b := 0
343 if !paid {
344 b |= 1 << lockBitFree
345 }
346 if frozen {
347 b |= 1 << lockBitFrozen
348 }
349 return b
350}
351
352/*
353setPrimaryIfNone gives an address a reverse pointer when it has none — or
354when the one it has points at a name it no longer holds.
355
356The second half is new. A pointer used to be cleared on every transfer,
357because transfers went through this realm. They no longer do: a sale that
358settles on nsnft leaves the seller's pointer aimed at a name they sold.
359nslogic.ResolveAddress already refuses to answer with it, so it never
360misleads anybody — but left in place it also blocked the seller's NEXT
361registration from becoming their primary, which would have looked like a
362bug in registration rather than a stale pointer.
363*/
364func setPrimaryIfNone(owner address, tid string) {
365 k := owner.String()
366 if v := primary.Get(k); v == nil || !ownsNow(owner, v.(string)) {
367 primary.Set(k, tid)
368 // Reverse resolution is the one record that publishes a wallet's
369 // persona, so even the automatic pointer says so.
370 chain.Emit("PrimarySet", "owner", k, "tid", tid, "auto", "true")
371 }
372}
373
374/*
375UpgradeFreeRecord lifts the free-tier hold — the paid upgrade.
376
377Preserves the dates: this is not a re-registration.
378
379`newOwner` exists because the common case is somebody who decided to sell
380and, in the same moment, wants the name in a wallet they control rather
381than the key they were given. That move is a burn and a mint here, because
382a held token cannot be transferred by design.
383
384THE PROFILE DOES NOT GO WITH IT, and it used to. The move re-stamped the
385old data to the new epoch and gave the recipient a reverse pointer — and
386`newOwner` can be ANY address. An audit spelled out the attack: fill a
387free name's profile with your own pay addresses, pay list price to upgrade
388it into a victim's wallet, and the victim's name now asks people to pay
389you, their address reverse-resolves to it, and their own later edits do
390not clear it because the epoch is already current. So the data is left in
391the previous epoch, where every read hides it and the next write destroys
392it, and the recipient gets no pointer they never chose. A person moving
393their own name to a new wallet re-enters their profile once; that is the
394price of the move not being usable against somebody else.
395
396Refused for a frozen name. Upgrading must never be the way a disputed name
397gets out of its hold.
398*/
399func UpgradeFreeRecord(cur realm, label, domainLabel string, newOwner address) error {
400 assertIsTrustedLogic(cur)
401 if !newOwner.IsValid() {
402 return errStr("invalid owner address")
403 }
404 n, ok := getName(label, domainLabel)
405 if !ok {
406 return errStr("unknown name")
407 }
408 if n.frozen {
409 return errStr("name is frozen")
410 }
411 tid := nameTokenID(label, domainLabel)
412 owner := nsnft.OwnerOf(tid)
413 if owner == "" {
414 return errStr("name has no token")
415 }
416 bits := nsnft.LockBits(tid)
417 if bits&(1<<lockBitFree) == 0 {
418 return errStr("name is already paid for")
419 }
420 if newOwner == owner {
421 nsnft.SetLock(cross(cur), tid, lockBitFree, false)
422 // Paid from now: time spent on the free tier is not time spent
423 // holding a sellable name. See Name.since.
424 n.since = time.Now().Unix()
425 emitBy(cur, "NameUpgraded", "tid", tid, "owner", owner.String())
426 return nil
427 }
428 nsnft.Burn(cross(cur), tid)
429 nsnft.Mint(cross(cur), newOwner, tid, bits&^(1<<lockBitFree))
430 clearPrimaryIfPointingAt(owner, tid)
431 // n.epoch is deliberately NOT moved, and no pointer is given — see
432 // above. n.since belongs to that old epoch too; the new owner's clock
433 // starts at their first write.
434 emitBy(cur, "NameUpgraded", "tid", tid, "owner", newOwner.String(), "from", owner.String())
435 return nil
436}
437
438/*
439RestoreNameRecord is the migration door: records move, tokens do not.
440
441A registry swapped in behind the same nsnft finds tokens that already
442exist. This writes the record for one and ADOPTS its token, or mints one
443when the ledger has none. It never moves a token: a snapshot must never
444take a name from its current holder.
445
446WHEN THE LEDGER DISAGREES WITH THE SNAPSHOT, IT WINS. This used to refuse,
447and an audit showed where refusing leads: a name sold between the snapshot
448and the restore panics the batch, gets dropped to finish the rest, and the
449window seals — leaving the buyer's token with no record behind it, unable
450to be renewed, resolved or registered, forever. So the record is written
451for whoever holds the token now, and the snapshot's profile data is NOT
452replayable onto it (restoredFor stays empty), because it was the seller's.
453
454Gated like every write, and nslogic only reaches it through the restore
455window, which is one-shot.
456*/
457func RestoreNameRecord(cur realm, label, domainLabel string, owner address, registered int64, paid bool) error {
458 assertIsTrustedLogic(cur)
459 if !owner.IsValid() {
460 return errStr("invalid owner address")
461 }
462 if err := assertLabelKeySafe(label); err != nil {
463 return err
464 }
465 if _, ok := getDomain(domainLabel); !ok {
466 return errStr("unknown domain")
467 }
468 if _, ok := getName(label, domainLabel); ok {
469 return errStr("name already has a record")
470 }
471 tid := nameTokenID(label, domainLabel)
472 held := nsnft.OwnerOf(tid)
473 if held == "" {
474 nsnft.Mint(cross(cur), owner, tid, holdBits(paid, false))
475 held = owner
476 } else {
477 /* NEVER ADOPT WHILE ANOTHER REGISTRY CAN STILL MINT.
478
479 The ledger's trust set is a set so an old and a new registry can
480 both be live during a migration — and an audit showed what that
481 allows: a name adopted here and renewed here still has its OLD
482 record, with its old expiry, in the old registry. Once that
483 lapses past grace, anyone registers the name through the old
484 logic realm, which burns the token and mints it to them. The
485 holder's paid, renewed name is gone. So adoption requires that
486 this registry is the only one the ledger trusts: pause and
487 untrust the old registry first, then restore. */
488 if nsnft.Trusted() != cur.Address().String() {
489 return errStr("adopting " + tid + " while the ledger trusts another registry would let that registry reclaim it; untrust the old registry first")
490 }
491 // Adopt. BOTH holds are set from what is being restored: the free
492 // hold from the snapshot, and the frozen hold cleared, so a freeze
493 // left by the old registry cannot linger on a record that reads
494 // unfrozen. nslogic re-freezes after this if the snapshot says so.
495 nsnft.SetLock(cross(cur), tid, lockBitFree, !paid)
496 nsnft.SetLock(cross(cur), tid, lockBitFrozen, false)
497 }
498 // `since` is the restore, not the snapshot's registration date: this
499 // registry cannot know when the holder acquired the token, and the
500 // clock may understate a holding but never overstate one.
501 rec := &Name{
502 registered: registered, expires: registered + termLength,
503 epoch: nsnft.Epoch(tid), since: time.Now().Unix(), extra: avl.Tree{},
504 }
505 if held == owner {
506 rec.restoredFor = owner
507 setPrimaryIfNone(owner, tid)
508 }
509 names.Set(nameTreeKey(label, domainLabel), rec)
510 emitBy(cur, "NameRestored", "tid", tid, "owner", held.String(), "snapshotOwner", owner.String())
511 return nil
512}
513
514func RenewNameRecord(cur realm, label, domainLabel string, caller address, now int64) error {
515 assertIsTrustedLogic(cur)
516 n, ok := getName(label, domainLabel)
517 if !ok {
518 return errStr("unknown name")
519 }
520 if isReclaimable(n.expires, now) {
521 return errStr("name expired beyond grace period")
522 }
523 /* THE CHECK THE FIRST ATTEMPT GOT WRONG. It compared the caller to the
524 record's own owner copy, which stopped updating the moment ownership
525 moved to the token — so a buyer could not renew what they bought,
526 watched it lapse, and lost it to whoever re-registered. There is no
527 copy now. The ledger answers. */
528 if caller != nsnft.OwnerOf(nameTokenID(label, domainLabel)) {
529 return errStr("restricted to name owner")
530 }
531 n.expires = advanceTerm(n.expires, now)
532 emitBy(cur, "NameRenewed", "tid", nameTokenID(label, domainLabel), "expires", i64(n.expires))
533 return nil
534}
535
536/*
537SetNameFrozen sets the record's frozen bit AND the matching hold on the
538token, in the same call.
539
540The record bit alone used to be the whole mechanism, and it bound nothing
541outside our own transfer wrappers — a disputed name could be moved on any
542marketplace that called the token directly. The hold in nsnft binds every
543path there is.
544*/
545func SetNameFrozen(cur realm, label, domainLabel string, frozen bool) {
546 assertIsTrustedLogic(cur)
547 mustGetName(label, domainLabel).frozen = frozen
548 nsnft.SetLock(cross(cur), nameTokenID(label, domainLabel), lockBitFrozen, frozen)
549 emitBy(cur, "NameFrozen", "tid", nameTokenID(label, domainLabel), "frozen", boolStr(frozen))
550}
551
552// SetNameExpiryRecord sets an expiry directly. Backs the admin
553// free-extension lever (SPEC.md §19) and any future term mechanic;
554// the logic realm decides who may call it.
555func SetNameExpiryRecord(cur realm, label, domainLabel string, expires int64) {
556 assertIsTrustedLogic(cur)
557 mustGetName(label, domainLabel).expires = expires
558 emitBy(cur, "NameExpirySet", "tid", nameTokenID(label, domainLabel), "expires", i64(expires))
559}
560
561// SetNameTTLRecord sets how long a resolver may cache answers about this
562// name, in seconds, where 0 means never cache. See Domain.ttl for why
563// this exists and why the owner rather than the registry sets it.
564func SetNameTTLRecord(cur realm, label, domainLabel string, ttl int64) error {
565 assertIsTrustedLogic(cur)
566 if ttl < 0 {
567 return errStr("ttl may not be negative")
568 }
569 n, ok := getName(label, domainLabel)
570 if !ok {
571 return errStr("unknown name")
572 }
573 freshen(n, nameTokenID(label, domainLabel))
574 n.ttl = ttl
575 emitBy(cur, "NameTTLSet", "tid", nameTokenID(label, domainLabel), "ttl", i64(ttl))
576 return nil
577}
578
579// SetNameExtraRecord writes one arbitrary key on a name. This is where
580// profile fields, cross-chain addresses, management-key hashes and tier
581// markers live. An empty value deletes the key.
582func SetNameExtraRecord(cur realm, label, domainLabel, key, value string) error {
583 assertIsTrustedLogic(cur)
584 n, ok := getName(label, domainLabel)
585 if !ok {
586 return errStr("unknown name")
587 }
588 freshen(n, nameTokenID(label, domainLabel))
589 if err := setExtra(&n.extra, key, value); err != nil {
590 return err
591 }
592 // The field, never the value: see events.gno.
593 emitBy(cur, "NameExtraSet", "tid", nameTokenID(label, domainLabel), "key", key)
594 return nil
595}
596
597/*
598freshen is the WRITE half of the lazy wipe, and it is the half the first
599attempt described in prose and never wrote.
600
601If a name's data belongs to a previous ownership epoch, every extra and the
602TTL are destroyed, and the record is re-stamped to the current epoch —
603BEFORE the new value is written. So the buyer's first edit cannot carry
604the seller's Bitcoin address forward as the buyer's: by the time the edit
605lands, the address is gone.
606
607It wipes everything rather than a list of sensitive keys, for the same
608reason the old transfer wipe did: which keys matter is policy, and a list
609kept here would be wrong the first time nslogic invents a key.
610
611The read half — hiding stale data from everybody the instant the epoch
612moves — is isFresh, used by every name read.
613
614It is also where a buyer's holding clock starts (Name.since): the first
615moment this realm can see that the ledger has moved on.
616*/
617func freshen(n *Name, tid string) {
618 before := n.epoch
619 freshenTo(n, nsnft.Epoch(tid), time.Now().Unix())
620 if n.epoch != before {
621 chain.Emit("NameFreshened", "tid", tid, "since", i64(n.since))
622 }
623}
624
625// freshenTo is freshen with the ledger's answer and the clock passed in,
626// so the rule itself can be tested without a registry the ledger trusts.
627func freshenTo(n *Name, epoch, now int64) {
628 if n.epoch != epoch {
629 n.extra = avl.Tree{}
630 n.ttl = 0
631 n.epoch = epoch
632 n.since = now
633 }
634}
635
636// FreshenNameRecord applies freshen and writes nothing else: the way a
637// holder who bought a name starts its clock (Name.since) without having
638// to invent a profile edit. For the current holder, freshen destroys only
639// data every read already hides.
640func FreshenNameRecord(cur realm, label, domainLabel string) error {
641 assertIsTrustedLogic(cur)
642 n, ok := getName(label, domainLabel)
643 if !ok {
644 return errStr("unknown name")
645 }
646 freshen(n, nameTokenID(label, domainLabel)) // emits NameFreshened when it changed anything
647 return nil
648}
649
650func SetPrimaryRecord(cur realm, owner address, label, domainLabel string) error {
651 assertIsTrustedLogic(cur)
652 if _, ok := getName(label, domainLabel); !ok {
653 return errStr("unknown name")
654 }
655 tid := nameTokenID(label, domainLabel)
656 // nslogic checks this too. Checked here as well because a pointer at a
657 // name somebody does not hold is the one thing reverse resolution must
658 // never be able to say, and the ledger is one read away.
659 if !ownsNow(owner, tid) {
660 return errStr("restricted to the name's current owner")
661 }
662 primary.Set(owner.String(), tid)
663 emitBy(cur, "PrimarySet", "owner", owner.String(), "tid", tid)
664 return nil
665}
666
667func ClearPrimaryRecord(cur realm, owner address) {
668 assertIsTrustedLogic(cur)
669 primary.Remove(owner.String())
670 emitBy(cur, "PrimaryCleared", "owner", owner.String())
671}
672
673// -- there are no raw NFT operations here any more --
674//
675// RawTransferNFT, RawApprove, RawSetApprovalForAll and TransferRecordOwner
676// are gone. Transfers and approvals happen on nsnft, called directly by the
677// holder or by a marketplace they approved — exactly how an external
678// marketplace does it, so ours has no privileged path. A registry that
679// could move tokens would be a registry that could steal them.
680
681// -- config: all settable, none constant --
682
683func SetTreasuryRecord(cur realm, addr address) {
684 assertIsTrustedLogic(cur)
685 treasury = addr
686 emitConfig(cur, "treasury", addr.String())
687}
688
689// The lifecycle setters refuse the two values that would break the
690// lifecycle for everybody: a term of zero or less, and a negative grace
691// period, which made live names reclaimable early (audit L-2). nslogic
692// bounds both too; a future logic realm need not.
693func SetTermLengthRecord(cur realm, seconds int64) {
694 assertIsTrustedLogic(cur)
695 if seconds <= 0 {
696 panic("nsdata: a term must be positive")
697 }
698 termLength = seconds
699 emitConfig(cur, "termLength", i64(seconds))
700}
701
702func SetGracePeriodRecord(cur realm, seconds int64) {
703 assertIsTrustedLogic(cur)
704 if seconds < 0 {
705 panic("nsdata: a grace period cannot be negative")
706 }
707 gracePeriod = seconds
708 emitConfig(cur, "gracePeriod", i64(seconds))
709}
710
711// SetMaxDeletePerCallRecord tunes how much work one DeleteDomainRecord
712// call will do. See maxDeletePerCall in types.gno.
713func SetMaxDeletePerCallRecord(cur realm, n int) {
714 assertIsTrustedLogic(cur)
715 if n < 1 {
716 n = 1
717 }
718 maxDeletePerCall = n
719 emitConfig(cur, "maxDeletePerCall", i64(int64(n)))
720}
721func SetLabelPatternRecord(cur realm, pattern string) {
722 assertIsTrustedLogic(cur)
723 labelPattern = pattern
724 emitConfig(cur, "labelPattern", pattern)
725}
726
727func SetTokenURIBaseRecord(cur realm, base string) {
728 assertIsTrustedLogic(cur)
729 tokenURIBase = base
730 emitConfig(cur, "tokenURIBase", base)
731}
732
733func SetLabelLengthRecord(cur realm, minLen, maxLen int) {
734 assertIsTrustedLogic(cur)
735 minLabelLen, maxLabelLen = minLen, maxLen
736 emitConfig(cur, "labelLength", i64(int64(minLen))+","+i64(int64(maxLen)))
737}
738
739func SetExtraLimitsRecord(cur realm, keyLen, valueLen, entries int) {
740 assertIsTrustedLogic(cur)
741 maxExtraKeyLen, maxExtraValueLen, maxExtraEntries = keyLen, valueLen, entries
742 emitConfig(cur, "extraLimits", i64(int64(keyLen))+","+i64(int64(valueLen))+","+i64(int64(entries)))
743}
744
745// SetGlobalEntryLimitRecord sets how many keys globals may hold, apart
746// from the per-record extras cap. See maxGlobalEntries.
747func SetGlobalEntryLimitRecord(cur realm, entries int) {
748 assertIsTrustedLogic(cur)
749 if entries < 1 {
750 panic("nsdata: the globals cap must be at least 1")
751 }
752 maxGlobalEntries = entries
753 emitConfig(cur, "globalEntries", i64(int64(entries)))
754}
755
756// SetAccountLimitsRecord bounds the per-address store. Lowering a cap
757// never deletes anything; it only refuses the next write that exceeds it.
758func SetAccountLimitsRecord(cur realm, keyLen, valueLen, entries int) {
759 assertIsTrustedLogic(cur)
760 if keyLen < 1 || valueLen < 1 || entries < 1 {
761 panic("nsdata: account limits must be positive")
762 }
763 maxAccountKeyLen, maxAccountValueLen, maxAccountEntries = keyLen, valueLen, entries
764 emitConfig(cur, "accountLimits", i64(int64(keyLen))+","+i64(int64(valueLen))+","+i64(int64(entries)))
765}
766
767// -- per-address state --
768
769/*
770SetAccountRecord writes one field of an address's own record: a credit
771balance, a counter, whatever a logic realm keeps per wallet. An empty
772value deletes the field, so its storage can be reclaimed.
773
774Bounded like the extras, with caps of its own: the field name, the value,
775and how many fields one address may hold. The fields-per-address cap is
776what keeps any single wallet's footprint small, whoever pays for it.
777*/
778func SetAccountRecord(cur realm, owner address, key, value string) error {
779 assertIsTrustedLogic(cur)
780 if !owner.IsValid() {
781 return errStr("invalid address")
782 }
783 // Deleting first, before the field-name limit, as setExtraCapped does
784 // (re-audit L-2): a delete frees storage and grants nothing.
785 if value == "" {
786 if key == "" || strings.ContainsAny(key, ":\n\r") {
787 return errStr("bad account field")
788 }
789 accounts.Remove(accountKey(owner, key))
790 emitBy(cur, "AccountSet", "addr", owner.String(), "key", key, "value", "")
791 return nil
792 }
793 if err := assertAccountKey(key); err != nil {
794 return err
795 }
796 k := accountKey(owner, key)
797 if len(value) > maxAccountValueLen {
798 return errStr("account value too long")
799 }
800 if !utf8.ValidString(value) {
801 return errStr("account values must be valid UTF-8")
802 }
803 if accounts.Get(k) == nil && accountFields(owner) >= maxAccountEntries {
804 return errStr("too many fields on this address")
805 }
806 accounts.Set(k, value)
807 emitBy(cur, "AccountSet", "addr", owner.String(), "key", key, "value", value)
808 return nil
809}
810
811// accountFields counts one address's fields, stopping at the cap: the
812// only question asked of it is whether the cap is reached, and an
813// unbounded walk is not something a write path should contain.
814func accountFields(owner address) int {
815 n := 0
816 start, end := accountRange(owner)
817 accounts.Iterate(start, end, func(key string, v any) bool {
818 n++
819 return n >= maxAccountEntries
820 })
821 return n
822}
823
824// assertAccountKey keeps field names short and free of the characters
825// that would make a snapshot listing ambiguous, and of ':', which
826// separates the address from the field.
827func assertAccountKey(key string) error {
828 if key == "" || len(key) > maxAccountKeyLen {
829 return errStr("bad account field")
830 }
831 if strings.ContainsAny(key, ",:\n\r") {
832 return errStr("account field may not contain commas, colons or newlines")
833 }
834 return nil
835}
836
837// SetGlobalRecord writes one arbitrary package-level key — fee rates,
838// default prices, a custodian address, points balances, claim records.
839// Anything global that does not exist yet goes here instead of needing a
840// package variable that could never be added after deployment.
841// Keys the restore machinery owns. Written only by RestoreGlobal's own
842// path, never by a general setter — because nslogic's SetGlobal forwards
843// whatever key an admin passes, and one call writing "restore" reopened
844// a seal that restore.gno calls irreversible. Enforced HERE as well as
845// there, so a future logic realm cannot drop the guard by forgetting it.
846var reservedGlobals = []string{"restore", "restored"}
847
848func SetGlobalRecord(cur realm, key, value string) error {
849 assertIsTrustedLogic(cur)
850 for _, r := range reservedGlobals {
851 if key == r {
852 return errStr("that key belongs to the restore machinery and is not settable here")
853 }
854 }
855 if err := setGlobal(key, value); err != nil {
856 return err
857 }
858 emitBy(cur, "GlobalSet", "key", key, "value", value)
859 return nil
860}
861
862// setGlobal is setExtra held to globals' own entry cap.
863func setGlobal(key, value string) error {
864 return setExtraCapped(&globals, key, value, maxGlobalEntries)
865}
866
867// SetReservedGlobalRecord is the restore machinery's own door to the
868// keys above. Same trust gate; separate name, so a general-purpose
869// setter can never reach them by accident.
870func SetReservedGlobalRecord(cur realm, key, value string) error {
871 assertIsTrustedLogic(cur)
872 found := false
873 for _, r := range reservedGlobals {
874 if key == r {
875 found = true
876 }
877 }
878 if !found {
879 return errStr("not a reserved key; use SetGlobalRecord")
880 }
881 // THE RESTORE DOOR MOVES ONE WAY, here as well as in the logic realm:
882 // never opened, then opened once, then sealed — and from "sealed"
883 // nowhere. The seal is the public promise that this registry's history
884 // can no longer be rewritten on request, and "opened exactly once" is
885 // what keeps a window from being closed quietly and opened again later;
886 // a promise any trusted logic could undo with one write was only as
887 // good as the newest logic generation. Re-writing "sealed" is harmless.
888 if key == "restore" {
889 if state := GetGlobal("restore"); state == "sealed" && value != "sealed" {
890 return errStr("restore is sealed, and a seal does not come undone")
891 } else if state != "" && value != "sealed" {
892 return errStr("the restore window opens once; from here it can only be sealed")
893 }
894 }
895 if err := setGlobal(key, value); err != nil {
896 return err
897 }
898 emitBy(cur, "ReservedGlobalSet", "key", key, "value", value)
899 return nil
900}
901
902// setExtra applies the shared bounds and the delete-on-empty rule to a
903// name's or a domain's extras.
904func setExtra(t *avl.Tree, key, value string) error {
905 return setExtraCapped(t, key, value, maxExtraEntries)
906}
907
908func setExtraCapped(t *avl.Tree, key, value string, maxEntries int) error {
909 // DELETING COMES FIRST, before any limit is checked: removing a key
910 // frees storage and grants nothing, and with the key check in front a
911 // single SetExtraLimits(1, ...) made every global undeletable — the
912 // guardian's ClearKeeper, SetGranter(""), SetClaimWindow(0) included
913 // (re-audit 2026-10-16, L-2).
914 if value == "" {
915 if key == "" {
916 return errStr("bad extra key")
917 }
918 t.Remove(key)
919 return nil
920 }
921 if err := assertExtraKey(key); err != nil {
922 return err
923 }
924 // Bytes the snapshot can carry back. The vault used to store anything,
925 // and a value with invalid UTF-8 came back from a snapshot three bytes
926 // per bad byte, too long to replay (re-audit L-8).
927 if !utf8.ValidString(key) || !utf8.ValidString(value) {
928 return errStr("extra keys and values must be valid UTF-8")
929 }
930 if len(value) > maxExtraValueLen {
931 return errStr("extra value too long")
932 }
933 if t.Get(key) == nil && t.Size() >= maxEntries {
934 return errStr("too many extra entries")
935 }
936 t.Set(key, value)
937 return nil
938}
939
940// assertExtraKey keeps extension keys free of the characters that would
941// make a snapshot ambiguous. Export lists keys as CSV, so a key
942// containing a comma or a newline would silently corrupt a backup — and
943// a backup you cannot trust is the one thing this realm cannot afford.
944func assertExtraKey(key string) error {
945 if key == "" || len(key) > maxExtraKeyLen {
946 return errStr("bad extra key")
947 }
948 if strings.ContainsAny(key, ",\n\r") {
949 return errStr("extra key may not contain commas or newlines")
950 }
951 return nil
952}
953
954// assertLabelKeySafe is a STRUCTURAL check, not a policy one. Character
955// policy lives in the logic realm's tunable labelPattern; this is the
956// narrow set of characters that would break the vault's own machinery no
957// matter what policy is in force:
958//
959// "*" is the separator in both key encodings, so a label containing
960// one makes storage keys and token ids ambiguous;
961// "," and newlines would corrupt the CSV snapshot exports, which are
962// the disaster-recovery guarantee — assertExtraKey already refuses
963// them for extension keys, and labels become keys just as much;
964// "" cannot address a record at all.
965//
966// Enforcing it here rather than trusting the logic realm matters because
967// labelPattern is admin-tunable: loosening it must never be able to
968// silently corrupt a backup in a realm that cannot be redeployed.
969func assertLabelKeySafe(label string) error {
970 if label == "" {
971 return errStr("empty label")
972 }
973 if strings.ContainsAny(label, "*,\n\r") {
974 return errStr("label may not contain '*', commas or newlines")
975 }
976 return nil
977}
978
979func errStr(s string) error { return errors.New(s) }