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.

seafish.gno

20.42 Kb · 739 lines
  1// Package seafish is the tradable person-versus-person sea.
  2// CommitMint pays 150 GNOT and the whole fee goes to the wallet that called Init.
  3// RevealMint, once the chain is at least two blocks later, rolls rarity and trait
  4// from sha256 of the caller, the commit height, that fixed height plus two, and the id.
  5// Waiting longer does not change the roll. The player does not pass trait, size, or rarity.
  6// The revealed fish is a GRC721 owned by that player. TokenURI is the https portrait.
  7// Swim stays off chain. Register locks at least 10 GNOT. Leave returns it after 5 seconds.
  8// Settle pays one covered stake: 80% winner, 10% liquidity, 10% team. Dust stays with the team.
  9// There is no machine fish and no FISH draw. A seated, staked, or healing fish cannot be
 10// listed or transferred. List, UpdatePrice, Cancel, and Buy are the Nomaq trade.
 11// Buy pays the ask, in full, to the seller. RegisterCollection publishes the slug on bazaarv5.
 12// fishsea, fishnft, gnofish, and fishlive stay as deployed. This package does not move them.
 13// seafish-1. The mint cap is 10000. One wallet still seats only one fish.
 14package seafish
 15
 16import (
 17	"chain"
 18	"chain/banker"
 19	"chain/runtime"
 20	"chain/runtime/unsafe"
 21	"crypto/sha256"
 22	"errors"
 23	"strconv"
 24	"time"
 25
 26	"gno.land/p/nt/grc721/enumerable/v0"
 27	"gno.land/p/nt/grc721/v0"
 28	"gno.land/p/nt/seqid/v0"
 29	factory "gno.land/r/g1n4pl5uc4yt5r96m9w6fmdznx3x0jyg8l6arhmt/bazaar/bazaarv5"
 30)
 31
 32const (
 33	denom            = "ugnot"
 34	mintFee    int64 = 150_000_000
 35	stakeMin   int64 = 10_000_000
 36	quietMs    int64 = 5_000
 37	maxMint          = 10_000
 38	maxAsk     int64 = 100_000_000_000_000
 39	listCap          = 50
 40	slug             = "seafish"
 41	imageIPFS        = "ipfs://bafybeifdmgbtq3nf7xuepvygotk6ulk4wo7xshsi2yr4xukglsdpzz7p2i/"
 42	imageHTTPS       = "https://gateway.pinata.cloud/ipfs/bafybeifdmgbtq3nf7xuepvygotk6ulk4wo7xshsi2yr4xukglsdpzz7p2i/"
 43	cover            = imageHTTPS + "common-fish.jpg"
 44)
 45
 46var (
 47	hpBase   = [6]int{100, 122, 148, 178, 214, 256}
 48	biteBase = [6]int{24, 30, 37, 45, 54, 66}
 49	spdBase  = [6]int{108, 104, 100, 96, 92, 88}
 50	species  = [6]string{"Bubble", "Tideblade", "Abysswing", "Embermaw", "Aurelion", "Astravex"}
 51	rarities = [6]string{"common", "uncommon", "rare", "epic", "legendary", "mythic"}
 52	portrait = [6]string{"common-fish.jpg", "uncommon-fish.jpg", "race-fish.jpg", "epic-fish.jpg", "legendary-fish.jpg", "mythic-fish.jpg"}
 53	traits   = [3]string{"swift", "fang", "hide"}
 54)
 55
 56type fish struct {
 57	owner    address
 58	name     string
 59	rarity   string
 60	trait    string
 61	file     string
 62	bite     int
 63	hpMax    int
 64	hp       int
 65	speed    int
 66	pool     int64
 67	bouts    int
 68	commitH  int64
 69	touched  int64
 70	inSea    bool
 71	revealed bool
 72	mustHeal bool
 73	seller   address
 74	ask      int64
 75	onSale   bool
 76}
 77
 78var (
 79	inited  bool
 80	market  bool
 81	self    address
 82	mintTo  address
 83	lpSink  address
 84	team    address
 85	minted  int
 86	shown   int
 87	roster  []fish
 88	token   *grc721.Token
 89	ledger  *grc721.PrivateLedger
 90	teller  grc721.Teller
 91	indexed *enumerable.Enumerable
 92	seq     seqid.ID
 93)
 94
 95// Init records the caller as the mint wallet, plus liquidity and team.
 96// Those three must be distinct. Call once, from an EOA, with no coins.
 97func Init(cur realm, lp, crew address) {
 98	if !cur.Previous().IsUserCall() {
 99		panic("seafish: eoa")
100	}
101	takeExact(0)
102	caller := cur.Previous().Address()
103	if inited || !caller.IsValid() || !lp.IsValid() || !crew.IsValid() || caller == lp || caller == crew || lp == crew {
104		panic("seafish: init")
105	}
106	inited = true
107	self = cur.Address()
108	mintTo = caller
109	lpSink = lp
110	team = crew
111	roster = []fish{{}}
112	tok, led := grc721.NewToken("GnoFish Sea", "GSEA", seq.Next(), cur)
113	token = tok
114	ledger = led
115	teller = led.CallerTeller()
116	indexed, _ = enumerable.NewEnumerable(led)
117	chain.Emit("Init", "mint", caller.String(), "lp", lp.String(), "team", crew.String())
118}
119
120// CommitMint sells one unrevealed fish for 150 GNOT. The fee leaves this realm in the same call.
121// The player owns the fish. The GRC721 is minted at RevealMint, when the portrait exists.
122func CommitMint(cur realm) int {
123	caller := gate(cur, mintFee)
124	if minted >= maxMint || pending(caller) != 0 {
125		panic("seafish: mint")
126	}
127	height := runtime.ChainHeight()
128	if height < 1 {
129		panic("seafish: mint")
130	}
131	id := grow()
132	roster[id].owner = caller
133	roster[id].commitH = height
134	payMint(mintFee, cur)
135	chain.Emit("Commit", "id", strconv.Itoa(id), "owner", caller.String())
136	return id
137}
138
139// RevealMint assigns rarity, trait, the species name, and the portrait.
140// Entropy is the commit height plus two. The NFT is minted to the caller.
141func RevealMint(cur realm, id int) {
142	caller := gate(cur, 0)
143	row := mustFish(id)
144	entropyH := row.commitH + 2
145	if row.owner != caller || row.revealed || runtime.ChainHeight() < entropyH {
146		panic("seafish: reveal")
147	}
148	rarity, trait, bite, hp, speed := Roll(caller, row.commitH, entropyH, id)
149	row.rarity = rarity
150	row.trait = trait
151	row.bite = bite
152	row.hpMax = hp
153	row.hp = hp
154	row.speed = speed
155	row.name = pickName(tierOf(rarity))
156	row.file = portrait[tierOf(rarity)]
157	row.revealed = true
158	if err := ledger.Mint(caller, tidOf(id)); err != nil {
159		panic("seafish: mint")
160	}
161	shown++
162	chain.Emit("Reveal", "id", strconv.Itoa(id), "rarity", rarity, "trait", trait, "name", row.name, "file", row.file)
163	noteSupply(cur)
164}
165
166// Register seats one revealed fish the caller still owns. The attached coins are the stake.
167// A listed fish, or a second fish from a wallet that is already seated, is refused.
168func Register(cur realm, id int, stake int64) {
169	caller := gate(cur, stake)
170	row := mustFish(id)
171	if stake < stakeMin || row.owner != caller || !row.revealed || row.inSea || row.mustHeal || row.onSale || row.pool != 0 || seated(caller) {
172		panic("seafish: register")
173	}
174	row.pool = stake
175	row.inSea = true
176	row.touched = time.Now().UnixMilli()
177	chain.Emit("Register", "id", strconv.Itoa(id), "stake", strconv.FormatInt(stake, 10))
178}
179
180// Leave returns the seated stake once 5 seconds have passed since Register.
181func Leave(cur realm, id int) {
182	caller := gate(cur, 0)
183	row := mustFish(id)
184	if row.owner != caller || !row.inSea || time.Now().UnixMilli() < row.touched+quietMs {
185		panic("seafish: leave")
186	}
187	stake := row.pool
188	row.pool = 0
189	row.inSea = false
190	send(caller, stake, cur)
191	chain.Emit("Leave", "id", strconv.Itoa(id), "stake", strconv.FormatInt(stake, 10))
192}
193
194// Settle pays the smaller stake. The NFT stays. HP falls to 30% and must heal.
195// The caller has to own the killer. This realm does not check who bit whom.
196func Settle(cur realm, id int, target int) {
197	caller := gate(cur, 0)
198	if id == target {
199		panic("seafish: settle")
200	}
201	atk := mustFish(id)
202	vic := mustFish(target)
203	if atk.owner != caller || !atk.inSea || !vic.inSea || atk.owner == vic.owner || atk.pool < stakeMin || vic.pool < stakeMin {
204		panic("seafish: settle")
205	}
206	covered := atk.pool
207	if vic.pool < covered {
208		covered = vic.pool
209	}
210	excess := vic.pool - covered
211	owner := vic.owner
212	vic.pool = 0
213	vic.inSea = false
214	vic.mustHeal = true
215	vic.hp = vic.hpMax * 30 / 100
216	if vic.hp < 1 {
217		vic.hp = 1
218	}
219	atk.bouts++
220	vic.bouts++
221	killer, lp, crew := splitKill(covered)
222	send(atk.owner, killer, cur)
223	send(lpSink, lp, cur)
224	send(team, crew, cur)
225	send(owner, excess, cur)
226	chain.Emit("Kill", "id", strconv.Itoa(id), "target", strconv.Itoa(target), "covered", strconv.FormatInt(covered, 10))
227}
228
229// Heal clears mustHeal outside the sea so Register, List, and Transfer can run again.
230func Heal(cur realm, id int) {
231	caller := gate(cur, 0)
232	row := mustFish(id)
233	if row.owner != caller || row.inSea || row.onSale || !row.mustHeal || !row.revealed {
234		panic("seafish: heal")
235	}
236	row.mustHeal = false
237	row.hp = row.hpMax
238	chain.Emit("Heal", "id", strconv.Itoa(id))
239}
240
241// Transfer moves a fish the caller owns. A seated, staked, healing, or listed fish stays put.
242func Transfer(cur realm, to address, id int) {
243	from := gate(cur, 0)
244	move(cur, from, to, id)
245}
246
247// TransferFrom moves a fish. The caller must own it or be approved for it.
248func TransferFrom(cur realm, from, to address, id int) {
249	gate(cur, 0)
250	move(cur, from, to, id)
251}
252
253// Approve lets to move this fish once. The caller must own it. The call sends no coins.
254func Approve(cur realm, to address, id int) {
255	gate(cur, 0)
256	if err := teller.Approve(0, cur, to, tidOf(id)); err != nil {
257		panic("seafish: approve")
258	}
259}
260
261// SetApprovalForAll lets operator move every fish of the caller, or revokes it.
262func SetApprovalForAll(cur realm, operator address, approved bool) {
263	gate(cur, 0)
264	if err := teller.SetApprovalForAll(0, cur, operator, approved); err != nil {
265		panic("seafish: approve")
266	}
267}
268
269// List escrows the fish to this realm and puts it on sale. The caller sends no coins.
270// Refused while the fish is seated, still holds a stake, or must heal.
271func List(cur realm, id int, priceUgnot int64) {
272	caller := gate(cur, 0)
273	row := mustFish(id)
274	if priceUgnot <= 0 || priceUgnot > maxAsk || !row.revealed || row.onSale || row.owner != caller || busy(row) {
275		panic("seafish: list")
276	}
277	ledgerMove(caller, caller, self, id)
278	row.onSale = true
279	row.seller = caller
280	row.ask = priceUgnot
281	row.owner = self
282	chain.Emit("List", "id", strconv.Itoa(id), "seller", caller.String(), "price", strconv.FormatInt(priceUgnot, 10))
283}
284
285// UpdatePrice changes the ask of a fish the caller is selling.
286func UpdatePrice(cur realm, id int, priceUgnot int64) {
287	caller := gate(cur, 0)
288	row := mustFish(id)
289	if priceUgnot <= 0 || priceUgnot > maxAsk || !row.onSale || row.seller != caller {
290		panic("seafish: list")
291	}
292	row.ask = priceUgnot
293	chain.Emit("List", "id", strconv.Itoa(id), "seller", caller.String(), "price", strconv.FormatInt(priceUgnot, 10))
294}
295
296// Cancel returns a listed fish to its seller. The call sends no coins.
297func Cancel(cur realm, id int) {
298	caller := gate(cur, 0)
299	row := mustFish(id)
300	if !row.onSale || row.seller != caller {
301		panic("seafish: list")
302	}
303	ledgerMove(self, self, caller, id)
304	row.onSale = false
305	row.owner = caller
306	row.seller = address("")
307	row.ask = 0
308	chain.Emit("Cancel", "id", strconv.Itoa(id))
309}
310
311// Buy pays the ask, in full, to the seller and gives the fish to the buyer.
312func Buy(cur realm, id int) {
313	if !cur.Previous().IsUserCall() {
314		panic("seafish: eoa")
315	}
316	if !inited {
317		panic("seafish: init")
318	}
319	row := mustFish(id)
320	buyer := cur.Previous().Address()
321	if !row.onSale || buyer == row.seller {
322		panic("seafish: list")
323	}
324	price := row.ask
325	seller := row.seller
326	takeExact(price)
327	ledgerMove(self, self, buyer, id)
328	row.onSale = false
329	row.owner = buyer
330	row.seller = address("")
331	row.ask = 0
332	send(seller, price, cur)
333	chain.Emit("Buy", "id", strconv.Itoa(id), "buyer", buyer.String(), "price", strconv.FormatInt(price, 10))
334}
335
336// RegisterCollection publishes this realm on bazaarv5. The caller must be the Init wallet,
337// the same wallet that reserved slug seafish, and must send no coins. Call it once, after Init.
338func RegisterCollection(cur realm) {
339	caller := gate(cur, 0)
340	if caller != mintTo || market {
341		panic("seafish: admin")
342	}
343	factory.Register(cross(cur), "GnoFish Sea", "GSEA", cover, maxMint, mintFee, 0)
344	market = true
345	factory.NoteMinted(cross(cur), shown)
346}
347
348// Roll is the reveal hash. entropyH must be commitH+2. Trait adds 12%, half up.
349func Roll(caller address, commitH int64, entropyH int64, id int) (rarity string, trait string, bite int, hp int, speed int) {
350	if id < 1 || commitH < 1 || entropyH != commitH+2 {
351		panic("seafish: roll")
352	}
353	raw := caller.String() + "|" + strconv.FormatInt(commitH, 10) + "|" + strconv.FormatInt(entropyH, 10) + "|" + strconv.Itoa(id)
354	sum := sha256.Sum256([]byte(raw))
355	n := (int(sum[0])<<8 | int(sum[1])) % 10000
356	tier := 0
357	if n == 0 {
358		tier = 5
359	} else if n < 4 {
360		tier = 4
361	} else if n < 14 {
362		tier = 3
363	} else if n < 54 {
364		tier = 2
365	} else if n < 254 {
366		tier = 1
367	}
368	kind := int(sum[2]) % 3
369	bite = biteBase[tier]
370	hp = hpBase[tier]
371	speed = spdBase[tier]
372	if kind == 0 {
373		speed = (speed*112 + 50) / 100
374	} else if kind == 1 {
375		bite = (bite*112 + 50) / 100
376	} else {
377		hp = (hp*112 + 50) / 100
378	}
379	return rarities[tier], traits[kind], bite, hp, speed
380}
381
382func Realm() address     { return self }
383func MintTo() address    { return mintTo }
384func Liquidity() address { return lpSink }
385func Team() address      { return team }
386func Version() string    { return "seafish-1" }
387func Name() string       { return "GnoFish Sea" }
388func GrcName() string    { return Name() }
389func Symbol() string     { return "GSEA" }
390func GrcSymbol() string  { return Symbol() }
391func MintPrice() int64   { return mintFee }
392func StakeMin() int64    { return stakeMin }
393func MaxMint() int       { return maxMint }
394func Minted() int        { return minted }
395func Shown() int         { return shown }
396func MarketOpen() bool   { return market }
397func Height() int64      { return runtime.ChainHeight() }
398
399func TraitOf(id int) string  { return mustFish(id).trait }
400func RarityOf(id int) string { return mustFish(id).rarity }
401func SizeOf(id int) string {
402	mustFish(id)
403	return "medium"
404}
405func BoutsOf(id int) int    { return mustFish(id).bouts }
406func InMatch(id int) bool   { return mustFish(id).inSea }
407func PoolOf(id int) int64   { return mustFish(id).pool }
408func Revealed(id int) bool  { return mustFish(id).revealed }
409func MustHeal(id int) bool  { return mustFish(id).mustHeal }
410func CommitOf(id int) int64 { return mustFish(id).commitH }
411func HpOf(id int) int       { return mustFish(id).hp }
412func BiteOf(id int) int     { return mustFish(id).bite }
413func SpeedOf(id int) int    { return mustFish(id).speed }
414func NameOf(id int) string  { return mustFish(id).name }
415func Listed(id int) bool    { return revealedFish(id).onSale }
416func PriceOf(id int) int64  { return revealedFish(id).ask }
417func ImageOf(id int) string { return imageHTTPS + revealedFish(id).file }
418
419func SellerOf(id int) string {
420	row := revealedFish(id)
421	if !row.seller.IsValid() {
422		return ""
423	}
424	return row.seller.String()
425}
426
427func ItemLine(id int) string {
428	row := revealedFish(id)
429	line := "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)
430	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 + "|" + line + "|true|-1"
431}
432
433func ListOpen() string {
434	if !inited {
435		return ""
436	}
437	out := ""
438	n := 0
439	for id := 1; id <= minted && n < listCap; id++ {
440		if !roster[id].onSale {
441			continue
442		}
443		if out != "" {
444			out += "\n"
445		}
446		out += ItemLine(id)
447		n++
448	}
449	return out
450}
451
452func TokensOf(owner address) string {
453	if !inited || !owner.IsValid() {
454		return ""
455	}
456	out := ""
457	for id := 1; id <= minted; id++ {
458		if roster[id].owner != owner {
459			continue
460		}
461		if out != "" {
462			out += ","
463		}
464		out += strconv.Itoa(id)
465	}
466	return out
467}
468
469func Pending(owner string) int {
470	who := address(owner)
471	if !who.IsValid() {
472		return 0
473	}
474	return pending(who)
475}
476
477func OwnerOf(tid grc721.TokenID) (address, error) {
478	if token == nil {
479		return "", errors.New("seafish: init")
480	}
481	return token.OwnerOf(tid)
482}
483
484func BalanceOf(addr address) (int64, error) {
485	if token == nil {
486		return 0, errors.New("seafish: init")
487	}
488	return token.BalanceOf(addr)
489}
490
491func TotalSupply() int64 {
492	if token == nil {
493		return 0
494	}
495	return token.TotalSupply()
496}
497
498func GetApproved(tid grc721.TokenID) (address, error) {
499	if token == nil {
500		return "", errors.New("seafish: init")
501	}
502	addr, err := token.GetApproved(tid)
503	if err == grc721.ErrTokenIdNotApproved {
504		return "", nil
505	}
506	return addr, err
507}
508
509func IsApprovedForAll(owner, operator address) bool {
510	if token == nil {
511		return false
512	}
513	return token.IsApprovedForAll(owner, operator)
514}
515
516func TokenByIndex(index int64) (grc721.TokenID, error) {
517	if indexed == nil {
518		return "", errors.New("seafish: init")
519	}
520	return indexed.TokenByIndex(index)
521}
522
523func TokenOfOwnerByIndex(owner address, index int64) (grc721.TokenID, error) {
524	if indexed == nil {
525		return "", errors.New("seafish: init")
526	}
527	return indexed.TokenOfOwnerByIndex(owner, index)
528}
529
530// TokenURI is the https image Adena puts in an img tag.
531func TokenURI(tid grc721.TokenID) (string, error) {
532	row, err := byToken(tid)
533	if err != nil {
534		return "", err
535	}
536	return imageHTTPS + row.file, nil
537}
538
539// TokenMetadata is the JSON Adena reads. Attribute keys are traitType.
540func TokenMetadata(tid grc721.TokenID) (string, error) {
541	row, err := byToken(tid)
542	if err != nil {
543		return "", err
544	}
545	return `{"name":"` + row.name + `","description":"` + row.name + `, a ` + row.rarity + ` GnoFish.","image":"` + imageHTTPS + row.file + `","attributes":[` +
546		attr("species", speciesOf(row.rarity)) + `,` +
547		attr("rarity", row.rarity) + `,` +
548		attr("trait", row.trait) + `,` +
549		attr("bite", strconv.Itoa(row.bite)) + `,` +
550		attr("hp", strconv.Itoa(row.hp)) + `,` +
551		attr("speed", strconv.Itoa(row.speed)) + `,` +
552		attr("ipfs", imageIPFS+row.file) + `]}`, nil
553}
554
555func Render(path string) string {
556	if path != "" {
557		return "# GnoFish Sea\n\nUnknown path.\n"
558	}
559	return "# GnoFish Sea\n\nPerson versus person. The mint fee goes to the deploy wallet. A win pays 80% to the winner, 10% to liquidity, and 10% to the team. Portraits are https images. A seated fish cannot be listed.\n"
560}
561
562func gate(cur realm, pay int64) address {
563	if !cur.Previous().IsUserCall() {
564		panic("seafish: eoa")
565	}
566	if !inited || self == address("") {
567		panic("seafish: init")
568	}
569	takeExact(pay)
570	return cur.Previous().Address()
571}
572
573func payMint(fee int64, rlm realm) {
574	send(mintTo, fee, rlm)
575}
576
577func splitKill(covered int64) (killer int64, lp int64, crew int64) {
578	killer = covered * 80 / 100
579	lp = covered * 10 / 100
580	crew = covered - killer - lp
581	return
582}
583
584func send(to address, amt int64, rlm realm) {
585	if !rlm.IsCurrent() {
586		panic("seafish: realm")
587	}
588	if amt == 0 {
589		return
590	}
591	bk := banker.NewBanker(banker.BankerTypeRealmSend, rlm)
592	bk.SendCoins(rlm.Address(), to, chain.Coins{{Denom: denom, Amount: amt}})
593}
594
595func noteSupply(cur realm) {
596	if !market {
597		return
598	}
599	factory.NoteMinted(cross(cur), shown)
600}
601
602func grow() int {
603	if minted >= maxMint || len(roster) != minted+1 {
604		panic("seafish: mint")
605	}
606	roster = append(roster, fish{})
607	minted++
608	return minted
609}
610
611func mustFish(id int) *fish {
612	if !inited || id < 1 || id > minted || id >= len(roster) {
613		panic("seafish: fish")
614	}
615	return &roster[id]
616}
617
618func revealedFish(id int) *fish {
619	row := mustFish(id)
620	if !row.revealed {
621		panic("seafish: uri")
622	}
623	return row
624}
625
626func pending(who address) int {
627	for i := 1; i <= minted; i++ {
628		if roster[i].owner == who && !roster[i].revealed {
629			return i
630		}
631	}
632	return 0
633}
634
635func seated(who address) bool {
636	for i := 1; i <= minted; i++ {
637		if roster[i].owner == who && roster[i].inSea {
638			return true
639		}
640	}
641	return false
642}
643
644func busy(row *fish) bool {
645	return row.inSea || row.pool != 0 || row.mustHeal
646}
647
648func pickName(tier int) string {
649	base := species[tier]
650	if !nameTaken(base) {
651		return base
652	}
653	for n := 2; n <= maxMint; n++ {
654		candidate := base + strconv.Itoa(n)
655		if !nameTaken(candidate) {
656			return candidate
657		}
658	}
659	panic("seafish: name")
660}
661
662func nameTaken(name string) bool {
663	for i := 1; i <= minted; i++ {
664		if roster[i].revealed && roster[i].name == name {
665			return true
666		}
667	}
668	return false
669}
670
671func tierOf(rarity string) int {
672	for i := 0; i < len(rarities); i++ {
673		if rarities[i] == rarity {
674			return i
675		}
676	}
677	panic("seafish: roll")
678}
679
680func move(cur realm, from, to address, id int) {
681	row := mustFish(id)
682	if row.onSale || !row.revealed || busy(row) || !to.IsValid() || to == from {
683		panic("seafish: transfer")
684	}
685	if err := teller.TransferFrom(0, cur, from, to, tidOf(id)); err != nil {
686		panic("seafish: transfer")
687	}
688	row.owner = to
689}
690
691func ledgerMove(spender, from, to address, id int) {
692	if err := ledger.TransferFrom(spender, from, to, tidOf(id)); err != nil {
693		panic("seafish: transfer")
694	}
695}
696
697func tidOf(id int) grc721.TokenID {
698	return grc721.TokenID(strconv.Itoa(id))
699}
700
701func byToken(tid grc721.TokenID) (*fish, error) {
702	if token == nil {
703		return nil, errors.New("seafish: init")
704	}
705	if _, err := token.OwnerOf(tid); err != nil {
706		return nil, err
707	}
708	id, err := strconv.Atoi(string(tid))
709	if err != nil || id < 1 || id > minted || id >= len(roster) || !roster[id].revealed {
710		return nil, errors.New("seafish: fish")
711	}
712	return &roster[id], nil
713}
714
715func speciesOf(rarity string) string {
716	for i := 0; i < len(rarities); i++ {
717		if rarities[i] == rarity {
718			return species[i]
719		}
720	}
721	return ""
722}
723
724func attr(key, value string) string {
725	return `{"traitType":"` + key + `","value":"` + value + `"}`
726}
727
728func takeExact(n int64) {
729	sent := unsafe.OriginSend()
730	if n == 0 {
731		if len(sent) != 0 {
732			panic("seafish: payment")
733		}
734		return
735	}
736	if len(sent) != 1 || sent[0].Denom != denom || sent[0].Amount != n {
737		panic("seafish: payment")
738	}
739}