tokens.gno
10.95 Kb · 361 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/g1pq6pxhgxk2uw5shg5le6glfydpxcu74k0zh069/sinopia/engine/v1"
12 "gno.land/p/g1pq6pxhgxk2uw5shg5le6glfydpxcu74k0zh069/sinopia/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
116// Every Approve of a token and every SetApprovalForAll of an (owner,
117// operator) pair bumps a nonce. A market pins the nonces at listing time,
118// so revoking an approval kills a listing for good: approving again later
119// starts a new nonce and never revives the old listing (SA-L3).
120func Approve(cur realm, cid, tid string, to address) {
121 assertNoDeposit(cur)
122 c := mustCollection(cid)
123 if err := c.ledger.Approve(caller(cur), to, grc721.TokenID(tid)); err != nil {
124 panic(err)
125 }
126 bumpNonce(c, "t:"+tid)
127}
128
129func SetApprovalForAll(cur realm, cid string, operator address, approved bool) {
130 assertNoDeposit(cur)
131 c := mustCollection(cid)
132 owner := caller(cur)
133 if err := c.ledger.SetApprovalForAll(owner, operator, approved); err != nil {
134 panic(err)
135 }
136 bumpNonce(c, "o:"+owner.String()+":"+operator.String())
137}
138
139// ApprovalNonces returns the token's approval nonce and the (owner,
140// operator) approval-for-all nonce. Approvals made directly through
141// Token(cid).RealmTeller by a realm acting for itself do not bump them.
142func ApprovalNonces(cid, tid string, owner, operator address) (tokenNonce, operatorNonce int64) {
143 c := mustCollection(cid)
144 return nonceOf(c, "t:"+tid), nonceOf(c, "o:"+owner.String()+":"+operator.String())
145}
146
147func bumpNonce(c *collection, key string) {
148 c.approvals.Set(key, nonceOf(c, key)+1)
149}
150
151func nonceOf(c *collection, key string) int64 {
152 if v := c.approvals.Get(key); v != nil {
153 return v.(int64)
154 }
155 return 0
156}
157
158func TransferFrom(cur realm, cid, tid string, from, to address) {
159 assertNoDeposit(cur)
160 c := mustCollection(cid)
161 if err := c.ledger.TransferFrom(caller(cur), from, to, grc721.TokenID(tid)); err != nil {
162 panic(err)
163 }
164}
165
166// Burn is owner-only. The supply gate uses nextTID, so a burn never reopens
167// a slot (I-5).
168func Burn(cur realm, cid, tid string) {
169 assertNoDeposit(cur)
170 c := mustCollection(cid)
171 owner, err := c.token.OwnerOf(grc721.TokenID(tid))
172 if err != nil {
173 panic(err)
174 }
175 if owner != caller(cur) {
176 panic("only the owner can burn a token")
177 }
178 if err := c.ledger.Burn(grc721.TokenID(tid)); err != nil {
179 panic(err)
180 }
181}
182
183// --- token reads ---------------------------------------------------------------
184
185// Token publishes the collection's official *grc721.Token. Its exported
186// methods are reads or teller factories that need the caller's live realm;
187// the ledger itself is never published.
188func Token(cid string) *grc721.Token { return mustCollection(cid).token }
189
190// Royalty publishes the read-only royalty view (EIP-2981 RoyaltyInfo).
191func Royalty(cid string) *royalty.Royalty { return mustCollection(cid).roy }
192
193func OwnerOf(cid, tid string) (address, error) {
194 return mustCollection(cid).token.OwnerOf(grc721.TokenID(tid))
195}
196
197func BalanceOf(cid string, owner address) (int64, error) {
198 return mustCollection(cid).token.BalanceOf(owner)
199}
200
201// TokenMoves is the transfer counter market/v1 pins in a listing (H-2).
202func TokenMoves(cid, tid string) int64 {
203 n, _ := mustCollection(cid).st.moveOf(tid)
204 return n
205}
206
207// TokenSeed is "" until the collection is Final: no read path can leak the
208// rarity map early.
209func TokenSeed(cid, tid string) string {
210 c := mustCollection(cid)
211 if !c.st.final || !exists(c, tid) {
212 return ""
213 }
214 return safe.Seed(c.id, tid, c.prov.salt)
215}
216
217// TokenState is {mintHeight, lastMoveHeight, moves} as JSON.
218func TokenState(cid, tid string) string {
219 c := mustCollection(cid)
220 if !exists(c, tid) {
221 panic(grc721.ErrInvalidTokenId)
222 }
223 in := tokenInput(c, tid)
224 w := &safe.JSONWriter{}
225 w.BeginObject()
226 w.IntField("mintHeight", in.MintHeight)
227 w.IntField("lastMoveHeight", in.LastMoveHeight)
228 w.IntField("moves", in.Moves)
229 w.EndObject()
230 return w.String()
231}
232
233// TokenURI is a base64 SVG data URI. Before Final every token shows core's
234// neutral placeholder, never engine output (design §4.2).
235func TokenURI(cid, tid string) (string, error) {
236 c := mustCollection(cid)
237 if _, err := c.token.OwnerOf(grc721.TokenID(tid)); err != nil {
238 return "", err
239 }
240 if !c.st.final {
241 return dataURI(placeholderSVG(c.name, "Unrevealed")), nil
242 }
243 return dataURI(renderToken(c, tid)), nil
244}
245
246// TokenTraits is [{"trait_type":…,"value":…}] JSON.
247func TokenTraits(cid, tid string) string {
248 c := mustCollection(cid)
249 w := &safe.JSONWriter{}
250 writeTraits(w, tokenTraits(c, tid))
251 return w.String()
252}
253
254// TokenTraitList is the typed form of TokenTraits for other realms (a copy).
255func TokenTraitList(cid, tid string) []engine.Trait {
256 return append([]engine.Trait{}, tokenTraits(mustCollection(cid), tid)...)
257}
258
259// TokenMetadata is OpenSea-style metadata JSON.
260func TokenMetadata(cid, tid string) string {
261 c := mustCollection(cid)
262 uri, err := TokenURI(cid, tid)
263 if err != nil {
264 panic(err)
265 }
266 w := &safe.JSONWriter{}
267 w.BeginObject()
268 w.StrField("name", c.name+" #"+tid)
269 w.StrField("description", c.description)
270 w.StrField("image", uri)
271 w.Key("attributes")
272 writeTraits(w, tokenTraits(c, tid))
273 w.EndObject()
274 return w.String()
275}
276
277func exists(c *collection, tid string) bool {
278 _, err := c.token.OwnerOf(grc721.TokenID(tid))
279 return err == nil
280}
281
282func tokenInput(c *collection, tid string) engine.Input {
283 n := safe.ParseID("tid", tid)
284 moves, lastMove := c.st.moveOf(tid)
285 mh := c.st.mintHeight(n)
286 if lastMove == 0 {
287 lastMove = mh
288 }
289 in := engine.Input{
290 Params: c.params,
291 CollectionID: c.id,
292 TokenID: tid,
293 MintHeight: mh,
294 LastMoveHeight: lastMove,
295 Moves: moves,
296 Height: height(),
297 }
298 if c.st.final {
299 in.Seed = safe.Seed(c.id, tid, c.prov.salt)
300 }
301 return in
302}
303
304// renderToken runs the collection's engine on a read path. A bad output
305// degrades to a placeholder; a panicking engine only fails reads of its own
306// collections (interrealm v2 §9: cross-realm panics abort the query).
307func renderToken(c *collection, tid string) string {
308 rec := mustEngine(c.engineID)
309 svg := rec.impl.Render(tokenInput(c, tid))
310 if err := engine.CheckSVG(rec.manifest, svg); err != nil {
311 return placeholderSVG(c.name, "Engine output rejected")
312 }
313 return svg
314}
315
316func tokenTraits(c *collection, tid string) []engine.Trait {
317 if !exists(c, tid) {
318 panic(grc721.ErrInvalidTokenId)
319 }
320 if !c.st.final {
321 return []engine.Trait{{Type: "Status", Value: "Unrevealed"}}
322 }
323 rec := mustEngine(c.engineID)
324 ts := rec.impl.Traits(tokenInput(c, tid))
325 if err := engine.ValidateTraits(rec.manifest, ts); err != nil {
326 return []engine.Trait{{Type: "Status", Value: "Engine output rejected"}}
327 }
328 return ts
329}
330
331func writeTraits(w *safe.JSONWriter, ts []engine.Trait) {
332 w.BeginArray()
333 for _, t := range ts {
334 w.BeginObject()
335 w.StrField("trait_type", t.Type)
336 w.StrField("value", t.Value)
337 w.EndObject()
338 }
339 w.EndArray()
340}
341
342// placeholderSVG is core's own neutral art: identical for every token of a
343// collection, so it carries no rarity signal.
344func placeholderSVG(name, label string) string {
345 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">` +
346 `<rect width="512" height="512" fill="#111"/>` +
347 `<text x="256" y="236" fill="#ddd" font-family="monospace" font-size="24" text-anchor="middle">` +
348 safe.XMLEscape(name) + `</text>` +
349 `<text x="256" y="286" fill="#888" font-family="monospace" font-size="18" text-anchor="middle">` +
350 safe.XMLEscape(label) + `</text></svg>`
351}
352
353func dataURI(svg string) string {
354 return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
355}
356
357// emitBatch announces one MintTo ("MintBatch") or ReserveTo ("Reserved")
358// call; each token also gets its own "Minted" event from lockExt.
359func emitBatch(kind string, c *collection, to address, first, last int64) {
360 chain.Emit(kind, "cid", c.id, "to", to.String(), "first", itoa(first), "last", itoa(last))
361}