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.

nsnft.gno

22.32 Kb · 609 lines
  1// Package nsnft is the token, and only the token.
  2//
  3// WHY THIS REALM EXISTS AT ALL.
  4//
  5// A name here is a GRC-721. An external marketplace that wants to list
  6// one has to hard-code an address, and whatever address that is becomes
  7// the thing we can never move. Until now the interface was split: the
  8// READS lived on nsdata, which is permanent, and the WRITES — Approve,
  9// SetApprovalForAll, TransferFrom — lived on nslogic, which changed
 10// address three times in a single day. So the half an integrator needs
 11// in order to settle a sale was the half that kept moving, and the reads
 12// kept answering while it did, which is the worst way for it to break.
 13//
 14// The fix is to pick ONE realm to freeze and make it the smallest, most
 15// boring one there is. That is this realm: an ownership ledger, the
 16// approval bookkeeping GRC-721 requires, the governance that decides who
 17// may mint, and nothing else. No prices, no expiry, no profiles, no
 18// policy — none of the things that will certainly need changing.
 19//
 20// Everything with an opinion in it stays in the registry, which becomes
 21// replaceable BECAUSE ownership no longer lives there. A migration then
 22// moves records, not tokens: nobody's holdings are stranded and no
 23// listing anywhere goes stale, because the token contract never moved.
 24//
 25// THE LEDGER IS THE CHAIN'S OWN gno.land/p/nt/grc721/v0, NOT A COPY.
 26//
 27// chain.Emit stamps the package whose code emits, and wallets and
 28// indexers (Adena, the Onbloc indexer) accept only Transfer and NewToken
 29// events stamped gno.land/p/nt/grc721/v0. The first version of this realm
 30// minted on a vendored GRC-721 deployed under our own p/ path, so every
 31// event it would ever have emitted carried OUR path, and no wallet would
 32// ever have listed a single name — permanently, because events cannot be
 33// re-emitted and this realm cannot be replaced. The ledger object lives
 34// here, created once in init; only this realm holds its PrivateLedger,
 35// and no Teller is ever handed out (see the note on holds below).
 36//
 37// THIS REALM HAS ITS OWN GOVERNANCE, AND THAT IS THE POINT.
 38//
 39// It would be shorter to ask nsdata who the admin is. It would also
 40// re-couple this realm to the exact thing it exists to let us replace —
 41// nsnft would then depend on one nsdata forever and we would have moved
 42// the problem rather than solved it. So roles, trust and the timelock are
 43// local (governance.gno), and nsdata is not imported.
 44//
 45// WHAT MAY BE CHANGED LATER AND WHAT MAY NOT.
 46//
 47// May, slowly: which registries are trusted, the roles, the timelock,
 48// and which view realm draws the art. May, instantly: withdrawing a
 49// registry's trust or the art, which only ever removes a power. Through a
 50// trusted registry only: the hold bits on a token. May not: anything
 51// else. If this realm ever needs a new function, the design has failed
 52// and the honest answer is that it was not small enough to freeze.
 53package nsnft
 54
 55import (
 56	"chain"
 57	"chain/runtime/unsafe" // OriginSend only; this realm never moves coins
 58	"time"
 59
 60	"gno.land/p/nt/avl/v0"
 61	"gno.land/p/nt/grc721/v0"
 62	"gno.land/p/nt/seqid/v0"
 63)
 64
 65const (
 66	collectionName   = "Meme Name Service"
 67	collectionSymbol = "MNS"
 68
 69	/* THE REGISTRY AND THE VIEW THIS LEDGER IS BORN WITH.
 70
 71	   Placeholders, exactly like an import path: the deploy tooling
 72	   (tools/deploy-gen.sh, tools/bundle-sources.py) rewrites every
 73	   quoted gno.land/r/demo/<realm> string to the generation's real path,
 74	   so the ledger is born trusting the one registry that is deployed
 75	   beside it — and no wallet is ever trusted, not even for the first
 76	   wiring. The previous version trusted the deployer's key at birth and
 77	   relied on somebody remembering to untrust it.
 78
 79	   Fail-safe if the rewrite is ever skipped: nobody can deploy under
 80	   gno.land/r/demo on a public chain, so an unrewritten placeholder
 81	   trusts an address no transaction can come from. */
 82	genesisRegistry = "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
 83	genesisView     = "gno.land/r/nym-mnsrehearsal001/nsview/v1"
 84)
 85
 86var (
 87	// Created once, in init, by the chain's own GRC-721. token is the
 88	// read side; ledger is the write side and never leaves this realm.
 89	token  *grc721.Token
 90	ledger *grc721.PrivateLedger
 91
 92	/* THE REGISTRIES PERMITTED TO MINT AND BURN: registry address -> the
 93	   package path it was trusted as. A set rather than a single value so
 94	   a migration can have the old and the new registry both live for the
 95	   window in which records are moving. An avl.Tree, so it is both a
 96	   lookup and an enumeration — whoever can mint names is the most
 97	   important fact about this contract, and it must be legible without
 98	   an indexer — and so a write touches one node rather than the whole
 99	   set. */
100	trusted avl.Tree
101
102	/* HOLDS, set by a trusted registry and never interpreted here.
103
104	   A small bitmask per token (tid -> int). While any bit is set the
105	   token cannot be approved or transferred — by its owner, by an
106	   operator, by any marketplace — through any path in this realm.
107
108	   WHY THE FROZEN REALM NEEDED THIS. The registry has two reasons to
109	   hold a token in place: a free-tier name that has not been paid for
110	   must not be sellable, and a name frozen by an admin during a
111	   dispute must not be moved. Both used to be enforced in nslogic's
112	   transfer wrappers — which only worked while nslogic was the only
113	   way to transfer. The point of this realm is that it is NOT: any
114	   GRC-721 marketplace calls TransferFrom here directly. A rule that
115	   lives anywhere else is a rule an external marketplace skips.
116
117	   WHY BITS AND NOT NAMED REASONS. What a bit MEANS is policy, and
118	   policy changes; this realm cannot. So it stores numbers and the
119	   registry owns the vocabulary. Separate bits, not one flag, so that
120	   unfreezing a free-tier name cannot accidentally make it sellable.
121
122	   Absent key = no hold. Burn clears it. */
123	locks avl.Tree
124
125	/* THE OWNERSHIP EPOCH (tid -> int64). Moves on every mint and every
126	   transfer.
127
128	   The registry stamps a name's profile data with the epoch it was
129	   written in, and treats data from any other epoch as belonging to a
130	   previous holder. That is how a sale that settles HERE — on a
131	   marketplace the registry never hears from — still stops the
132	   seller's payment addresses from answering for the buyer's name.
133
134	   AN EPOCH, NOT AN OWNER ADDRESS, because a name can go A to B and
135	   back to A. Compared by address, A's data from before the sale would
136	   come back to life — including a pay address A may have abandoned.
137
138	   Drawn from one global counter rather than counted per token, so a
139	   token burned and minted again can never repeat an epoch it had in
140	   a previous life.
141
142	   avl.Tree and not a map: a Go map is persisted as ONE object, so
143	   every mint and transfer re-wrote every token's epoch — measured at
144	   ~784 gas per existing token per write, growing forever, in the one
145	   realm that could never be fixed. A tree write touches a path. */
146	epochs  avl.Tree
147	lastSeq int64
148)
149
150// Bits a lock value may use. Eight is plenty for a registry and keeps a
151// caller from storing arbitrary integers in a realm that cannot be
152// patched to reject them later.
153const maxLockBit = 7
154
155// validLockBits is the range check, alone, so it can be tested directly.
156func validLockBits(b int) bool { return b >= 0 && b < 1<<(maxLockBit+1) }
157
158// validLockBit checks a bit INDEX, for SetLock. Separate from
159// validLockBits, which checks a whole mask, because confusing the two is
160// exactly the bug SetLock shipped with for one commit.
161func validLockBit(bit int) bool { return bit >= 0 && bit <= maxLockBit }
162
163func lockBitsOf(tid string) int {
164	if v := locks.Get(tid); v != nil {
165		return v.(int)
166	}
167	return 0
168}
169
170func bump(tid string) {
171	lastSeq++
172	epochs.Set(tid, lastSeq)
173}
174
175/*
176THE HOLDS AND THE EPOCH LIVE IN THE LEDGER'S OWN HOOKS.
177
178grc721/v0 calls an issuer-registered extension on every mint, transfer
179and burn, after the ledger write and before the event, and a panic in a
180hook aborts the movement. So the two rules this realm exists to keep are
181enforced at the one place EVERY movement passes through — not in the
182wrappers below, which merely repeat the hold check to give a clearer
183error first.
184
185Why that matters in a realm that never hands out a Teller: it makes the
186rules a property of the ledger rather than of whichever entry point a
187future reader adds or a marketplace finds. A transfer that reaches the
188ledger by any route moves the epoch, and a held token does not move.
189Approvals do not pass through a hook, so Approve checks the hold itself.
190
191The type is unexported and the only instance is attached to the private
192ledger, so no other realm can reach these methods.
193*/
194type holds struct{}
195
196func (holds) ExtensionKind() string { return "nsnft/holds" }
197
198func (holds) OnMint(to address, tid grc721.TokenID) {
199	// A fresh token starts unheld; Mint applies the bits it was asked for
200	// straight after. Burn already cleared any old hold, so this is the
201	// belt to that brace.
202	locks.Remove(string(tid))
203	bump(string(tid))
204}
205
206func (holds) OnTransfer(from, to address, tid grc721.TokenID) {
207	assertUnlocked(string(tid))
208	bump(string(tid))
209}
210
211func (holds) OnBurn(tid grc721.TokenID) {
212	// A hold on a token that no longer exists would silently apply to the
213	// NEXT token minted with this id — a reclaimed name arriving frozen.
214	locks.Remove(string(tid))
215	// The epoch goes too. The next mint draws a fresh one from lastSeq,
216	// which can never equal anything this token had before.
217	epochs.Remove(string(tid))
218}
219
220func init(cur realm) {
221	// Emits NewToken from gno.land/p/nt/grc721/v0; the collection id is
222	// "<this realm's path>.MNS.0000000", the shape wallets parse.
223	token, ledger = grc721.NewToken(collectionName, collectionSymbol, seqid.ID(0), cur)
224	ledger.RegisterExtension(holds{})
225
226	/* BOTH ROLES START ON THE DEPLOYER, AND ONLY IF IT IS REAL.
227
228	   The deployer hands recovery to a cold key and admin to a multisig
229	   as part of the deploy (tools/deploy-gen.sh), while the timelock is
230	   still zero; then the timelock goes up. An invalid deployer cannot
231	   happen on chain, and under `gno test` (which has none) it leaves
232	   both roles empty — and every role check below refuses an empty
233	   role, so nobody, not even a caller whose address stringifies to
234	   nothing, can act as an absent admin. */
235	if d := cur.Previous().Address(); d.IsValid() {
236		admin = d
237		recovery = d
238	}
239
240	reg := chain.PackageAddress(genesisRegistry)
241	trusted.Set(reg.String(), genesisRegistry)
242	chain.Emit("RegistryTrusted", "addr", reg.String(), "path", genesisRegistry)
243	rendererPath = genesisView
244	chain.Emit("RendererApproved", "path", genesisView)
245}
246
247/*
248COINS SENT HERE ARE DESTROYED, SO REFUSE THEM.
249
250This realm has no banker, no withdrawal and no sweep, and it can never
251gain one — that is the whole premise of freezing it. Anything attached to
252a call would sit at its address until the chain ends.
253
254Checked only for a direct user call: when another realm calls in, any
255coins the user attached went to THAT realm, not to this one, and
256refusing them here would break legitimate registry calls.
257*/
258func assertNoSend(cur realm) {
259	if !cur.Previous().IsUserCall() {
260		return
261	}
262	if !unsafe.OriginSend().IsZero() {
263		panic("nsnft: this realm takes no payment and cannot return it; do not attach coins")
264	}
265}
266
267/*
268assertTrusted is the whole security model for minting.
269
270A caller that can mint can invent a name, and a name is the product. So
271this is checked against the REALM address of the caller rather than an
272address it hands us — a registry proves what it is by being it. Only
273package paths can be trusted (governance.gno), so a wallet can never be
274in this set.
275*/
276func assertTrusted(caller address) {
277	if !caller.IsValid() || !trusted.Has(caller.String()) {
278		panic("nsnft: only a trusted registry may do that")
279	}
280}
281
282// ---------------------------------------------------------------------
283// mint and burn — registry only
284// ---------------------------------------------------------------------
285
286/*
287Mint creates a token, optionally already held in place.
288
289`lockBits` is the hold it starts with — a free-tier name is minted locked
290so there is never an instant in which it could be approved and sold
291before the registry got round to locking it (nothing runs between the
292ledger write and the line that sets the bits). Zero for an ordinary
293paid name.
294*/
295func Mint(cur realm, to address, tid string, lockBits int) {
296	assertNoSend(cur)
297	assertTrusted(cur.Previous().Address())
298	if !to.IsValid() {
299		panic("nsnft: invalid recipient")
300	}
301	if tid == "" {
302		panic("nsnft: empty token id")
303	}
304	if !validLockBits(lockBits) {
305		panic("nsnft: lock bits out of range")
306	}
307	if err := ledger.Mint(to, grc721.TokenID(tid)); err != nil {
308		panic("nsnft: " + err.Error())
309	}
310	if lockBits != 0 {
311		locks.Set(tid, lockBits)
312		chain.Emit("Locked", "tid", tid, "bits", itoa(int64(lockBits)))
313	}
314}
315
316/*
317Burn destroys a token. Reachable only by a trusted registry, which calls
318it when a name lapses past its grace period and is reclaimed.
319
320NOT reachable by the holder. A GRC-721 that lets an owner burn is
321correct for collectibles and wrong here: the token is a lease on a name,
322and destroying it would leave the registry holding a record for a name
323whose token no longer exists. Letting go of a name is letting it expire.
324
325The hold and the epoch are cleared by the burn hook (holds.OnBurn), the
326per-token approval by the ledger itself.
327*/
328func Burn(cur realm, tid string) {
329	assertNoSend(cur)
330	assertTrusted(cur.Previous().Address())
331	if err := ledger.Burn(grc721.TokenID(tid)); err != nil {
332		panic("nsnft: " + err.Error())
333	}
334}
335
336/*
337SetLock turns one hold bit on or off. Trusted registry only.
338
339The registry decides what a bit means; this realm only guarantees that
340while any bit is set, nothing moves the token.
341*/
342func SetLock(cur realm, tid string, bit int, on bool) {
343	assertNoSend(cur)
344	assertTrusted(cur.Previous().Address())
345	/* THE BIT IS RANGE-CHECKED BEFORE IT IS SHIFTED. An earlier check
346	   tested 1<<bit, and an audit caught what that does: a bit of 64 or
347	   more shifts to zero, passes, and the call quietly changes nothing
348	   while still emitting LockChanged. Unacceptable in the one realm
349	   that can never be patched. */
350	if !validLockBit(bit) {
351		panic("nsnft: lock bit out of range")
352	}
353	owner := OwnerOf(tid)
354	if owner == "" {
355		panic("nsnft: no such token")
356	}
357	v := lockBitsOf(tid)
358	if on {
359		v |= 1 << bit
360	} else {
361		v &^= 1 << bit
362	}
363	if v == 0 {
364		locks.Remove(tid)
365	} else {
366		locks.Set(tid, v)
367	}
368	/* A NEW HOLD WITHDRAWS THE STANDING APPROVAL. A hold stops an
369	   approval being USED, not the approval itself — so a name frozen
370	   mid-dispute kept whatever approval it had, and it became live again
371	   the moment the name was unfrozen. An audit caught it. The owner can
372	   re-approve afterwards; nobody else's pre-dispute permission
373	   survives. Operator grants are per owner, not per token, and are the
374	   owner's to revoke. Approving the zero address is grc721/v0's
375	   revoke, and it emits the standard Approval event. */
376	if on && GetApproved(tid) != "" {
377		if err := ledger.Approve(owner, "", grc721.TokenID(tid)); err != nil {
378			panic("nsnft: " + err.Error())
379		}
380	}
381	chain.Emit("LockChanged", "tid", tid, "bits", itoa(int64(v)))
382}
383
384func assertUnlocked(tid string) {
385	if lockBitsOf(tid) != 0 {
386		panic("nsnft: this token is held by the registry and cannot be approved or moved")
387	}
388}
389
390// LockBits reports the current hold; 0 means free to move.
391func LockBits(tid string) int { return lockBitsOf(tid) }
392
393func IsLocked(tid string) bool { return lockBitsOf(tid) != 0 }
394
395// Epoch reports the ownership epoch of a token, or 0 for one that does
396// not exist. See epochs.
397func Epoch(tid string) int64 {
398	if v := epochs.Get(tid); v != nil {
399		return v.(int64)
400	}
401	return 0
402}
403
404// ---------------------------------------------------------------------
405// GRC-721, public — this is the surface the outside world holds
406// ---------------------------------------------------------------------
407
408// Approve grants (or, to the empty address, withdraws) the per-token
409// approval. Refused while the token is held: approvals do not pass
410// through a ledger hook, so this is the one place the hold is checked.
411func Approve(cur realm, to address, tid string) {
412	assertNoSend(cur)
413	assertUnlocked(tid)
414	if err := ledger.Approve(cur.Previous().Address(), to, grc721.TokenID(tid)); err != nil {
415		panic("nsnft: " + err.Error())
416	}
417}
418
419/*
420RevokeApproval withdraws a token's per-token approval.
421
422grc721/v0 revokes by approving the empty address; this keeps the named
423entry point the site and tools already call, and allows it while the
424token is held — withdrawing a permission is the safe direction. The
425owner, or an operator acting for the owner, the same two callers Approve
426accepts.
427*/
428func RevokeApproval(cur realm, tid string) {
429	assertNoSend(cur)
430	if OwnerOf(tid) == "" {
431		panic("nsnft: no such token")
432	}
433	if err := ledger.Approve(cur.Previous().Address(), "", grc721.TokenID(tid)); err != nil {
434		panic("nsnft: only the owner or an operator may revoke an approval: " + err.Error())
435	}
436}
437
438func SetApprovalForAll(cur realm, operator address, approved bool) {
439	assertNoSend(cur)
440	if err := ledger.SetApprovalForAll(cur.Previous().Address(), operator, approved); err != nil {
441		panic("nsnft: " + err.Error())
442	}
443}
444
445/*
446TransferFrom carries NO expiry check, and that is a deliberate reversal
447of what nslogic once did.
448
449Refusing to transfer an expired token is an anti-scam measure, and it
450belongs in the REGISTRY, not here: this realm does not know what expiry
451is and must not learn, because the moment it holds policy it stops being
452the small frozen thing it exists to be. The registry keeps the rule by
453refusing to RESOLVE an expired name. A buyer can still acquire a
454worthless token from a careless seller, which is true of every NFT on
455every chain, and the marketplace disclaimers say so.
456
457The hold is checked here for a clear error and again by the ledger hook,
458which also moves the epoch.
459*/
460func TransferFrom(cur realm, from, to address, tid string) {
461	assertNoSend(cur)
462	assertUnlocked(tid)
463	if err := ledger.TransferFrom(cur.Previous().Address(), from, to, grc721.TokenID(tid)); err != nil {
464		panic("nsnft: " + err.Error())
465	}
466}
467
468// SafeTransferFrom is kept for the standard signature. grc721/v0 has no
469// receiver hook (gno has no contract-receiver registry to check), so it
470// is TransferFrom — as the vendored version effectively was, its
471// receiver check being a stub that always said yes.
472func SafeTransferFrom(cur realm, from, to address, tid string) {
473	assertNoSend(cur)
474	assertUnlocked(tid)
475	if err := ledger.TransferFrom(cur.Previous().Address(), from, to, grc721.TokenID(tid)); err != nil {
476		panic("nsnft: " + err.Error())
477	}
478}
479
480// ---------------------------------------------------------------------
481// reads
482// ---------------------------------------------------------------------
483
484/*
485OwnerOf answers the zero address for a token that does not exist, rather
486than panicking or returning an error.
487
488The registry calls this on EVERY resolve — that is the whole design, one
489truth rather than a cached copy that goes stale the moment a sale settles
490somewhere we did not watch. A panic on an unknown id would make every
491caller wrap it, and the wrapper that gets forgotten is the one that turns
492a missing name into a broken page. (nsdata and nsmarket are written
493against exactly this signature.)
494*/
495func OwnerOf(tid string) address {
496	owner, err := token.OwnerOf(grc721.TokenID(tid))
497	if err != nil {
498		return ""
499	}
500	return owner
501}
502
503func Exists(tid string) bool { return OwnerOf(tid) != "" }
504
505func BalanceOf(owner address) int64 {
506	n, err := token.BalanceOf(owner)
507	if err != nil {
508		return 0
509	}
510	return n
511}
512
513// GetApproved answers totally: the zero address means nobody is approved
514// or there is no such token.
515func GetApproved(tid string) address {
516	a, err := token.GetApproved(grc721.TokenID(tid))
517	if err != nil {
518		return ""
519	}
520	return a
521}
522
523func IsApprovedForAll(owner, operator address) bool {
524	return token.IsApprovedForAll(owner, operator)
525}
526
527func Name() string       { return token.GetName() }
528func Symbol() string     { return token.GetSymbol() }
529func GetName() string    { return token.GetName() }
530func GetSymbol() string  { return token.GetSymbol() }
531func TokenCount() int64  { return token.TotalSupply() }
532func TotalSupply() int64 { return token.TotalSupply() }
533
534// ID is the collection id wallets and indexers key on:
535// "<this realm's path>.MNS.0000000".
536func ID() string { return token.ID() }
537
538/*
539Render builds its string by concatenation rather than with ufmt.
540
541NO DEPENDENCIES THIS REALM DOES NOT ABSOLUTELY NEED. It is meant to be
542deployed once and never replaced, so every import is a permanent
543commitment to somebody else's package. grc721/v0 (with avl and seqid,
544which it already depends on) is here because it IS the thing this realm
545is; a formatter, for one page of text, is not worth that.
546*/
547func Render(path string) string {
548	out := "# Meme Name Service — tokens\n\n" +
549		"This realm is the ownership ledger and nothing else. Prices, expiry,\n" +
550		"profiles and every rule live in the registry.\n\n" +
551		"- collection: `" + token.ID() + "`\n" +
552		"- tokens: " + itoa(token.TotalSupply()) + "\n" +
553		"- admin: `" + admin.String() + "`\n" +
554		"- recovery: `" + recovery.String() + "`\n" +
555		"- guardian: `" + guardian.String() + "`\n" +
556		"- timelock: " + itoa(timelockDelay) + " s\n" +
557		"- renderer: `" + rendererPath + "`"
558	if renderer == nil {
559		out += " (not registered: wallets see the built-in card)"
560	}
561	out += "\n\n## Trusted registries\n\n"
562	trusted.Iterate("", "", func(a string, v any) bool {
563		out += "- `" + v.(string) + "` (`" + a + "`)\n"
564		return false
565	})
566	out += "\n## Pending governance\n\n"
567	n := 0
568	now := time.Now().Unix()
569	pending.Iterate("", "", func(k string, v any) bool {
570		op := v.(*pendingOp)
571		if now > op.until {
572			return false // void: lapsed unexecuted (re-audit 2026-10-16, I-6)
573		}
574		out += "- " + k + " -> `" + op.target + "`, executable " + itoa(op.at) +
575			" to " + itoa(op.until) + ", by `" + op.proposer.String() + "`\n"
576		n++
577		return false
578	})
579	if n == 0 {
580		out += "none\n"
581	}
582	out += "\nIntegrators: this address is stable. The registry behind it is not,\n" +
583		"and is meant not to be.\n"
584	return out
585}
586
587// itoa, because importing a package to print one number would outlast
588// every reason we had for wanting it.
589func itoa(n int64) string {
590	if n == 0 {
591		return "0"
592	}
593	neg := n < 0
594	// Work in uint64 so the most negative int64 does not overflow when
595	// its sign is flipped.
596	u := uint64(n)
597	if neg {
598		u = uint64(-(n + 1)) + 1
599	}
600	var b []byte
601	for u > 0 {
602		b = append([]byte{byte('0' + u%10)}, b...)
603		u /= 10
604	}
605	if neg {
606		return "-" + string(b)
607	}
608	return string(b)
609}