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.

garden.gno

24.68 Kb · 538 lines
  1// Package garden is Gnotchi's Phase 1 on-chain slice: the one signature the
  2// owner wants (CLAUDE.md / the build brief's Phase 1 note, "adoption is the
  3// one signature"). Adopt mints a soulbound GRC721 gnome for the caller; a
  4// read and a Render page let anyone, including the gnoweb UI, see what that
  5// signature produced.
  6//
  7// Grounded in gno.land/p/nt/grc721/v0's own reference consumer,
  8// examples/gno.land/r/demo/foo721 (NewToken/CallerTeller/wrapper shape), not
  9// written from memory -- the GRC721 API here (NewToken(name, symbol, id,
 10// rlm), RegisterExtension, the (_ int, rlm realm) Teller methods) is newer
 11// than the pre-crossing-call grc721 some training data remembers.
 12//
 13// What this realm deliberately does NOT have: Approve, SetApprovalForAll, a
 14// public TransferFrom, Mint/Burn admin endpoints, or any notion of more than
 15// one gnome per address. The brief's Part 1 says "what it needs, and nothing
 16// more" -- a soulbound pet has no legitimate caller for any of those, so
 17// exposing them would only be surface to audit for no gameplay reason.
 18// Transfer is still provable: it is exercised directly against the ledger
 19// in garden_test.gno, which is honest because the extension hook that
 20// refuses it (soulboundExtension.OnTransfer) runs inside grc721's transfer
 21// path itself, not inside a wrapper this realm would otherwise own.
 22//
 23// SECURITY: no code in this realm, now or later, may ever recover() around
 24// a call into ledger or token (grc721's PrivateLedger/Token). The soulbound
 25// guarantee below is real, but it is NOT that the ledger write never
 26// happens -- it does, before the panic -- it is that an *uncaught* panic
 27// aborts the whole enclosing transaction, so that write (and everything
 28// else the transaction did) is discarded and never committed on chain.
 29// p/nt/grc721/v0/types.gno says this outright, about extension hooks like
 30// soulboundExtension below: "Hooks run after the ledger write so they
 31// observe the post-movement state; only a caller that recovers from the
 32// panic keeps that write." A recover() anywhere between a ledger call and
 33// the transaction boundary -- even one added for an unrelated reason, even
 34// one several call-frames away -- would silently convert every refused
 35// transfer into a permanent, successful one. Don't add one. This is not
 36// limited to code textually inside this file: a pure `p/` package this realm
 37// calls runs in garden's own call frame too, so a helper as innocuous as
 38// `Try(func(){ ledger.TransferFrom(...) })` imported from some future `p/`
 39// dependency would break the same guarantee just as surely as a recover()
 40// written here directly.
 41package garden
 42
 43import (
 44	"strconv"
 45	"time"
 46	"unicode"
 47	"unicode/utf8"
 48
 49	"gno.land/p/nt/avl/v0"
 50	"gno.land/p/nt/grc721/v0"
 51	"gno.land/p/nt/seqid/v0"
 52	"gno.land/p/nt/ufmt/v0"
 53
 54	"chain"
 55	"chain/runtime/unsafe"
 56)
 57
 58const (
 59	collectionName   = "Gnotchi gnomes"
 60	collectionSymbol = "GNOME"
 61
 62	// maxNameLen is this realm's own choice, but it is no longer the only 24 in this
 63	// codebase: app/js/config.js's NAME_MAX_LEN and, through it, app/js/adopt.js's name
 64	// field both already hard-code the same limit (the client-side pre-flight check, so a
 65	// player finds out before paying gas rather than after -- see config.js's own
 66	// comment). These two 24s are kept in step by hand, not generated from one another, so
 67	// if this number ever changes, app/js/config.js's NAME_MAX_LEN must change with it or
 68	// the client and the realm will silently disagree about what a valid name is. 24 runes
 69	// comfortably fits every pixel-art name card in pack/03-screens while keeping stored
 70	// state small.
 71	maxNameLen = 24
 72)
 73
 74// validHats/validCoats match app/js/sprite.js's HAT_COLOURS/COAT_COLOURS
 75// exactly (brief Part 1: "see app/js/config.js" -- the four-and-four list
 76// itself lives in sprite.js, which config.js's own comments point to).
 77var validHats = map[string]bool{
 78	"red":    true,
 79	"purple": true,
 80	"green":  true,
 81	"navy":   true,
 82}
 83
 84var validCoats = map[string]bool{
 85	"blue":   true,
 86	"teal":   true,
 87	"orange": true,
 88	"brown":  true,
 89}
 90
 91// gnome is this realm's own per-token record. grc721's ledger already owns
 92// identity, ownership and the soulbound guarantee; this just carries the
 93// pet's display fields that grc721 has no concept of.
 94type gnome struct {
 95	name      string
 96	hat       string
 97	coat      string
 98	level     int64
 99	gnomeType string // empty until a later phase assigns one; mirrors app/js/live.js's adopt() stub ("type: null")
100	adoptedAt int64  // unix seconds, time.Now().Unix() at Adopt
101}
102
103var (
104	token  *grc721.Token
105	ledger *grc721.PrivateLedger
106
107	nextID seqid.ID
108
109	// gnomes: TokenID string -> *gnome. The grc721 ledger already refuses a
110	// duplicate TokenID, so this is purely this realm's metadata store.
111	gnomes = avl.NewTree()
112
113	// ownerIndex: address string -> TokenID string. grc721/v0 has no
114	// "tokens by owner" enumeration built in (that is the separate
115	// enumerable extension, not used here), and the brief asks for "a read
116	// for one address's gnome", so this realm keeps the one index it needs.
117	ownerIndex = avl.NewTree()
118)
119
120func init(cur realm) {
121	token, ledger = grc721.NewToken(collectionName, collectionSymbol, 0, cur)
122	ledger.RegisterExtension(&soulboundExtension{})
123}
124
125// soulboundExtension is rule 6 ("every transfer fails") made structural
126// rather than a convention a future wrapper could forget to enforce: it is
127// grc721's own Extension hook, registered once at init.
128//
129// What actually happens on a refused transfer, read directly from
130// p/nt/grc721/v0/token.gno: PrivateLedger.transfer() (308-344) first writes
131// setBalance(from), setBalance(to) and owners.Set(tid, to) (328-330), and
132// only *after* those writes does it run the registered extensions'
133// OnTransfer (333). So this hook's panic does not roll back anything inside
134// the transfer path -- by the time it fires, the reassignment has already
135// been written. The guarantee this realm actually relies on is the
136// transaction boundary, not hook ordering or crossing-call status:
137// PrivateLedger.TransferFrom is in fact a plain method, not a crossing call
138// -- but not for the reason an earlier pass of this comment cited.
139// reference/grc721-v0/tellers.gno:130's "The leading int keeps this a plain
140// method" is about fnTeller.guardHome, a different method entirely (it
141// guards the frame-relative CallerTeller/RealmTeller wrappers, not the
142// ledger). The actual reason is simpler: reference/grc721-v0/token.gno:235
143// declares PrivateLedger.TransferFrom(spender, from, to address, tid
144// TokenID) error with no realm parameter anywhere in its signature -- and a
145// Gno function is only ever reachable as a crossing call (via cross(...))
146// when it takes one. No realm parameter, no crossing call, full stop;
147// garden_test.gno's own TestTransferAlwaysFails already gets this right (see
148// its comment). But the conclusion holds regardless, and unconditionally,
149// not because of crossing: an uncaught panic anywhere inside the current
150// transaction's call stack -- crossing call or plain method, this frame or
151// any callee's -- aborts the *entire enclosing transaction*, and gno.land
152// only ever commits a transaction's writes atomically -- if the panic is
153// never recovered, the ledger write a few lines earlier is discarded along
154// with everything else that transaction did, and nothing is ever observed on
155// chain. See this file's package-level SECURITY note: the only way to break
156// that guarantee is a recover() upstream of the panic, which is exactly why
157// this realm has none.
158//
159// TransferFrom's plainness is exactly why that SECURITY warning is a live
160// concern here rather than a moot one. Were TransferFrom (or anything it
161// calls) instead reached across a realm boundary, a panic unwinding back out
162// across that boundary would not be something a recover() placed in this
163// realm's own frame could ever catch -- there would be nothing upstream of
164// the boundary to protect, and the warning would have no real case to guard
165// against. Because it is a plain method, the panic instead propagates up
166// through this realm's own ordinary, unbroken Go call stack the entire way
167// to the transaction boundary -- which is exactly the shape of panic
168// recover() *can* catch, from anywhere in that stack, including a
169// recover() several frames away that was added for an unrelated reason. That
170// is precisely the danger the SECURITY note names, and TransferFrom being
171// plain is why it applies here at all.
172//
173// OnMint is intentionally a no-op: Adopt's own Mint call is how a
174// gnome is allowed to come into existence at all, so minting is not
175// something this hook should ever veto.
176type soulboundExtension struct{}
177
178func (soulboundExtension) ExtensionKind() string { return "soulbound" }
179
180func (soulboundExtension) OnMint(to address, tid grc721.TokenID) {}
181
182func (soulboundExtension) OnTransfer(from, to address, tid grc721.TokenID) {
183	panic("garden: gnomes are soulbound; transfer is permanently disabled")
184}
185
186func (soulboundExtension) OnBurn(tid grc721.TokenID) {}
187
188// Adopt mints a soulbound gnome for the caller. One gnome per address: a
189// second Adopt from the same address panics rather than minting a second
190// token, since grc721 has no per-address cap of its own. The caller is
191// cur.Previous().Address() -- the account that crossed into this realm --
192// matching examples/gno.land/r/demo/foo721's own admin-check idiom for
193// "who actually sent this transaction", not cur.Address() (which would be
194// this realm's own address).
195func Adopt(cur realm, name, hat, coat string) {
196	// Adopt is a public crossing function; nothing stops a direct caller
197	// (a plain `maketx call -send` MsgCall) from attaching GNOT to it. This
198	// realm has no banker and deliberately no withdrawal path (rule 5: no
199	// funds handled, no surface to audit for a non-existent feature), so
200	// coins attached that way would be stranded forever with no way for
201	// anyone, including the owner, to move them back out. Refuse that case
202	// outright rather than silently accepting and stranding the attachment.
203	// unsafe.OriginSend() (chain/runtime/unsafe, not a plain chain.*
204	// function -- see that package's doc for why it's quarantined there)
205	// is the *origin* send: the coins attached to the outermost transaction
206	// message, not to this call's own frame. IsZero() is true both for no
207	// Coins at all and for a Coins set whose entries are all zero-amount.
208	//
209	// Two honest limits on what this guard actually buys, found while
210	// re-checking it: it is origin-scoped, not frame-scoped, so it reads
211	// the whole transaction's send regardless of how many realms it crossed
212	// through to get here. A future flow where some other realm (e.g. a
213	// sponsor realm) takes its own fee off the top and then crosses into
214	// Adopt with the remainder, or with nothing, would still see the
215	// origin's full original send and could be rejected even though none of
216	// that money would ever reach, or be stranded at, this realm's address.
217	// And it does not make stranding impossible in general: nothing about
218	// this check stops a plain bank send, or another realm's own
219	// `banker.SendCoins`, from depositing coins at garden's address
220	// directly, with no call to Adopt (or any other garden function)
221	// involved at all -- OriginSend only ever observes money attached to
222	// *this* call. The guard below is still worth having for the one case
223	// it does close cleanly: a direct `maketx call -send` straight into
224	// Adopt. See NOTES.md for how this interacts with the realm-as-caller
225	// custody question already recorded there.
226	if !unsafe.OriginSend().IsZero() {
227		panic("garden: adopt accepts no coins; this realm has no banker and cannot return them")
228	}
229
230	caller := cur.Previous().Address()
231	if !caller.IsValid() {
232		panic("garden: adopt requires a valid caller address")
233	}
234
235	if alreadyAdopted(caller) {
236		panic("garden: this address has already adopted a gnome")
237	}
238
239	if !validNameLength(name) {
240		panic("garden: name must be 1-" + strconv.Itoa(maxNameLen) + " characters")
241	}
242
243	if !validNameCharset(name) {
244		panic("garden: name contains a character that is not an allowed letter, digit, space, hyphen or apostrophe")
245	}
246
247	if !hasVisibleNameContent(name) {
248		panic("garden: name must contain at least one visible character")
249	}
250
251	if !validHats[hat] {
252		panic("garden: invalid hat colour")
253	}
254
255	if !validCoats[coat] {
256		panic("garden: invalid coat colour")
257	}
258
259	id := nextID.Next()
260	tid := grc721.TokenID(id.String())
261
262	if err := ledger.Mint(caller, tid); err != nil {
263		// Unreachable in practice: nextID is monotonic and private, so the
264		// TokenID this builds has never been minted before. Kept as a
265		// checked error rather than ignored, per grc721's own Mint contract.
266		panic(err)
267	}
268
269	gnomes.Set(tid.String(), &gnome{
270		name:      name,
271		hat:       hat,
272		coat:      coat,
273		level:     1,
274		gnomeType: "",
275		adoptedAt: time.Now().Unix(),
276	})
277	ownerIndex.Set(caller.String(), tid.String())
278
279	chain.Emit(
280		"Adopt",
281		"owner", caller.String(),
282		"tokenId", tid.String(),
283		"name", name,
284		"hat", hat,
285		"coat", coat,
286	)
287}
288
289// alreadyAdopted consults two sources, not one, and they are not the same
290// thing: ownerIndex is this realm's own record, written once by Adopt and
291// never deleted (there is no burn), so a hit there is permanent and can
292// never regress -- it is the stronger, fail-closed source. token.BalanceOf
293// consults grc721's ledger, which today only agrees with ownerIndex because
294// this realm's own code keeps them in lockstep (no bypass path exists, per
295// the audit's reading of p/nt/grc721/v0 -- Mint/Burn/transfer are the only
296// writers and TransferFrom routes through transfer); nothing in grc721
297// itself promises that agreement will hold under a future change. Checking
298// only the balance, as before, meant a bug or future extension that changed
299// grc721's balance bookkeeping without a matching change here could let a
300// second Adopt through while ownerIndex still correctly remembered the
301// first gnome. Checking both means either source alone is enough to
302// refuse.
303func alreadyAdopted(addr address) bool {
304	if ownerIndex.Has(addr.String()) {
305		return true
306	}
307
308	balance, err := token.BalanceOf(addr)
309	if err != nil {
310		// BalanceOf's only documented error (p/nt/grc721/v0/token.gno) is
311		// ErrInvalidAddress, and Adopt already panics on an invalid caller
312		// before this is ever called -- so reaching here with a non-nil err
313		// is not a reachable "someone has no balance yet" case, it is
314		// something unexpected. The old `return false` failed *open*: it
315		// would let Adopt proceed to Mint for an address the ledger itself
316		// just refused to report a balance for. Panicking fails closed
317		// instead, matching ownerIndex's own fail-closed property above.
318		panic(err)
319	}
320	return balance > 0
321}
322
323// validNameLength is this realm's length rule (1-maxNameLen characters),
324// checked and reported on its own: Adopt gives a length failure and a
325// charset failure two different messages (see Adopt), because they are two
326// different mistakes for a player to have made and "characters" is the
327// wrong word for "that character isn't on the list". utf8.RuneCountInString,
328// not len/byte length: len(name) counts bytes, so a name full of multi-byte
329// runes could pack several times maxNameLen's intended character budget into
330// the stored string while still reading as "short" by byte count. This is
331// deliberately independent of validNameCharset below -- checking length
332// first and separately means a name that is both over-long and full of
333// hostile runes still gets the length message, not a charset message that
334// would obscure it, and vice versa.
335func validNameLength(name string) bool {
336	n := utf8.RuneCountInString(name)
337	return n > 0 && n <= maxNameLen
338}
339
340// validNameCharset reports whether every rune in name passes validNameRune.
341// Split out from length (above) and from hasVisibleNameContent (below) so
342// each has its own panic message in Adopt -- see that function's comment for
343// why conflating them once made
344// TestAdoptRejectsNamesWithMarkdownZeroWidthAndBidiCharacters weaker than it
345// looked.
346func validNameCharset(name string) bool {
347	for _, r := range name {
348		if !validNameRune(r) {
349			return false
350		}
351	}
352	return true
353}
354
355// hasVisibleNameContent reports whether name has at least one rune that is
356// not a plain space. validNameRune's allowlist admits space so that
357// multi-word names ("Mary Jane") and the hyphen/apostrophe punctuation
358// pack/03-screens' name cards use are not blocked -- but that same allowance
359// means a name of nothing but spaces passes validNameCharset while still
360// rendering as nothing at all. There is no rename, no burn and no admin
361// anywhere in this realm (see the package doc), so a name that gets past
362// Adopt is on chain permanently; a blank one would be permanently blank.
363func hasVisibleNameContent(name string) bool {
364	for _, r := range name {
365		if r != ' ' {
366			return true
367		}
368	}
369	return false
370}
371
372// invisibleLetterRunes is a denylist layered on top of the allowlist below,
373// not a replacement for it: these are specific code points that
374// unicode.IsLetter reports true for (Unicode general category Lo, "letter,
375// other") despite having no visible glyph at all in any tested renderer.
376//
377// This list is provably the complete set, not just the four this audit
378// happened to find by inspection. An exhaustive scan of every Unicode code
379// point, checking "is this both default-ignorable and (a letter or a
380// digit)", returns exactly these four and no others:
381//
382//	U+115F, U+1160, U+3164, U+FFA0 — total: 4
383//
384// The reason a scan is even conclusive here, rather than just a larger
385// sample, is Unicode's own general-category partition: every code point
386// belongs to exactly one general category, and the categories that carry
387// Unicode's "default ignorable" property -- Cf (format), Mn (nonspacing
388// mark), Zs (space separator), and the rest of the ignorable set -- are, by
389// that same partition, definitionally disjoint from the letter categories
390// (Lu/Ll/Lt/Lm/Lo) and the digit category (Nd) this allowlist admits. A code
391// point cannot be in Cf and Lo at once. So the only way a default-ignorable
392// code point can also be "a letter or a digit" by this realm's own test is
393// to sit in the one sliver where a letter category and the ignorable
394// property happen to overlap -- and the scan above is what that sliver
395// actually contains: four Lo code points, all Hangul filler characters, and
396// nothing else. That is not a sampling result that could have come out
397// differently with more code points checked; it is the whole space.
398//
399// If the toolchain's Unicode/CLDR version ever moves, re-run the scan
400// (unicode.IsLetter/IsDigit plus a default-ignorable table, iterated over
401// every code point up to unicode.MaxRune) rather than assuming this list is
402// still complete -- a future Unicode version could in principle assign the
403// default-ignorable property to a new Lo or Nd code point this list does not
404// yet know about.
405var invisibleLetterRunes = map[rune]bool{
406	'\u115f': true, // HANGUL CHOSEONG FILLER
407	'\u1160': true, // HANGUL JUNGSEONG FILLER
408	'\u3164': true, // HANGUL FILLER
409	'\uffa0': true, // HALFWIDTH HANGUL FILLER
410}
411
412// validNameRune is an allowlist, not a denylist, of what a single rune in a
413// gnome name may be. That choice matters because of what happens to a name
414// once Adopt accepts it: there is no admin, no burn and no rename anywhere
415// in this realm, so a name that gets past this check is on chain
416// permanently and unremovable, and it goes straight into Render's page
417// heading as a raw markdown "# " line with no further escaping (garden.gno,
418// Render). The old check only rejected ASCII control characters and
419// DEL (0x7f), which let through: markdown metacharacters (backtick,
420// asterisk, underscore, square/round brackets, '#', '|', '>', '\', '<')
421// that can break out of the heading or inject structure into any future
422// page that lists several names together (a leaderboard, a neighbour list);
423// zero-width characters (e.g. U+200B ZERO WIDTH SPACE) that make two
424// visually-identical names byte-distinct; and bidi override/isolate
425// controls (e.g. U+202E RIGHT-TO-LEFT OVERRIDE) that can make a rendered
426// name display as something other than what it actually stores. All of
427// those live in Unicode's "format" (Cf) general category, which this
428// allowlist already excludes outright: nothing in Cf is a letter, a digit,
429// space, hyphen or apostrophe.
430//
431// What this allowlist does guarantee: every accepted rune is an
432// alphanumeric character (by Unicode's own classification) or one of the
433// three punctuation runes named below, and no Cf format/control code point
434// of any kind -- known today or standardised tomorrow -- can ever pass.
435//
436// What it does NOT guarantee, stated here rather than implied away:
437// invisibleLetterRunes above is proven complete against the Unicode version
438// this toolchain ships today (see that var's own comment for the scan and
439// why the general-category partition makes it exhaustive, not just a
440// sample) -- but that completeness is tied to today's Unicode version. A
441// future Unicode update that assigned a new invisible code point to the
442// letter or digit categories would need the scan re-run and the map updated;
443// nothing in this realm detects that automatically.
444// And, separately and by deliberate choice, this allowlist does nothing at
445// all about cross-script homoglyphs -- Cyrillic "А", Greek "Ο", fullwidth
446// "A" are ordinary letters by Unicode's own definition and this check lets
447// every one of them through. That is accepted, not closed: gnome names are
448// not identifiers here (nothing requires one to be unique, nothing uses a
449// name as a lookup key), so two visually-confusable names existing side by
450// side is a cosmetic curiosity, not a security property this realm relies
451// on. A future feature that did start treating names as identifiers would
452// need to revisit this.
453func validNameRune(r rune) bool {
454	switch r {
455	case ' ', '-', '\'':
456		return true
457	}
458	if invisibleLetterRunes[r] {
459		return false
460	}
461	return unicode.IsLetter(r) || unicode.IsDigit(r)
462}
463
464// gnomeForAddr resolves addr's gnome via ownerIndex, or reports found=false.
465func gnomeForAddr(addr address) (tid string, g *gnome, found bool) {
466	v := ownerIndex.Get(addr.String())
467	if v == nil {
468		return "", nil, false
469	}
470	tid = v.(string)
471	gv := gnomes.Get(tid)
472	if gv == nil {
473		// Should not happen: the two trees are only ever written together in
474		// Adopt. Treated as "not found" rather than panicking a read.
475		return "", nil, false
476	}
477	return tid, gv.(*gnome), true
478}
479
480// GnomeByAddress is the brief's "read for one address's gnome". found=false
481// (with every other field zero) when addr has not adopted.
482func GnomeByAddress(addr address) (tokenID, name, hat, coat string, level int64, gnomeType string, adoptedAtUnix int64, found bool) {
483	tid, g, ok := gnomeForAddr(addr)
484	if !ok {
485		return "", "", "", "", 0, "", 0, false
486	}
487	return tid, g.name, g.hat, g.coat, g.level, g.gnomeType, g.adoptedAt, true
488}
489
490// Render implements the realm's gnoweb page (brief Part 1: "a public gnoweb
491// page: name, level, type, look"). path == "" is the collection overview;
492// any other path is read as a bech32 owner address.
493func Render(path string) string {
494	if path == "" {
495		return ufmt.Sprintf(
496			"# %s\n\nSoulbound gnome collection. Every gnome is non-transferable: see rule 6.\n\n* **Total adopted**: %d\n",
497			collectionName, token.TotalSupply(),
498		)
499	}
500
501	// address(path) is an unchecked conversion -- in Gno, any string is a
502	// syntactically legal `address` value, so this alone does not confirm
503	// path looks anything like a real bech32 address. The code below used
504	// to find that out the hard way, via gnomeForAddr/ownerIndex.Get
505	// returning nil -- by which point path had already been queued up to be
506	// echoed straight into the page inside backticks. A backtick in path
507	// (or any other markdown metacharacter) would close that code span and
508	// let the rest of path inject arbitrary content into gnoweb's rendered
509	// output. Reject before anything derived from path is echoed anywhere:
510	// addr.IsValid() bech32-decodes the string for real (same check Adopt
511	// already relies on for its caller), so every addr.String() reached
512	// below is a real, bech32-shaped address that cannot contain a
513	// backtick or any other markdown metacharacter -- path itself is never
514	// echoed again past this point.
515	addr := address(path)
516	if !addr.IsValid() {
517		return ufmt.Sprintf("# %s\n\nNo gnome adopted for an invalid address.\n", collectionName)
518	}
519
520	tid, g, ok := gnomeForAddr(addr)
521	if !ok {
522		return ufmt.Sprintf("# %s\n\nNo gnome adopted yet for `%s`.\n", collectionName, addr.String())
523	}
524
525	gnomeType := g.gnomeType
526	if gnomeType == "" {
527		gnomeType = "(not yet set)"
528	}
529
530	str := ufmt.Sprintf("# %s\n\n", g.name)
531	str += ufmt.Sprintf("* **Owner**: %s\n", addr.String())
532	str += ufmt.Sprintf("* **Token ID**: %s\n", tid)
533	str += ufmt.Sprintf("* **Level**: %d\n", g.level)
534	str += ufmt.Sprintf("* **Type**: %s\n", gnomeType)
535	str += ufmt.Sprintf("* **Look**: %s hat, %s coat\n", g.hat, g.coat)
536
537	return str
538}