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.

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/sinopia/engine/v1"
 12	"gno.land/p/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/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
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}