nft.gno
5.75 Kb · 199 lines
1// Package nft holds GnoRadio's concert tickets as GRC721 NFTs. It is
2// permanent, like the data realm: a tickets release changes the rules, never
3// this ledger, so no ticket ever moves to a new contract.
4//
5// Only the realm that writes the tickets role now (data.Writer("tickets"))
6// mints and transfers, only while that role is not paused, and only for the
7// wallet that signs the transaction (OriginCaller): a release can never move
8// the ticket of a holder who signs nothing. That is the whole guarantee. The
9// signature proves the holder sent some transaction through the tickets
10// writer, not that they asked for this move: a malicious tickets release (it
11// takes over only after the data realm's public 72 h window, which the
12// guardian can cancel) could move every ticket of a holder who then signs any
13// call to it. This realm cannot see further up the call stack. Anyone reads.
14//
15// A ticket's metadata and artwork are drawn on the fly from the tickets data
16// (art.gno), whose record layout below is frozen for that reason.
17package nft
18
19import (
20 "chain/runtime/unsafe"
21 "strconv"
22
23 "gno.land/p/nym-alexiscolin000/gnoradio/role/v0"
24 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
25 "gno.land/p/nt/grc721/v0"
26 "gno.land/r/nym-alexiscolin000/gnoradio/data"
27)
28
29// The tickets records this realm reads, written by the tickets writer. Every
30// tickets release keeps this layout: the artwork of existing tickets depends
31// on it.
32const (
33 CEvents = "tickets/events" // store.Pad(id) -> event record
34 CTickets = "tickets/tickets" // store.Pad((id-1)/Chunk) -> Chunk ticket records
35 Chunk = 16
36)
37
38// Event record fields (store.Rec); the one each sale changes comes first.
39const (
40 ESold = iota
41 EArtist
42 EStart
43 EPrice
44 ECapacity
45 ECancelled // "1" or ""
46 EHidden // "1" or ""
47 ERemoved // the moderator's public reason while hidden
48 ETitle
49 EVenue
50 ELink
51 EFields
52)
53
54// Ticket record fields.
55const (
56 TEvent = iota
57 TSerial
58 TAttended // "1" or ""
59)
60
61// The catalog's artist heads, read for a ticket's artist line: every catalog
62// release keeps this layout, since it shares the data realm's records
63// (catalog/v1 records.gno). Reading them here, not through a catalog realm,
64// keeps this permanent realm free of any versioned import.
65const (
66 CArtists = "catalog/artists" // store.Pad((id-1)/ArtistChunk) -> ArtistChunk artist heads
67 ArtistChunk = 8
68 AHidden = 0 // "1" or ""
69 AName = 6
70)
71
72var (
73 token *grc721.Token
74 ledger *grc721.PrivateLedger
75)
76
77// init creates the token, so its storage is paid at deploy and never by a
78// first buyer.
79func init(cur realm) {
80 token, ledger = grc721.NewToken("GnoRadio Ticket", "GRTIX", 1, cur)
81}
82
83// writer lets only the tickets writer through, while its role is not paused.
84func writer(cur realm) {
85 if cur.Previous().PkgPath() != data.Writer("tickets") {
86 panic("nft: only " + data.Writer("tickets") + " mints and transfers tickets")
87 }
88 if data.Paused("tickets") {
89 panic("nft: tickets is paused")
90 }
91}
92
93// maxID is the largest id a data key holds (store.Pad); readers answer ""
94// past it instead of panicking.
95const maxID = 99999999
96
97func tid(id int) grc721.TokenID { return grc721.TokenID(strconv.Itoa(id)) }
98
99// Mint creates ticket id for to, the wallet that signed the transaction.
100// The tickets writer only.
101func Mint(cur realm, to address, id int) {
102 writer(cur)
103 if to != unsafe.OriginCaller() {
104 panic("nft: a ticket is minted to the wallet that signs")
105 }
106 if id < 1 || id > maxID {
107 panic("nft: invalid ticket id")
108 }
109 if err := ledger.Mint(to, tid(id)); err != nil {
110 panic("nft: " + err.Error())
111 }
112}
113
114// Transfer moves ticket id from its holder from to to. The tickets writer
115// only, and only in a transaction from signed: no release moves the tickets
116// of a holder who signs nothing (see the package comment for what a
117// malicious release could still do). The rules decide who may gift.
118func Transfer(cur realm, from, to address, id int) {
119 writer(cur)
120 if from != unsafe.OriginCaller() {
121 panic("nft: only the holder's own transaction moves a ticket")
122 }
123 if !role.Canonical(to) {
124 panic("nft: not an address")
125 }
126 if err := ledger.TransferFrom(from, from, to, tid(id)); err != nil {
127 panic("nft: " + err.Error())
128 }
129}
130
131// ---- GRC721 reads ----
132
133func Name() string { return token.GetName() }
134func Symbol() string { return token.GetSymbol() }
135func TotalSupply() int64 { return token.TotalSupply() }
136
137// BalanceOf is how many tickets owner holds.
138func BalanceOf(owner address) int64 {
139 n, err := token.BalanceOf(owner)
140 if err != nil {
141 panic("nft: " + err.Error())
142 }
143 return n
144}
145
146// OwnerOf is a ticket's holder; it panics for an unknown ticket.
147func OwnerOf(tokenID string) address {
148 owner, err := token.OwnerOf(grc721.TokenID(tokenID))
149 if err != nil {
150 panic("nft: " + err.Error())
151 }
152 return owner
153}
154
155// Holder is a ticket's holder, "" for an unknown ticket.
156func Holder(id int) address {
157 owner, _ := token.OwnerOf(tid(id))
158 return owner
159}
160
161// Render shows the collection.
162func Render(_ string) string {
163 return token.RenderHome() + "\nGnoRadio concert tickets. Each one is drawn on-chain: `TokenURI(id)`. Only `" +
164 data.Writer("tickets") + "` mints and transfers them.\n"
165}
166
167// ---- the tickets records ----
168
169// ticket returns ticket id's record ("" when absent).
170func ticket(id int) string {
171 if id < 1 || id > maxID {
172 return ""
173 }
174 ch, ok := data.Get(CTickets, store.Pad((id-1)/Chunk))
175 if !ok {
176 return ""
177 }
178 return store.Field(ch, (id-1)%Chunk)
179}
180
181// artist returns artist id's head record ("" when absent).
182func artist(id int) string {
183 if id < 1 || id > maxID {
184 return ""
185 }
186 ch, ok := data.Get(CArtists, store.Pad((id-1)/ArtistChunk))
187 if !ok {
188 return ""
189 }
190 return store.Field(ch, (id-1)%ArtistChunk)
191}
192
193func event(id int) string {
194 if id < 1 || id > maxID {
195 return ""
196 }
197 rec, _ := data.Get(CEvents, store.Pad(id))
198 return rec
199}