tokens.gno
9.96 Kb · 335 lines
1package core
2
3import (
4 "chain"
5 "encoding/base64"
6
7 "gno.land/p/nt/avl/v0"
8 "gno.land/p/nt/grc721/royalty/v0"
9 "gno.land/p/nt/grc721/v0"
10
11 "gno.land/p/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/minhwa/engine/v1"
12 "gno.land/p/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/minhwa/safe/v1"
13)
14
15const tidWidth = 6
16
17// tokenState is the per-collection data the transfer hook needs. It is
18// shared by the collection and its lockExt, so neither points at the other.
19type tokenState struct {
20 final bool
21 batches avl.Tree // safe.PadInt(first) -> *mintBatch
22 moves avl.Tree // tid -> *moveRecord, created on a token's first transfer
23}
24
25// mintBatch records one mint call; mint height is derived per token from
26// it, so a 10-token mint writes one record, not ten.
27type mintBatch struct {
28 first, last, height int64
29}
30
31type moveRecord struct {
32 moves, lastMove int64
33}
34
35func (st *tokenState) addBatch(first, last, h int64) {
36 st.batches.Set(safe.PadInt(first, tidWidth), &mintBatch{first: first, last: last, height: h})
37}
38
39func (st *tokenState) mintHeight(tid int64) int64 {
40 var h int64
41 st.batches.ReverseIterate("", safe.PadInt(tid, tidWidth), func(_ string, v any) bool {
42 b := v.(*mintBatch)
43 if tid <= b.last {
44 h = b.height
45 }
46 return true
47 })
48 return h
49}
50
51func (st *tokenState) moveOf(tid string) (moves, lastMove int64) {
52 if v := st.moves.Get(tid); v != nil {
53 r := v.(*moveRecord)
54 return r.moves, r.lastMove
55 }
56 return 0, 0
57}
58
59// lockExt is the grc721 Extension behind two invariants:
60//
61// - I-11: before Final no token moves or burns (the reveal lock that
62// closes RA H-3's information-advantage branch). The veto is a panic,
63// which aborts the whole transaction — the documented Extension
64// semantics of p/nt/grc721/v0.
65// - H-2: every transfer bumps the token's move counter, so a market
66// listing taken at moves=N is dead after any transfer, even if the
67// token later comes back to the seller.
68//
69// It is declared in this realm, so the hooks write core state with core's
70// authority even when another realm moves the token via RealmTeller.
71type lockExt struct {
72 cid string
73 st *tokenState
74}
75
76const lockKind = "art-lock"
77
78func (e *lockExt) ExtensionKind() string { return lockKind }
79
80// The hooks also emit the platform's per-token history events with the
81// collection id ("Minted", "Transfer"); grc721's own Transfer event carries
82// only the token's grc721 id.
83func (e *lockExt) OnMint(to address, tid grc721.TokenID) {
84 chain.Emit("Minted", "cid", e.cid, "tid", tid.String(), "to", to.String())
85}
86
87func (e *lockExt) OnTransfer(from, to address, tid grc721.TokenID) {
88 if !e.st.final {
89 panic("tokens are locked until the collection is final (reveal)")
90 }
91 key := tid.String()
92 r, _ := e.st.moves.Get(key).(*moveRecord)
93 if r == nil {
94 r = &moveRecord{}
95 e.st.moves.Set(key, r)
96 }
97 r.moves++
98 r.lastMove = height()
99 chain.Emit("Transfer", "cid", e.cid, "tid", key, "from", from.String(), "to", to.String())
100}
101
102func (e *lockExt) OnBurn(tid grc721.TokenID) {
103 if !e.st.final {
104 panic("tokens are locked until the collection is final (reveal)")
105 }
106 e.st.moves.Remove(tid.String())
107}
108
109// --- owner actions (I-2: never paused) ---------------------------------------
110//
111// The acting account is always the immediate caller (cur.Previous()), the
112// same rule as grc721's CallerTeller: a realm a user merely calls cannot act
113// for that user. Another realm moves tokens only after an Approve, through
114// Token(cid).RealmTeller (market/v1 does exactly that).
115
116func Approve(cur realm, cid, tid string, to address) {
117 assertNoDeposit(cur)
118 c := mustCollection(cid)
119 if err := c.ledger.Approve(caller(cur), to, grc721.TokenID(tid)); err != nil {
120 panic(err)
121 }
122}
123
124func SetApprovalForAll(cur realm, cid string, operator address, approved bool) {
125 assertNoDeposit(cur)
126 c := mustCollection(cid)
127 if err := c.ledger.SetApprovalForAll(caller(cur), operator, approved); err != nil {
128 panic(err)
129 }
130}
131
132func TransferFrom(cur realm, cid, tid string, from, to address) {
133 assertNoDeposit(cur)
134 c := mustCollection(cid)
135 if err := c.ledger.TransferFrom(caller(cur), from, to, grc721.TokenID(tid)); err != nil {
136 panic(err)
137 }
138}
139
140// Burn is owner-only. The supply gate uses nextTID, so a burn never reopens
141// a slot (I-5).
142func Burn(cur realm, cid, tid string) {
143 assertNoDeposit(cur)
144 c := mustCollection(cid)
145 owner, err := c.token.OwnerOf(grc721.TokenID(tid))
146 if err != nil {
147 panic(err)
148 }
149 if owner != caller(cur) {
150 panic("only the owner can burn a token")
151 }
152 if err := c.ledger.Burn(grc721.TokenID(tid)); err != nil {
153 panic(err)
154 }
155}
156
157// --- token reads ---------------------------------------------------------------
158
159// Token publishes the collection's official *grc721.Token. Its exported
160// methods are reads or teller factories that need the caller's live realm;
161// the ledger itself is never published.
162func Token(cid string) *grc721.Token { return mustCollection(cid).token }
163
164// Royalty publishes the read-only royalty view (EIP-2981 RoyaltyInfo).
165func Royalty(cid string) *royalty.Royalty { return mustCollection(cid).roy }
166
167func OwnerOf(cid, tid string) (address, error) {
168 return mustCollection(cid).token.OwnerOf(grc721.TokenID(tid))
169}
170
171func BalanceOf(cid string, owner address) (int64, error) {
172 return mustCollection(cid).token.BalanceOf(owner)
173}
174
175// TokenMoves is the transfer counter market/v1 pins in a listing (H-2).
176func TokenMoves(cid, tid string) int64 {
177 n, _ := mustCollection(cid).st.moveOf(tid)
178 return n
179}
180
181// TokenSeed is "" until the collection is Final: no read path can leak the
182// rarity map early.
183func TokenSeed(cid, tid string) string {
184 c := mustCollection(cid)
185 if !c.st.final || !exists(c, tid) {
186 return ""
187 }
188 return safe.Seed(c.id, tid, c.prov.salt)
189}
190
191// TokenState is {mintHeight, lastMoveHeight, moves} as JSON.
192func TokenState(cid, tid string) string {
193 c := mustCollection(cid)
194 if !exists(c, tid) {
195 panic(grc721.ErrInvalidTokenId)
196 }
197 in := tokenInput(c, tid)
198 w := &safe.JSONWriter{}
199 w.BeginObject()
200 w.IntField("mintHeight", in.MintHeight)
201 w.IntField("lastMoveHeight", in.LastMoveHeight)
202 w.IntField("moves", in.Moves)
203 w.EndObject()
204 return w.String()
205}
206
207// TokenURI is a base64 SVG data URI. Before Final every token shows core's
208// neutral placeholder, never engine output (design §4.2).
209func TokenURI(cid, tid string) (string, error) {
210 c := mustCollection(cid)
211 if _, err := c.token.OwnerOf(grc721.TokenID(tid)); err != nil {
212 return "", err
213 }
214 if !c.st.final {
215 return dataURI(placeholderSVG(c.name, "Unrevealed")), nil
216 }
217 return dataURI(renderToken(c, tid)), nil
218}
219
220// TokenTraits is [{"trait_type":…,"value":…}] JSON.
221func TokenTraits(cid, tid string) string {
222 c := mustCollection(cid)
223 w := &safe.JSONWriter{}
224 writeTraits(w, tokenTraits(c, tid))
225 return w.String()
226}
227
228// TokenTraitList is the typed form of TokenTraits for other realms (a copy).
229func TokenTraitList(cid, tid string) []engine.Trait {
230 return append([]engine.Trait{}, tokenTraits(mustCollection(cid), tid)...)
231}
232
233// TokenMetadata is OpenSea-style metadata JSON.
234func TokenMetadata(cid, tid string) string {
235 c := mustCollection(cid)
236 uri, err := TokenURI(cid, tid)
237 if err != nil {
238 panic(err)
239 }
240 w := &safe.JSONWriter{}
241 w.BeginObject()
242 w.StrField("name", c.name+" #"+tid)
243 w.StrField("description", c.description)
244 w.StrField("image", uri)
245 w.Key("attributes")
246 writeTraits(w, tokenTraits(c, tid))
247 w.EndObject()
248 return w.String()
249}
250
251func exists(c *collection, tid string) bool {
252 _, err := c.token.OwnerOf(grc721.TokenID(tid))
253 return err == nil
254}
255
256func tokenInput(c *collection, tid string) engine.Input {
257 n := safe.ParseID("tid", tid)
258 moves, lastMove := c.st.moveOf(tid)
259 mh := c.st.mintHeight(n)
260 if lastMove == 0 {
261 lastMove = mh
262 }
263 in := engine.Input{
264 Params: c.params,
265 CollectionID: c.id,
266 TokenID: tid,
267 MintHeight: mh,
268 LastMoveHeight: lastMove,
269 Moves: moves,
270 Height: height(),
271 }
272 if c.st.final {
273 in.Seed = safe.Seed(c.id, tid, c.prov.salt)
274 }
275 return in
276}
277
278// renderToken runs the collection's engine on a read path. A bad output
279// degrades to a placeholder; a panicking engine only fails reads of its own
280// collections (interrealm v2 §9: cross-realm panics abort the query).
281func renderToken(c *collection, tid string) string {
282 rec := mustEngine(c.engineID)
283 svg := rec.impl.Render(tokenInput(c, tid))
284 if err := engine.CheckSVG(rec.manifest, svg); err != nil {
285 return placeholderSVG(c.name, "Engine output rejected")
286 }
287 return svg
288}
289
290func tokenTraits(c *collection, tid string) []engine.Trait {
291 if !exists(c, tid) {
292 panic(grc721.ErrInvalidTokenId)
293 }
294 if !c.st.final {
295 return []engine.Trait{{Type: "Status", Value: "Unrevealed"}}
296 }
297 rec := mustEngine(c.engineID)
298 ts := rec.impl.Traits(tokenInput(c, tid))
299 if err := engine.ValidateTraits(rec.manifest, ts); err != nil {
300 return []engine.Trait{{Type: "Status", Value: "Engine output rejected"}}
301 }
302 return ts
303}
304
305func writeTraits(w *safe.JSONWriter, ts []engine.Trait) {
306 w.BeginArray()
307 for _, t := range ts {
308 w.BeginObject()
309 w.StrField("trait_type", t.Type)
310 w.StrField("value", t.Value)
311 w.EndObject()
312 }
313 w.EndArray()
314}
315
316// placeholderSVG is core's own neutral art: identical for every token of a
317// collection, so it carries no rarity signal.
318func placeholderSVG(name, label string) string {
319 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">` +
320 `<rect width="512" height="512" fill="#111"/>` +
321 `<text x="256" y="236" fill="#ddd" font-family="monospace" font-size="24" text-anchor="middle">` +
322 safe.XMLEscape(name) + `</text>` +
323 `<text x="256" y="286" fill="#888" font-family="monospace" font-size="18" text-anchor="middle">` +
324 safe.XMLEscape(label) + `</text></svg>`
325}
326
327func dataURI(svg string) string {
328 return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
329}
330
331// emitBatch announces one MintTo ("MintBatch") or ReserveTo ("Reserved")
332// call; each token also gets its own "Minted" event from lockExt.
333func emitBatch(kind string, c *collection, to address, first, last int64) {
334 chain.Emit(kind, "cid", c.id, "to", to.String(), "first", itoa(first), "last", itoa(last))
335}