mint.gno
9.45 Kb · 312 lines
1package planes
2
3import (
4 "chain"
5 "chain/banker"
6 "errors"
7
8 // Only for OriginSend, in Mint. Caller identity always comes from
9 // cur.Previous(), never from this package.
10 "chain/runtime/unsafe"
11
12 "gno.land/p/nt/grc721/v0"
13 "gno.land/p/onbloc/json/v0"
14)
15
16// maxBatch is the most planes one Mint call mints. It is a constant, apart
17// from the per-wallet cap that admins tune with SetMintCap.
18const maxBatch = int64(7)
19
20// Mint mints count planes for the caller, 1 to 7 in one call, drawn at
21// random among the ids not minted yet (draw.gno), and returns their ids in
22// the order drawn. Exactly
23// count times the price must be attached to the transaction (-send
24// "<count*price>ugnot"); it is forwarded to the treasury of the game realm,
25// in one send, before the call returns.
26//
27// Each id comes out once, while the mint is open. A batch is
28// minted whole or not at all: one that would take the caller over the
29// per-wallet cap, or past the last plane, is refused with how many are left.
30// It must be called directly by a user (maketx call): a call relayed by
31// another realm or by a maketx run script is refused. That is also what
32// keeps the draw fair: a contract could otherwise look at the ids it got
33// and panic to draw again.
34//
35// The caller pays the storage of their planes as a deposit on top of the
36// price, given back on burn: the ledger entries only, the traits stay in the
37// table (see the numbers in docs/spec.md, section 9.2).
38func Mint(cur realm, count int64) []int64 {
39 minter := callerOf(0, cur)
40
41 // Paid-call guard: two checks that are only correct together.
42 //
43 // (a) The caller is a plain user account. Only then is the -send
44 // envelope guaranteed to have landed at this realm's address. A
45 // relaying realm or a maketx run script can attach coins to the
46 // transaction and keep them, while OriginSend still reports them.
47 if !cur.Previous().IsUserCall() {
48 panic(errNotUserCall)
49 }
50 if count < 1 || count > maxBatch {
51 panic(errInvalidCount)
52 }
53 if !mintOpen {
54 panic(errMintClosed)
55 }
56 if n := left(); n < 1 {
57 panic(errSoldOut)
58 } else if count > n {
59 panic("gnofly: only " + itoa(n) + " planes are left to mint")
60 }
61 if left := mintCap - mintedBy(minter); left < 1 {
62 panic(errCapReached)
63 } else if count > left {
64 panic("gnofly: this wallet can mint " + itoa(left) + " more planes, the cap is " + itoa(mintCap))
65 }
66
67 // (b) The envelope is exactly count times the price: one coin, the right
68 // denom, the right amount. Anything else would either underpay or
69 // leave coins behind in the realm.
70 total := count * price
71 sent := unsafe.OriginSend()
72 if len(sent) != 1 || sent[0].Denom != payDenom || sent[0].Amount != total {
73 panic("gnofly: wrong payment, send exactly " + itoa(total) + payDenom)
74 }
75
76 // Forward the payment right away, so that nothing accrues in the realm.
77 // An OriginSend banker can spend the envelope of this call and nothing
78 // else.
79 treasury := roles.Treasury()
80 payment := chain.NewCoins(chain.NewCoin(payDenom, total))
81 banker.NewBanker(banker.BankerTypeOriginSend, cur).SendCoins(cur.Address(), treasury, payment)
82
83 ids := make([]int64, 0, count)
84 for i := int64(0); i < count; i++ {
85 p := mustPlane(drawID(minter))
86 mintCount++
87 ids = append(ids, p.id)
88 copy(recent[1:], recent[:len(recent)-1])
89 recent[0] = p.id
90 if err := ledger.Mint(minter, tokenID(p.id)); err != nil {
91 panic(err)
92 }
93 tierMinted[tierIndex(p.tier)]++
94 chain.Emit("PlaneMinted",
95 "id", itoa(p.id),
96 "minter", minter.String(),
97 "price", itoa(price),
98 "treasury", treasury.String(),
99 )
100 }
101 mints.Set(minter.String(), mintedBy(minter)+count)
102 return ids
103}
104
105// mintedBy returns how many planes addr minted so far.
106func mintedBy(addr address) int64 {
107 v := mints.Get(addr.String())
108 if v == nil {
109 return 0
110 }
111 return v.(int64)
112}
113
114// The read functions a wallet calls take a grc721.TokenID and return an
115// error rather than panic, the way Adena calls them: OwnerOf("42") into two
116// values, TokenURI("42") as the image it shows, TokenMetadata("42") as JSON
117// for the traits. The errors are made on the call, not stored: a stored
118// error value costs a kilobyte of deposit (see errors.gno).
119
120// TokenMetadata returns the metadata of a minted plane as JSON, built from
121// the table on every read: the realm stores no metadata per token.
122//
123// {"name":"Storm Saint","description":"Plane 42 of 777, ...",
124// "image":"https://gnofly.xyz/nft/collections/planes/042.png",
125// "attributes":[{"traitType":"model","value":"delta"}, ...]}
126//
127// The keys are the ones Adena reads, traitType included.
128func TokenMetadata(tid grc721.TokenID) (string, error) {
129 id, err := mintedID(tid)
130 if err != nil {
131 return "", err
132 }
133 p := mustPlane(id)
134 b := json.Builder().
135 WriteString("name", p.name).
136 WriteString("description", description(p)).
137 WriteString("image", imageURL(id)).
138 WriteArray("attributes", func(ab *json.ArrayBuilder) {
139 for _, t := range [][2]string{
140 {"model", p.model}, {"pilot", p.pilot}, {"body", p.body}, {"wing", p.wing},
141 {"accent", p.accent}, {"tier", p.tier}, {"laser", p.laser},
142 } {
143 ab.WriteObject(func(o *json.NodeBuilder) {
144 o.WriteString("traitType", t[0]).WriteString("value", t[1])
145 })
146 }
147 })
148 return encode(b.Node()), nil
149}
150
151// mintedID reads a token id, "1" to "777", and checks the plane is minted.
152func mintedID(tid grc721.TokenID) (int64, error) {
153 id := parseID(string(tid))
154 if ownerOf(id) == "" {
155 return 0, errors.New(errNotMinted)
156 }
157 return id, nil
158}
159
160// parseID reads "1" to "777" in decimal, 0 for anything else.
161func parseID(s string) int64 {
162 if len(s) == 0 || len(s) > 3 || s[0] == '0' {
163 return 0
164 }
165 n := int64(0)
166 for i := 0; i < len(s); i++ {
167 if s[i] < '0' || s[i] > '9' {
168 return 0
169 }
170 n = n*10 + int64(s[i]-'0')
171 }
172 if n > supply {
173 return 0
174 }
175 return n
176}
177
178// description is one sentence about a plane: "Plane 42 of 777, an epic delta
179// flown by a gnome." The tier is the only word that may start with a vowel.
180func description(p plane) string {
181 article := "a "
182 if p.tier == "epic" {
183 article = "an "
184 }
185 s := "Plane " + itoa(p.id) + " of " + itoa(supply) + ", " + article + p.tier + " " + p.model
186 if p.pilot == "gnome" {
187 s += " flown by a gnome"
188 }
189 return s + "."
190}
191
192// Burn destroys a plane. Only its owner can, and nobody can mint it again:
193// the id stays out of circulation.
194func Burn(cur realm, id int64) {
195 caller := callerOf(0, cur)
196 if ownerOf(id) != caller {
197 panic(errNotPlaneOwner)
198 }
199 if err := ledger.Burn(tokenID(id)); err != nil {
200 panic(err)
201 }
202 chain.Emit("PlaneBurned", "id", itoa(id), "owner", caller.String())
203}
204
205// The GRC721 transfer surface. These wrappers hand cur to the caller-scoped
206// teller, which acts as cur.Previous() and applies it to the private ledger:
207// a caller moves their own planes and the ones they were approved for,
208// nothing else. See p/nt/grc721/v0/tellers.gno.
209
210// TransferFrom moves plane id from one address to another. The caller must
211// own it or be approved for it.
212func TransferFrom(cur realm, from, to address, id int64) {
213 if err := teller.TransferFrom(0, cur, from, to, tokenID(id)); err != nil {
214 panic(err)
215 }
216}
217
218// Approve lets to move plane id once. The caller must own it. An empty
219// address revokes.
220func Approve(cur realm, to address, id int64) {
221 if err := teller.Approve(0, cur, to, tokenID(id)); err != nil {
222 panic(err)
223 }
224}
225
226// SetApprovalForAll lets operator move every plane of the caller, or
227// revokes it.
228func SetApprovalForAll(cur realm, operator address, approved bool) {
229 if err := teller.SetApprovalForAll(0, cur, operator, approved); err != nil {
230 panic(err)
231 }
232}
233
234// ownerOf returns who owns plane id, or "" when it is not minted.
235func ownerOf(id int64) address {
236 if id < 1 || id > supply {
237 return ""
238 }
239 owner, err := Token.OwnerOf(tokenID(id))
240 if err != nil {
241 return ""
242 }
243 return owner
244}
245
246// OwnerOf returns who owns plane tid, or an error when it is not minted.
247func OwnerOf(tid grc721.TokenID) (address, error) {
248 id, err := mintedID(tid)
249 if err != nil {
250 return "", err
251 }
252 return ownerOf(id), nil
253}
254
255// BalanceOf returns how many planes addr owns.
256func BalanceOf(addr address) (int64, error) {
257 return Token.BalanceOf(addr)
258}
259
260// TotalSupply returns how many planes exist: minted and not burned.
261func TotalSupply() int64 {
262 return Token.TotalSupply()
263}
264
265// GetApproved returns the address approved to move plane id, or "" when
266// there is none.
267func GetApproved(id int64) address {
268 addr, err := Token.GetApproved(tokenID(id))
269 if err == grc721.ErrTokenIdNotApproved {
270 return ""
271 }
272 if err != nil {
273 panic(err)
274 }
275 return addr
276}
277
278// IsApprovedForAll reports whether operator may move every plane of owner.
279func IsApprovedForAll(owner, operator address) bool {
280 return Token.IsApprovedForAll(owner, operator)
281}
282
283// TokenURI returns the image of a minted plane, "<image base><nnn>.png":
284// what a wallet shows (Adena puts it in an <img>). The metadata is
285// TokenMetadata, or "<image base><id>.json" on the website. It is computed,
286// not stored: a mint writes no URI.
287func TokenURI(tid grc721.TokenID) (string, error) {
288 id, err := mintedID(tid)
289 if err != nil {
290 return "", err
291 }
292 return imageURL(id), nil
293}
294
295// RoyaltyInfo returns who receives the royalty on a sale of plane id at
296// salePrice, and how much: EIP-2981.
297func RoyaltyInfo(id int64, salePrice int64) (address, int64) {
298 receiver, amount, err := Royalties.RoyaltyInfo(tokenID(id), salePrice)
299 if err != nil {
300 panic(err)
301 }
302 return receiver, amount
303}
304
305// RoyaltyBps returns the royalty of a resale, in basis points.
306func RoyaltyBps() int64 {
307 info, ok := Royalties.DefaultRoyalty()
308 if !ok {
309 return 0
310 }
311 return info.Bps
312}