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.

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