package nsdata import ( "unicode/utf8" "errors" "math" "strings" "time" "chain" "gno.land/p/nt/avl/v0" "gno.land/r/nym-mnsrehearsal001/nsnft/v2" ) // Every function here is gated to trustedLogic and does exactly what it // is told. Authorization beyond "is this the current logic realm" is // deliberately NOT duplicated here: the vault stores, the logic realm // decides. Re-checking policy in an unfixable realm sounds safer but // bakes today's rules in permanently — a future feature that legitimately // needs an admin to touch someone's record would be unbuildable. // -- domains -- func RegisterDomainRecord(cur realm, label string, owner address, now int64) error { assertIsTrustedLogic(cur) if !owner.IsValid() { return errStr("invalid owner address") } if err := assertLabelKeySafe(label); err != nil { return err } if d, ok := getDomain(label); ok { if d.frozen { return errStr("domain is frozen") } if !isReclaimable(d.expires, now) { return errStr("domain already registered and not yet reclaimable") } // No token to burn and no reverse pointer to clear: domains are // records, not tokens, and `primary` only ever held name ids. domains.Remove(label) } domains.Set(label, &Domain{ owner: owner, registered: now, expires: now + termLength, extra: avl.Tree{}, }) emitBy(cur, "DomainRegistered", "label", label, "owner", owner.String(), "expires", i64(now+termLength)) return nil } /* ReassignDomainRecord moves a domain to a new owner and leaves its configuration exactly where it was. This is the durable fix nslogic.ReassignDomain's comment was waiting for. The old path moved a domain through the name-transfer machinery, whose wipe exists to clear a SELLER'S payment addresses — and so it silently reopened closed domains, dropped their pricing to the default and released their term caps, during the key-compromise recovery it exists for. A domain's extras are its settings, not a previous holder's data, and there is no previous holder: domains never reach a third party. TTL resets, because it is the one field that is a caching promise made by whoever held the record. */ func ReassignDomainRecord(cur realm, label string, to address) error { assertIsTrustedLogic(cur) if !to.IsValid() { return errStr("invalid owner address") } d, ok := getDomain(label) if !ok { return errStr("unknown domain") } d.owner = to d.ttl = 0 emitBy(cur, "DomainReassigned", "label", label, "to", to.String()) return nil } func RenewDomainRecord(cur realm, label string, caller address, now int64) error { assertIsTrustedLogic(cur) d, ok := getDomain(label) if !ok { return errStr("unknown domain") } if isReclaimable(d.expires, now) { return errStr("domain expired beyond grace period") } if caller != d.owner { return errStr("restricted to domain owner") } d.expires = advanceTerm(d.expires, now) emitBy(cur, "DomainRenewed", "label", label, "expires", i64(d.expires)) return nil } // advanceTerm extends a term by exactly one term-length from its // PREVIOUS due date — never from `now`, because the grace period must // not silently extend the term somebody paid for. // // It no longer returns a streak. The streak used to be maintained here, // with a guard against renewing repeatedly in one block to inflate it; // that whole class of problem disappeared when the streak became a // function of the registration date instead of a stored counter. See // streakOf in auth.gno. func advanceTerm(expires, now int64) int64 { // Anchoring to the previous due date is only safe while a renewal // cannot happen more than one term after it — i.e. while // gracePeriod <= termLength. That invariant held silently under the // old hardcoded constants and stopped holding the moment both became // settable: with a grace period longer than the term, a renewal paid // late in grace would return an expiry ALREADY IN THE PAST, leaving // the payer with a name that is still expired. Clamp forward so a // paid renewal always buys a live term, while keeping the anchor // behaviour in every normal case. // Saturate rather than wrap: renewing a far-future expiry used to // overflow and SHORTEN it to now + term (audit L-2). if expires > math.MaxInt64-termLength { return math.MaxInt64 } next := expires + termLength if next <= now { next = now + termLength } return next } func SetDomainFrozen(cur realm, label string, frozen bool) { assertIsTrustedLogic(cur) mustGetDomain(label).frozen = frozen emitBy(cur, "DomainFrozen", "label", label, "frozen", boolStr(frozen)) } func SetDomainExpiryRecord(cur realm, label string, expires int64) { assertIsTrustedLogic(cur) mustGetDomain(label).expires = expires emitBy(cur, "DomainExpirySet", "label", label, "expires", i64(expires)) } // SetDomainTTLRecord sets how long a resolver may cache answers about // this domain, in seconds, where 0 means never cache. See Domain.ttl. func SetDomainTTLRecord(cur realm, label string, ttl int64) error { assertIsTrustedLogic(cur) if ttl < 0 { return errStr("ttl may not be negative") } d, ok := getDomain(label) if !ok { return errStr("unknown domain") } d.ttl = ttl emitBy(cur, "DomainTTLSet", "label", label, "ttl", i64(ttl)) return nil } // SetDomainExtraRecord writes one arbitrary key on a domain — pricing, // or whatever a future logic realm needs. An empty value deletes the // key, so storage can be reclaimed. func SetDomainExtraRecord(cur realm, label, key, value string) error { assertIsTrustedLogic(cur) d, ok := getDomain(label) if !ok { return errStr("unknown domain") } if err := setExtra(&d.extra, key, value); err != nil { return err } emitBy(cur, "DomainExtraSet", "label", label, "key", key, "value", value) return nil } // DeleteDomainRecord destroys a domain and the names beneath it: it // burns every token, drops every reverse-resolution pointer into them, // removes the records, and finally removes the domain itself. // // WHY IT EXISTS. Retiring a domain after a brand complaint, recovering // the storage deposit on a domain that never sold, and clearing out fad // domains nobody renewed. A logic realm cannot provide any of this, // because the vault owns the trees and exposed no delete path — the only // Remove call for a domain lived inside RegisterDomainRecord as the // evict-and-replace step of re-registration. // // IT DELIBERATELY WORKS ON A DOMAIN WITH LIVE NAMES UNDER IT. The case // this exists for is a demand to remove a domain entirely, where waiting // for names to lapse is not an option. Compensating the holders is // handled off chain and is deliberately not a contract concern. // // WHERE THIS DEPARTS FROM THE SPEC, stated plainly because it is a real // difference: MAINNET.md §3d asks for all of it "in one call". It is // bounded instead, deleting at most maxDeletePerCall names per call and // reporting whether it finished. An unbounded loop over a tree that may // hold any number of names is a transaction that stops fitting in a // block once a domain gets popular — which would mean the domains most // likely to attract a complaint are exactly the ones that could never be // deleted, in a realm that can never be fixed. The caller repeats until // `done`; for any domain of realistic size that is one call, so the // no-preconditions requirement is preserved. What is NOT preserved is // atomicity: a multi-call deletion is visible half-done between calls. // Freeze the domain first if that matters, so nothing new lands under it // meanwhile. Not Pause(): a paused registry deletes nothing at all, since // this passes the same trust gate as every write. // // Calling it on a domain that does not exist is a no-op reporting done, // so a repeat loop terminates cleanly rather than panicking on the call // that follows the last one. func DeleteDomainRecord(cur realm, label string) (removed int64, done bool) { assertIsTrustedLogic(cur) d, ok := getDomain(label) if !ok { return 0, true } // Collect first, mutate second. Removing entries from the tree while // iterating it is undefined, and this loop exists precisely to remove // entries. start, end := domainRange(label) var keys []string names.Iterate(start, end, func(key string, v any) bool { if len(keys) >= maxDeletePerCall { return true } keys = append(keys, key) return false }) for _, key := range keys { v := names.Get(key) if v == nil { continue } n := v.(*Name) // The name label is whatever follows "domain*" in the storage // key — the record no longer stores its own label. nameLabel := key[len(label)+1:] tid := nameTokenID(nameLabel, label) _ = n owner := nsnft.OwnerOf(tid) clearPrimaryIfPointingAt(owner, tid) // Every name has a token, but a registry swapped in mid-migration // could meet a record whose token was already dealt with. Burning a // token that does not exist panics and would strand the domain // half-deleted, so ask first. if owner != "" { nsnft.Burn(cross(cur), tid) } names.Remove(key) removed++ } // Anything left means the cap was hit; the domain record has to stay // so the next call can still find its name span. more := false names.Iterate(start, end, func(key string, v any) bool { more = true return true }) if more { emitBy(cur, "DomainDeleted", "label", label, "removed", i64(removed), "done", "false") return removed, false } _ = d domains.Remove(label) emitBy(cur, "DomainDeleted", "label", label, "removed", i64(removed), "done", "true") return removed, true } // -- names -- /* RegisterNameRecord creates `label*domain` and mints its token in nsnft. `paid` false registers a FREE-TIER name: the token is minted with the free hold already set, so there is never an instant in which it could be approved and sold before a lock arrived. There is no longer any such thing as a name without a token — see Name. RECLAIM burns the previous holder's token first. The owner is read from the ledger, never from the record, because the record has no owner. AN ORPHAN TOKEN IS REFUSED. A token that exists with no record behind it means a registry swap is mid-migration or something has gone wrong; either way, registering over it would mint a second claim to a name somebody already holds. RestoreNameRecord is the door for adopting one. */ func RegisterNameRecord(cur realm, label, domainLabel string, owner address, now int64, paid bool) error { assertIsTrustedLogic(cur) if !owner.IsValid() { return errStr("invalid owner address") } if err := assertLabelKeySafe(label); err != nil { return err } d, ok := getDomain(domainLabel) if !ok { return errStr("unknown domain") } if d.frozen { return errStr("domain is frozen") } if isReclaimable(d.expires, now) { return errStr("domain has lapsed past its grace period") } treeKey := nameTreeKey(label, domainLabel) tid := nameTokenID(label, domainLabel) var reclaimedFrom address if n, ok := getName(label, domainLabel); ok { if n.frozen { return errStr("name is frozen") } if !isReclaimable(n.expires, now) { return errStr("name already registered and not yet reclaimable") } // Evict the previous holder completely. The reverse pointer goes // first: leaving it would make the old owner resolve to a name the // reclaimer now owns. prev := nsnft.OwnerOf(tid) reclaimedFrom = prev clearPrimaryIfPointingAt(prev, tid) if prev != "" { nsnft.Burn(cross(cur), tid) } names.Remove(treeKey) } else if nsnft.OwnerOf(tid) != "" { return errStr("a token for " + tid + " exists with no record behind it; restore it rather than registering over it") } nsnft.Mint(cross(cur), owner, tid, holdBits(paid, false)) names.Set(treeKey, &Name{ registered: now, expires: now + termLength, epoch: nsnft.Epoch(tid), since: now, extra: avl.Tree{}, }) setPrimaryIfNone(owner, tid) emitBy(cur, "NameRegistered", "tid", tid, "owner", owner.String(), "expires", i64(now+termLength), "paid", boolStr(paid), "reclaimedFrom", reclaimedFrom.String()) return nil } func holdBits(paid, frozen bool) int { b := 0 if !paid { b |= 1 << lockBitFree } if frozen { b |= 1 << lockBitFrozen } return b } /* setPrimaryIfNone gives an address a reverse pointer when it has none — or when the one it has points at a name it no longer holds. The second half is new. A pointer used to be cleared on every transfer, because transfers went through this realm. They no longer do: a sale that settles on nsnft leaves the seller's pointer aimed at a name they sold. nslogic.ResolveAddress already refuses to answer with it, so it never misleads anybody — but left in place it also blocked the seller's NEXT registration from becoming their primary, which would have looked like a bug in registration rather than a stale pointer. */ func setPrimaryIfNone(owner address, tid string) { k := owner.String() if v := primary.Get(k); v == nil || !ownsNow(owner, v.(string)) { primary.Set(k, tid) // Reverse resolution is the one record that publishes a wallet's // persona, so even the automatic pointer says so. chain.Emit("PrimarySet", "owner", k, "tid", tid, "auto", "true") } } /* UpgradeFreeRecord lifts the free-tier hold — the paid upgrade. Preserves the dates: this is not a re-registration. `newOwner` exists because the common case is somebody who decided to sell and, in the same moment, wants the name in a wallet they control rather than the key they were given. That move is a burn and a mint here, because a held token cannot be transferred by design. THE PROFILE DOES NOT GO WITH IT, and it used to. The move re-stamped the old data to the new epoch and gave the recipient a reverse pointer — and `newOwner` can be ANY address. An audit spelled out the attack: fill a free name's profile with your own pay addresses, pay list price to upgrade it into a victim's wallet, and the victim's name now asks people to pay you, their address reverse-resolves to it, and their own later edits do not clear it because the epoch is already current. So the data is left in the previous epoch, where every read hides it and the next write destroys it, and the recipient gets no pointer they never chose. A person moving their own name to a new wallet re-enters their profile once; that is the price of the move not being usable against somebody else. Refused for a frozen name. Upgrading must never be the way a disputed name gets out of its hold. */ func UpgradeFreeRecord(cur realm, label, domainLabel string, newOwner address) error { assertIsTrustedLogic(cur) if !newOwner.IsValid() { return errStr("invalid owner address") } n, ok := getName(label, domainLabel) if !ok { return errStr("unknown name") } if n.frozen { return errStr("name is frozen") } tid := nameTokenID(label, domainLabel) owner := nsnft.OwnerOf(tid) if owner == "" { return errStr("name has no token") } bits := nsnft.LockBits(tid) if bits&(1< maxAccountValueLen { return errStr("account value too long") } if !utf8.ValidString(value) { return errStr("account values must be valid UTF-8") } if accounts.Get(k) == nil && accountFields(owner) >= maxAccountEntries { return errStr("too many fields on this address") } accounts.Set(k, value) emitBy(cur, "AccountSet", "addr", owner.String(), "key", key, "value", value) return nil } // accountFields counts one address's fields, stopping at the cap: the // only question asked of it is whether the cap is reached, and an // unbounded walk is not something a write path should contain. func accountFields(owner address) int { n := 0 start, end := accountRange(owner) accounts.Iterate(start, end, func(key string, v any) bool { n++ return n >= maxAccountEntries }) return n } // assertAccountKey keeps field names short and free of the characters // that would make a snapshot listing ambiguous, and of ':', which // separates the address from the field. func assertAccountKey(key string) error { if key == "" || len(key) > maxAccountKeyLen { return errStr("bad account field") } if strings.ContainsAny(key, ",:\n\r") { return errStr("account field may not contain commas, colons or newlines") } return nil } // SetGlobalRecord writes one arbitrary package-level key — fee rates, // default prices, a custodian address, points balances, claim records. // Anything global that does not exist yet goes here instead of needing a // package variable that could never be added after deployment. // Keys the restore machinery owns. Written only by RestoreGlobal's own // path, never by a general setter — because nslogic's SetGlobal forwards // whatever key an admin passes, and one call writing "restore" reopened // a seal that restore.gno calls irreversible. Enforced HERE as well as // there, so a future logic realm cannot drop the guard by forgetting it. var reservedGlobals = []string{"restore", "restored"} func SetGlobalRecord(cur realm, key, value string) error { assertIsTrustedLogic(cur) for _, r := range reservedGlobals { if key == r { return errStr("that key belongs to the restore machinery and is not settable here") } } if err := setGlobal(key, value); err != nil { return err } emitBy(cur, "GlobalSet", "key", key, "value", value) return nil } // setGlobal is setExtra held to globals' own entry cap. func setGlobal(key, value string) error { return setExtraCapped(&globals, key, value, maxGlobalEntries) } // SetReservedGlobalRecord is the restore machinery's own door to the // keys above. Same trust gate; separate name, so a general-purpose // setter can never reach them by accident. func SetReservedGlobalRecord(cur realm, key, value string) error { assertIsTrustedLogic(cur) found := false for _, r := range reservedGlobals { if key == r { found = true } } if !found { return errStr("not a reserved key; use SetGlobalRecord") } // THE RESTORE DOOR MOVES ONE WAY, here as well as in the logic realm: // never opened, then opened once, then sealed — and from "sealed" // nowhere. The seal is the public promise that this registry's history // can no longer be rewritten on request, and "opened exactly once" is // what keeps a window from being closed quietly and opened again later; // a promise any trusted logic could undo with one write was only as // good as the newest logic generation. Re-writing "sealed" is harmless. if key == "restore" { if state := GetGlobal("restore"); state == "sealed" && value != "sealed" { return errStr("restore is sealed, and a seal does not come undone") } else if state != "" && value != "sealed" { return errStr("the restore window opens once; from here it can only be sealed") } } if err := setGlobal(key, value); err != nil { return err } emitBy(cur, "ReservedGlobalSet", "key", key, "value", value) return nil } // setExtra applies the shared bounds and the delete-on-empty rule to a // name's or a domain's extras. func setExtra(t *avl.Tree, key, value string) error { return setExtraCapped(t, key, value, maxExtraEntries) } func setExtraCapped(t *avl.Tree, key, value string, maxEntries int) error { // DELETING COMES FIRST, before any limit is checked: removing a key // frees storage and grants nothing, and with the key check in front a // single SetExtraLimits(1, ...) made every global undeletable — the // guardian's ClearKeeper, SetGranter(""), SetClaimWindow(0) included // (re-audit 2026-10-16, L-2). if value == "" { if key == "" { return errStr("bad extra key") } t.Remove(key) return nil } if err := assertExtraKey(key); err != nil { return err } // Bytes the snapshot can carry back. The vault used to store anything, // and a value with invalid UTF-8 came back from a snapshot three bytes // per bad byte, too long to replay (re-audit L-8). if !utf8.ValidString(key) || !utf8.ValidString(value) { return errStr("extra keys and values must be valid UTF-8") } if len(value) > maxExtraValueLen { return errStr("extra value too long") } if t.Get(key) == nil && t.Size() >= maxEntries { return errStr("too many extra entries") } t.Set(key, value) return nil } // assertExtraKey keeps extension keys free of the characters that would // make a snapshot ambiguous. Export lists keys as CSV, so a key // containing a comma or a newline would silently corrupt a backup — and // a backup you cannot trust is the one thing this realm cannot afford. func assertExtraKey(key string) error { if key == "" || len(key) > maxExtraKeyLen { return errStr("bad extra key") } if strings.ContainsAny(key, ",\n\r") { return errStr("extra key may not contain commas or newlines") } return nil } // assertLabelKeySafe is a STRUCTURAL check, not a policy one. Character // policy lives in the logic realm's tunable labelPattern; this is the // narrow set of characters that would break the vault's own machinery no // matter what policy is in force: // // "*" is the separator in both key encodings, so a label containing // one makes storage keys and token ids ambiguous; // "," and newlines would corrupt the CSV snapshot exports, which are // the disaster-recovery guarantee — assertExtraKey already refuses // them for extension keys, and labels become keys just as much; // "" cannot address a record at all. // // Enforcing it here rather than trusting the logic realm matters because // labelPattern is admin-tunable: loosening it must never be able to // silently corrupt a backup in a realm that cannot be redeployed. func assertLabelKeySafe(label string) error { if label == "" { return errStr("empty label") } if strings.ContainsAny(label, "*,\n\r") { return errStr("label may not contain '*', commas or newlines") } return nil } func errStr(s string) error { return errors.New(s) }