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}