gnofish.gno
15.62 Kb · 613 lines
1// Package gnofish is the GnoFish sale NFT.
2// A listed wallet mints for free. Anyone else pays exactly 100 GNOT.
3// An address may mint as many times as the 10000 cap allows.
4// The portrait is rolled in the same call. Every NFT is owned by the admin.
5// The whole 100 GNOT is sent to that admin. This realm does not add FISH liquidity.
6// The height and the next id are public before the transaction, so the roll can be predicted. This is not drand.
7// Adena reads OwnerOf, BalanceOf, TokenURI (the https image) and TokenMetadata
8// (JSON, traitType). NewToken and Transfer come from the GRC721 ledger.
9// This package does not swim, settle, or draw FISH. fishlive stays untouched.
10// Nomaq lists with List, UpdatePrice, Cancel, and Buy. ItemLine and ListOpen feed the page.
11// gnofish-1.
12package gnofish
13
14import (
15 "chain"
16 "chain/banker"
17 "chain/runtime"
18 "chain/runtime/unsafe"
19 "crypto/sha256"
20 "errors"
21 "strconv"
22
23 "gno.land/p/nt/grc721/enumerable/v0"
24 "gno.land/p/nt/grc721/v0"
25 "gno.land/p/nt/seqid/v0"
26)
27
28const (
29 denom = "ugnot"
30 mintPrice int64 = 100_000_000
31 maxAsk int64 = 100_000_000_000_000
32 maxMint = 10_000
33 listCap = 50
34 slug = "gnofish"
35 imageIPFS = "ipfs://bafybeifdmgbtq3nf7xuepvygotk6ulk4wo7xshsi2yr4xukglsdpzz7p2i/"
36 imageHTTPS = "https://gateway.pinata.cloud/ipfs/bafybeifdmgbtq3nf7xuepvygotk6ulk4wo7xshsi2yr4xukglsdpzz7p2i/"
37)
38
39var (
40 species = [6]string{"Bubble", "Tideblade", "Abysswing", "Embermaw", "Aurelion", "Astravex"}
41 rarities = [6]string{"common", "uncommon", "rare", "epic", "legendary", "mythic"}
42 portrait = [6]string{"common-fish.jpg", "uncommon-fish.jpg", "race-fish.jpg", "epic-fish.jpg", "legendary-fish.jpg", "mythic-fish.jpg"}
43 traits = [3]string{"swift", "fang", "hide"}
44 biteBase = [6]int{24, 30, 37, 45, 54, 66}
45 hpBase = [6]int{100, 122, 148, 178, 214, 256}
46 spdBase = [6]int{108, 104, 100, 96, 92, 88}
47)
48
49type fish struct {
50 owner address
51 payer address
52 name string
53 rarity string
54 trait string
55 file string
56 bite int
57 hp int
58 speed int
59 height int64
60 revealed bool
61 seller address
62 ask int64
63 onSale bool
64}
65
66var (
67 inited bool
68 self address
69 admin address
70 minted int
71 roster []fish
72 listed map[address]bool
73 token *grc721.Token
74 ledger *grc721.PrivateLedger
75 teller grc721.Teller
76 indexed *enumerable.Enumerable
77 seq seqid.ID
78)
79
80// Init opens the sale. The caller becomes the admin and the owner of every mint. No coins.
81func Init(cur realm) {
82 if !cur.Previous().IsUserCall() {
83 panic("fishdrop: eoa")
84 }
85 takeExact(0)
86 if inited {
87 panic("fishdrop: init")
88 }
89 inited = true
90 self = cur.Address()
91 admin = cur.Previous().Address()
92 listed = map[address]bool{}
93 roster = []fish{{}}
94 tok, led := grc721.NewToken("GnoFish", "GFISH", seq.Next(), cur)
95 token = tok
96 ledger = led
97 teller = led.CallerTeller()
98 indexed, _ = enumerable.NewEnumerable(led)
99 chain.Emit("Init", "admin", admin.String())
100}
101
102// Grant lets that wallet mint without paying. Admin only. There is no per-address cap.
103func Grant(cur realm, who address) {
104 adminOnly(cur)
105 if !who.IsValid() {
106 panic("fishdrop: addr")
107 }
108 listed[who] = true
109 chain.Emit("Grant", "who", who.String())
110}
111
112// Revoke removes the free mint.
113func Revoke(cur realm, who address) {
114 adminOnly(cur)
115 if !who.IsValid() {
116 panic("fishdrop: addr")
117 }
118 listed[who] = false
119 chain.Emit("Revoke", "who", who.String())
120}
121
122// TransferAdmin moves the wallet that receives NFTs and GNOT.
123func TransferAdmin(cur realm, next address) {
124 adminOnly(cur)
125 if !next.IsValid() || next == admin {
126 panic("fishdrop: addr")
127 }
128 admin = next
129 chain.Emit("Admin", "who", next.String())
130}
131
132// Mint rolls one fish and gives it to the admin. A listed wallet sends nothing. Everyone else sends 100 GNOT, and that full amount goes to the admin.
133func Mint(cur realm) int {
134 if !cur.Previous().IsUserCall() {
135 panic("fishdrop: eoa")
136 }
137 if !inited {
138 panic("fishdrop: init")
139 }
140 caller := cur.Previous().Address()
141 free := listed[caller]
142 if free {
143 takeExact(0)
144 } else {
145 takeExact(mintPrice)
146 }
147 if minted >= maxMint {
148 panic("fishdrop: mint")
149 }
150 height := runtime.ChainHeight()
151 if height < 1 {
152 panic("fishdrop: mint")
153 }
154 id := grow()
155 row := &roster[id]
156 rarity, trait, bite, hp, speed, tier := Roll(caller, height, id)
157 row.owner = admin
158 row.payer = caller
159 row.rarity = rarity
160 row.trait = trait
161 row.bite = bite
162 row.hp = hp
163 row.speed = speed
164 row.height = height
165 row.name = pickName(tier)
166 row.file = portrait[tier]
167 row.revealed = true
168 if err := ledger.Mint(admin, grc721.TokenID(strconv.Itoa(id))); err != nil {
169 panic("fishdrop: mint")
170 }
171 if !free {
172 send(admin, mintPrice, cur)
173 }
174 chain.Emit("Mint", "id", strconv.Itoa(id), "payer", caller.String(), "owner", admin.String(), "rarity", rarity, "file", row.file)
175 return id
176}
177
178// Transfer moves a fish the caller owns. The call sends no coins.
179func Transfer(cur realm, to address, id int) {
180 from := gate(cur)
181 move(cur, from, to, id)
182}
183
184// TransferFrom moves a fish. The caller must own it or be approved for it.
185func TransferFrom(cur realm, from, to address, id int) {
186 gate(cur)
187 move(cur, from, to, id)
188}
189
190// Approve lets to move this fish once. The caller must own it.
191func Approve(cur realm, to address, id int) {
192 gate(cur)
193 if err := teller.Approve(0, cur, to, tidOf(id)); err != nil {
194 panic("fishdrop: approve")
195 }
196}
197
198// SetApprovalForAll lets operator move every fish of the caller, or revokes it.
199func SetApprovalForAll(cur realm, operator address, approved bool) {
200 gate(cur)
201 if err := teller.SetApprovalForAll(0, cur, operator, approved); err != nil {
202 panic("fishdrop: approve")
203 }
204}
205
206// List escrows the fish to this realm and puts it on sale. The caller sends no coins.
207func List(cur realm, id int, priceUgnot int64) {
208 caller := gate(cur)
209 row := must(id)
210 if priceUgnot <= 0 || priceUgnot > maxAsk || !row.revealed || row.onSale || row.owner != caller {
211 panic("fishdrop: list")
212 }
213 ledgerMove(caller, caller, self, id)
214 row.onSale = true
215 row.seller = caller
216 row.ask = priceUgnot
217 row.owner = self
218 chain.Emit("List", "id", strconv.Itoa(id), "seller", caller.String(), "price", strconv.FormatInt(priceUgnot, 10))
219}
220
221// UpdatePrice changes the ask of a fish the caller is selling.
222func UpdatePrice(cur realm, id int, priceUgnot int64) {
223 caller := gate(cur)
224 row := must(id)
225 if priceUgnot <= 0 || priceUgnot > maxAsk || !row.onSale || row.seller != caller {
226 panic("fishdrop: list")
227 }
228 row.ask = priceUgnot
229 chain.Emit("List", "id", strconv.Itoa(id), "seller", caller.String(), "price", strconv.FormatInt(priceUgnot, 10))
230}
231
232// Cancel returns a listed fish to its seller. The call sends no coins.
233func Cancel(cur realm, id int) {
234 caller := gate(cur)
235 row := must(id)
236 if !row.onSale || row.seller != caller {
237 panic("fishdrop: list")
238 }
239 ledgerMove(self, self, caller, id)
240 row.onSale = false
241 row.owner = caller
242 row.seller = address("")
243 row.ask = 0
244 chain.Emit("Cancel", "id", strconv.Itoa(id))
245}
246
247// Buy pays the ask, in full, to the seller and gives the fish to the buyer.
248func Buy(cur realm, id int) {
249 if !cur.Previous().IsUserCall() {
250 panic("fishdrop: eoa")
251 }
252 if !inited {
253 panic("fishdrop: init")
254 }
255 row := must(id)
256 buyer := cur.Previous().Address()
257 if !row.onSale || buyer == row.seller {
258 panic("fishdrop: list")
259 }
260 price := row.ask
261 seller := row.seller
262 takeExact(price)
263 ledgerMove(self, self, buyer, id)
264 row.onSale = false
265 row.owner = buyer
266 row.seller = address("")
267 row.ask = 0
268 send(seller, price, cur)
269 chain.Emit("Buy", "id", strconv.Itoa(id), "buyer", buyer.String(), "price", strconv.FormatInt(price, 10))
270}
271
272func Version() string { return "gnofish-1" }
273func Name() string { return "GnoFish" }
274func GrcName() string { return Name() }
275func Symbol() string { return "GFISH" }
276func GrcSymbol() string { return Symbol() }
277func Creator() string {
278 if !inited {
279 return ""
280 }
281 return admin.String()
282}
283func MintPrice() int64 { return mintPrice }
284func MaxMint() int { return maxMint }
285func Minted() int { return minted }
286func Realm() address { return self }
287func Admin() string { return admin.String() }
288func Whitelisted(who string) bool {
289 addr := address(who)
290 return addr.IsValid() && listed[addr]
291}
292
293func Listed(id int) bool { return revealed(id).onSale }
294func PriceOf(id int) int64 { return revealed(id).ask }
295func SellerOf(id int) string {
296 row := revealed(id)
297 if !row.seller.IsValid() {
298 return ""
299 }
300 return row.seller.String()
301}
302
303func ItemLine(id int) string {
304 row := revealed(id)
305 traits := "Species:" + speciesOf(row.rarity) + ";Rarity:" + row.rarity + ";Trait:" + row.trait + ";Bite:" + strconv.Itoa(row.bite) + ";HP:" + strconv.Itoa(row.hp) + ";Speed:" + strconv.Itoa(row.speed)
306 return strconv.Itoa(id) + "|" + row.name + "|" + row.owner.String() + "|" + SellerOf(id) + "|" + strconv.FormatInt(row.ask, 10) + "|" + strconv.FormatBool(row.onSale) + "|" + imageHTTPS + row.file + "|" + slug + "|" + row.rarity + "|" + traits + "|true|-1"
307}
308
309func ListOpen() string {
310 if !inited {
311 return ""
312 }
313 out := ""
314 n := 0
315 for id := 1; id <= minted && n < listCap; id++ {
316 if !roster[id].onSale {
317 continue
318 }
319 if out != "" {
320 out += "\n"
321 }
322 out += ItemLine(id)
323 n++
324 }
325 return out
326}
327
328func TokensOf(owner address) string {
329 if !inited || !owner.IsValid() {
330 return ""
331 }
332 out := ""
333 for id := 1; id <= minted; id++ {
334 if roster[id].owner != owner {
335 continue
336 }
337 if out != "" {
338 out += ","
339 }
340 out += strconv.Itoa(id)
341 }
342 return out
343}
344func PayerOf(id int) string { return revealed(id).payer.String() }
345
346func OwnerOf(tid grc721.TokenID) (address, error) {
347 if token == nil {
348 return "", errors.New("fishdrop: init")
349 }
350 return token.OwnerOf(tid)
351}
352
353func BalanceOf(addr address) (int64, error) {
354 if token == nil {
355 return 0, errors.New("fishdrop: init")
356 }
357 return token.BalanceOf(addr)
358}
359
360func TotalSupply() int64 {
361 if token == nil {
362 return 0
363 }
364 return token.TotalSupply()
365}
366
367func GetApproved(tid grc721.TokenID) (address, error) {
368 if token == nil {
369 return "", errors.New("fishdrop: init")
370 }
371 addr, err := token.GetApproved(tid)
372 if err == grc721.ErrTokenIdNotApproved {
373 return "", nil
374 }
375 return addr, err
376}
377
378func IsApprovedForAll(owner, operator address) bool {
379 if token == nil {
380 return false
381 }
382 return token.IsApprovedForAll(owner, operator)
383}
384
385func TokenByIndex(index int64) (grc721.TokenID, error) {
386 if indexed == nil {
387 return "", errors.New("fishdrop: init")
388 }
389 return indexed.TokenByIndex(index)
390}
391
392func TokenOfOwnerByIndex(owner address, index int64) (grc721.TokenID, error) {
393 if indexed == nil {
394 return "", errors.New("fishdrop: init")
395 }
396 return indexed.TokenOfOwnerByIndex(owner, index)
397}
398
399func RarityOf(id int) string { return revealed(id).rarity }
400func TraitOf(id int) string { return revealed(id).trait }
401func NameOf(id int) string { return revealed(id).name }
402func ImageOf(id int) string { return imageHTTPS + revealed(id).file }
403
404// TokenURI is the https image Adena puts in an img tag.
405func TokenURI(tid grc721.TokenID) (string, error) {
406 row, err := byToken(tid)
407 if err != nil {
408 return "", err
409 }
410 return imageHTTPS + row.file, nil
411}
412
413// TokenMetadata is the JSON Adena reads. Attribute keys are traitType.
414func TokenMetadata(tid grc721.TokenID) (string, error) {
415 row, err := byToken(tid)
416 if err != nil {
417 return "", err
418 }
419 return `{"name":"` + row.name + `","description":"` + row.name + `, a ` + row.rarity + ` GnoFish.","image":"` + imageHTTPS + row.file + `","attributes":[` +
420 attr("species", speciesOf(row.rarity)) + `,` +
421 attr("rarity", row.rarity) + `,` +
422 attr("trait", row.trait) + `,` +
423 attr("bite", strconv.Itoa(row.bite)) + `,` +
424 attr("hp", strconv.Itoa(row.hp)) + `,` +
425 attr("speed", strconv.Itoa(row.speed)) + `,` +
426 attr("ipfs", imageIPFS+row.file) + `]}`, nil
427}
428
429// Roll is sha256 of caller|height|id.
430// mythic 1/10000, legendary 3, epic 10, rare 40, uncommon 200, the rest common.
431func Roll(caller address, height int64, id int) (rarity string, trait string, bite int, hp int, speed int, tier int) {
432 if id < 1 || height < 1 {
433 panic("fishdrop: roll")
434 }
435 raw := caller.String() + "|" + strconv.FormatInt(height, 10) + "|" + strconv.Itoa(id)
436 sum := sha256.Sum256([]byte(raw))
437 n := (int(sum[0])<<8 | int(sum[1])) % 10000
438 tier = 0
439 if n == 0 {
440 tier = 5
441 } else if n < 4 {
442 tier = 4
443 } else if n < 14 {
444 tier = 3
445 } else if n < 54 {
446 tier = 2
447 } else if n < 254 {
448 tier = 1
449 }
450 kind := int(sum[2]) % 3
451 bite = biteBase[tier]
452 hp = hpBase[tier]
453 speed = spdBase[tier]
454 if kind == 0 {
455 speed = (speed*112 + 50) / 100
456 } else if kind == 1 {
457 bite = (bite*112 + 50) / 100
458 } else {
459 hp = (hp*112 + 50) / 100
460 }
461 return rarities[tier], traits[kind], bite, hp, speed, tier
462}
463
464func Render(path string) string {
465 if path != "" {
466 return "# GnoFish\n\nUnknown path.\n"
467 }
468 if !inited {
469 return "# GnoFish\n\nNot opened.\n"
470 }
471 return "# GnoFish\n\nWhitelist mint is free. Public mint is 100 GNOT, paid entirely to the admin. Every NFT is minted to the admin and revealed in that same call. An address may mint more than once. This realm does not add liquidity.\n"
472}
473
474func adminOnly(cur realm) {
475 if !cur.Previous().IsUserCall() {
476 panic("fishdrop: eoa")
477 }
478 takeExact(0)
479 if !inited || cur.Previous().Address() != admin {
480 panic("fishdrop: admin")
481 }
482}
483
484func gate(cur realm) address {
485 if !cur.Previous().IsUserCall() {
486 panic("fishdrop: eoa")
487 }
488 takeExact(0)
489 if !inited {
490 panic("fishdrop: init")
491 }
492 return cur.Previous().Address()
493}
494
495func send(to address, amt int64, rlm realm) {
496 if !rlm.IsCurrent() {
497 panic("fishdrop: realm")
498 }
499 if amt == 0 {
500 return
501 }
502 bk := banker.NewBanker(banker.BankerTypeRealmSend, rlm)
503 bk.SendCoins(rlm.Address(), to, chain.Coins{{Denom: denom, Amount: amt}})
504}
505
506func grow() int {
507 if minted >= maxMint || len(roster) != minted+1 {
508 panic("fishdrop: mint")
509 }
510 roster = append(roster, fish{})
511 minted++
512 return minted
513}
514
515func must(id int) *fish {
516 if !inited || id < 1 || id > minted || id >= len(roster) {
517 panic("fishdrop: fish")
518 }
519 return &roster[id]
520}
521
522func revealed(id int) *fish {
523 row := must(id)
524 if !row.revealed {
525 panic("fishdrop: uri")
526 }
527 return row
528}
529
530func pickName(tier int) string {
531 base := species[tier]
532 if !nameTaken(base) {
533 return base
534 }
535 for n := 2; n <= maxMint; n++ {
536 candidate := base + strconv.Itoa(n)
537 if !nameTaken(candidate) {
538 return candidate
539 }
540 }
541 panic("fishdrop: name")
542}
543
544func nameTaken(name string) bool {
545 for i := 1; i <= minted; i++ {
546 if roster[i].revealed && roster[i].name == name {
547 return true
548 }
549 }
550 return false
551}
552
553func move(cur realm, from, to address, id int) {
554 row := must(id)
555 if row.onSale || !row.revealed || !to.IsValid() || to == from {
556 panic("fishdrop: transfer")
557 }
558 if err := teller.TransferFrom(0, cur, from, to, tidOf(id)); err != nil {
559 panic("fishdrop: transfer")
560 }
561 row.owner = to
562}
563
564func ledgerMove(spender, from, to address, id int) {
565 if err := ledger.TransferFrom(spender, from, to, tidOf(id)); err != nil {
566 panic("fishdrop: transfer")
567 }
568}
569
570func tidOf(id int) grc721.TokenID {
571 return grc721.TokenID(strconv.Itoa(id))
572}
573
574func byToken(tid grc721.TokenID) (*fish, error) {
575 if token == nil {
576 return nil, errors.New("fishdrop: init")
577 }
578 if _, err := token.OwnerOf(tid); err != nil {
579 return nil, err
580 }
581 id, err := strconv.Atoi(string(tid))
582 if err != nil || id < 1 || id > minted || id >= len(roster) || !roster[id].revealed {
583 return nil, errors.New("fishdrop: fish")
584 }
585 return &roster[id], nil
586}
587
588func speciesOf(rarity string) string {
589 for i := 0; i < len(rarities); i++ {
590 if rarities[i] == rarity {
591 return species[i]
592 }
593 }
594 return ""
595}
596
597func attr(key, value string) string {
598 return `{"traitType":"` + key + `","value":"` + value + `"}`
599}
600
601func takeExact(n int64) {
602 sent := unsafe.OriginSend()
603 if n == 0 {
604 if len(sent) != 0 {
605 panic("fishdrop: payment")
606 }
607 return
608 }
609 if len(sent) != 1 || sent[0].Denom != denom || sent[0].Amount != n {
610 panic("fishdrop: payment")
611 }
612}
613