skins.gno
5.00 Kb · 178 lines
1package skins
2
3import (
4 "chain"
5 "strconv"
6 "strings"
7
8 "gno.land/p/nt/bptree/v0"
9 "gno.land/p/nt/grc721/v0"
10)
11
12// design is one skin of the catalog: what it looks like and how it sells.
13type design struct {
14 id string
15 name string
16 model string // a model the client draws (web/src/world/planes.ts)
17 pilot string // none or gnome
18 body string // colours, "#rrggbb"
19 wing string
20 accent string
21 price int64 // ugnot
22 maxSupply int64 // how many tokens of it can ever be minted, 0 for no limit
23 minted int64 // tokens minted so far, burned ones included
24 listed bool // an unlisted design cannot be bought, owners keep theirs
25}
26
27// The state. Nothing is exported by reference: the ledger and the trees are
28// /p/ types with mutator methods. The token itself is not exported either:
29// its tellers move tokens, and a skin never moves (soulbound, see doc.gno).
30var (
31 token *grc721.Token
32 ledger *grc721.PrivateLedger
33
34 designs bptree.BPTree // design id -> *design
35
36 // Per token, as little as possible: the skins are bought for a few GNOT
37 // and every byte is a deposit. The ledger keeps the owner; tokenSkin the
38 // design, as a string; owned answers "does addr own design x" and lists
39 // the designs of addr, keyed "<addr>/<design>", value the token id.
40 tokenSkin bptree.BPTree // token id -> design id
41 owned bptree.BPTree // "<addr>/<design>" -> token id
42
43 nextID int64 // the id of the next token, 1 first
44
45 webPath string // "/r/.../gnofly/nfts/skins", for the links of Render
46)
47
48const (
49 tokenName = "gnofly skins"
50 tokenSymbol = "SKIN"
51 payDenom = "ugnot"
52 maxPrice = int64(1000000000000)
53
54 // uriBase is where the website serves the metadata of each design: a
55 // token's URI is <uriBase><design>.json. It is computed on read, not
56 // stored per token.
57 uriBase = "https://gnofly.xyz/nft/skins/"
58
59 classic = "classic" // the free look, never a token
60)
61
62func init(cur realm) {
63 bootstrap(0, cur)
64}
65
66// bootstrap (re)creates the state and seeds the catalog. The tests call it to
67// start over.
68func bootstrap(_ int, rlm realm) {
69 token, ledger = grc721.NewToken(tokenName, tokenSymbol, 0, rlm)
70 designs = bptree.BPTree{}
71 tokenSkin = bptree.BPTree{}
72 owned = bptree.BPTree{}
73 nextID = 1
74 webPath = webPathOf(rlm.PkgPath())
75 for _, line := range strings.Split(catalog, "\n") {
76 f := strings.Split(line, "|")
77 if len(f) != 8 {
78 panic(errBadCatalog)
79 }
80 price, err := strconv.ParseInt(f[7], 10, 64)
81 if err != nil {
82 panic(errBadCatalog)
83 }
84 putDesign(f[0], f[1], f[2], f[3], f[4], f[5], f[6], price, 0)
85 }
86}
87
88// getDesign returns the design registered under id, or nil.
89func getDesign(id string) *design {
90 v := designs.Get(id)
91 if v == nil {
92 return nil
93 }
94 return v.(*design)
95}
96
97// putDesign validates and stores a design. An existing one keeps its count
98// and its listed flag; a new one starts listed.
99func putDesign(id, name, model, pilot, body, wing, accent string, price, maxSupply int64) {
100 if !validID(id) {
101 panic(errInvalidID)
102 }
103 if id == classic {
104 panic(errReservedID)
105 }
106 if !validName(name) {
107 panic(errInvalidName)
108 }
109 if !validModel(model) {
110 panic(errInvalidModel)
111 }
112 if pilot != "none" && pilot != "gnome" {
113 panic(errInvalidPilot)
114 }
115 if !validColor(body) || !validColor(wing) || !validColor(accent) {
116 panic(errInvalidColor)
117 }
118 if price < 1 || price > maxPrice {
119 panic(errInvalidPrice)
120 }
121 if maxSupply < 0 {
122 panic(errInvalidSupply)
123 }
124 d := &design{id: id, listed: true}
125 if old := getDesign(id); old != nil {
126 d.minted, d.listed = old.minted, old.listed
127 }
128 d.name, d.model, d.pilot = name, model, pilot
129 d.body, d.wing, d.accent = body, wing, accent
130 d.price, d.maxSupply = price, maxSupply
131 designs.Set(id, d)
132}
133
134// SetSkin creates or updates a design. Admin only.
135//
136// id is [a-z0-9-]{1,32} and not "classic", name [A-Za-z0-9 '.!-]{1,40}, model
137// [a-z0-9-]{1,16}, pilot none or gnome, colours "#rrggbb" in lowercase, price
138// 1 to 1e12 ugnot, maxSupply 0 for unlimited. A max supply under what is
139// already minted stops the sales, it takes nothing back. Owners keep their
140// tokens whatever happens to the design; its look is read from here, so a
141// change of colours repaints every copy.
142func SetSkin(cur realm, id, name, model, pilot, body, wing, accent string, price, maxSupply int64) {
143 assertAdmin(0, cur)
144 putDesign(id, name, model, pilot, body, wing, accent, price, maxSupply)
145 chain.Emit("SkinSet", "id", id, "price", itoa(price), "maxSupply", itoa(maxSupply))
146}
147
148// SetSkinListed puts a design on or off sale. Admin only.
149func SetSkinListed(cur realm, id string, listed bool) {
150 assertAdmin(0, cur)
151 d := getDesign(id)
152 if d == nil {
153 panic(errUnknownSkin)
154 }
155 d.listed = listed
156 chain.Emit("SkinListed", "id", id, "listed", boolString(listed))
157}
158
159// webPathOf turns "gno.land/r/..." into "/r/...".
160func webPathOf(pkgPath string) string {
161 for i := 0; i < len(pkgPath); i++ {
162 if pkgPath[i] == '/' {
163 return pkgPath[i:]
164 }
165 }
166 return ""
167}
168
169func itoa(n int64) string {
170 return strconv.FormatInt(n, 10)
171}
172
173func boolString(b bool) string {
174 if b {
175 return "true"
176 }
177 return "false"
178}