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.

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