art.gno
5.68 Kb · 179 lines
1package nsnft
2
3import (
4 "chain"
5 "encoding/base64"
6 "strings"
7
8 "gno.land/p/nt/grc721/v0"
9)
10
11/*
12WHAT A WALLET SHOWS, AND WHY THIS FROZEN REALM DOES NOT DRAW IT.
13
14Adena puts TokenURI(tid) straight into an <img src>, so it must return
15the image itself (a data:image/svg+xml URI), and it reads TokenMetadata
16as a camelCase JSON string. Both have to be answered HERE, because the
17collection id names this realm. But the card is the most cosmetic and
18most-revised thing in the project, and this realm can never change — so
19the drawing is delegated to a view realm through a Renderer, and only
20the delegation is frozen.
21
22This realm cannot import the view: the view reads the registry, which
23reads this ledger, and imports may not form a cycle. So the view hands
24this realm an object at runtime instead. gno runs a method declared in
25another realm with THAT realm's storage authority (the declaring-realm
26borrow), so the renderer can read anything and write nothing of ours —
27it gets no `cur`, so it cannot cross into anything as us either. What it
28can do is draw something wrong, slow or broken, which is a display
29problem: ClearRenderer withdraws it in one transaction (admin, recovery
30or guardian) and wallets fall back to the built-in card below.
31
32Who may register is pinned to ONE package path, approved through the
33timelock (ProposeRenderer), because what every wallet shows for every
34name is worth a waiting period: a hostile card could tell holders to pay
35somebody. The ledger is born approving its own generation's view.
36*/
37
38// Renderer draws one token for wallets. Implemented by the view realm.
39type Renderer interface {
40 // TokenImage returns the image itself, as a data: URI.
41 TokenImage(tid string) string
42 // TokenMetadata returns the metadata as a JSON string: name,
43 // description, externalUrl, attributes [{traitType, value}].
44 TokenMetadata(tid string) string
45}
46
47var (
48 // The one realm allowed to register a renderer; "" for none.
49 rendererPath string
50 // What it registered; nil means the built-in card.
51 renderer Renderer
52)
53
54// RegisterRenderer is called BY the approved view realm, handing over
55// the object that draws. Refused from anywhere else.
56func RegisterRenderer(cur realm, r Renderer) {
57 assertNoSend(cur)
58 registerRenderer(cur.Previous().PkgPath(), r)
59}
60
61func registerRenderer(callerPath string, r Renderer) {
62 if rendererPath == "" || callerPath != rendererPath {
63 panic("nsnft: only the approved view realm may register a renderer")
64 }
65 if r == nil {
66 panic("nsnft: no renderer given")
67 }
68 renderer = r
69 chain.Emit("RendererRegistered", "path", callerPath)
70}
71
72// ClearRenderer withdraws the art at once, approval included, so the
73// realm that drew it cannot simply register again: approving one takes
74// the timelock. Admin, recovery or guardian.
75func ClearRenderer(cur realm) {
76 assertNoSend(cur)
77 clearRenderer(cur.Previous().Address())
78}
79
80func clearRenderer(caller address) {
81 assertCanRevoke(caller)
82 prev := rendererPath
83 rendererPath = ""
84 renderer = nil
85 chain.Emit("RendererCleared", "path", prev, "by", caller.String())
86}
87
88func RendererPath() string { return rendererPath }
89func HasRenderer() bool { return renderer != nil }
90
91// TokenURI is the token's image, as a data: URI, for a token that exists.
92// A renderer that answers nothing gets the built-in card instead: an
93// empty <img src> is a broken picture in every wallet.
94func TokenURI(tid string) (string, error) {
95 if !Exists(tid) {
96 return "", grc721.ErrInvalidTokenId
97 }
98 if renderer != nil {
99 if img := renderer.TokenImage(tid); img != "" {
100 return img, nil
101 }
102 }
103 return builtinImage(tid), nil
104}
105
106// TokenMetadata is the token's metadata as a camelCase JSON string, the
107// shape Adena reads. The image is not in it; that is TokenURI.
108func TokenMetadata(tid string) (string, error) {
109 if !Exists(tid) {
110 return "", grc721.ErrInvalidTokenId
111 }
112 if renderer != nil {
113 if meta := renderer.TokenMetadata(tid); meta != "" {
114 return meta, nil
115 }
116 }
117 return builtinMetadata(tid), nil
118}
119
120/*
121THE BUILT-IN CARD, for when no view is registered: the name, plainly.
122
123Deliberately plain and deliberately free of anything that could go
124stale. No URL — a domain this realm named could lapse and be bought by
125somebody else, and the link would then live in every wallet forever — and
126no colours or claims that a policy change could make wrong.
127*/
128func builtinImage(tid string) string {
129 size := 40
130 if n := len(tid); n > 16 {
131 size = 640 / n
132 if size < 8 {
133 size = 8
134 }
135 }
136 svg := `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">` +
137 `<rect width="400" height="400" fill="#0b0d10"/>` +
138 `<text x="200" y="200" text-anchor="middle" font-family="monospace" font-size="` +
139 itoa(int64(size)) + `" fill="#f4f4f5">` + escapeXML(tid) + `</text>` +
140 `<text x="200" y="372" text-anchor="middle" font-family="monospace" font-size="13"` +
141 ` letter-spacing="3" fill="#71717a">MEME NAME SERVICE</text></svg>`
142 return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
143}
144
145func builtinMetadata(tid string) string {
146 return `{"name":"` + escapeJSON(tid) + `",` +
147 `"description":"A name on the Meme Name Service.",` +
148 `"attributes":[]}`
149}
150
151func escapeXML(s string) string {
152 s = strings.ReplaceAll(s, "&", "&")
153 s = strings.ReplaceAll(s, "<", "<")
154 s = strings.ReplaceAll(s, ">", ">")
155 s = strings.ReplaceAll(s, `"`, """)
156 s = strings.ReplaceAll(s, "'", "'")
157 return s
158}
159
160func escapeJSON(s string) string {
161 var b strings.Builder
162 for i := 0; i < len(s); i++ {
163 c := s[i]
164 switch {
165 case c == '"':
166 b.WriteString(`\"`)
167 case c == '\\':
168 b.WriteString(`\\`)
169 case c < 0x20:
170 const hex = "0123456789abcdef"
171 b.WriteString(`\u00`)
172 b.WriteByte(hex[c>>4])
173 b.WriteByte(hex[c&0xf])
174 default:
175 b.WriteByte(c)
176 }
177 }
178 return b.String()
179}