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}