goldenticket.gno
5.86 Kb · 179 lines
1// Package goldenticket is a 1/1 GRC721 NFT: one golden ticket, minted to the
2// publisher when the package is added. It checks that a gno.land collection
3// shows in the Adena wallet, built the way gnofly's planes are:
4//
5// - the collection is a gno.land/p/nt/grc721/v0 token made with NewToken,
6// whose NewToken and Transfer events Adena finds on its indexer;
7// - the read functions take a grc721.TokenID and return (value, error), as
8// Adena calls them: OwnerOf("1"), BalanceOf("g1..."), TokenURI("1") as
9// the <img src>, TokenMetadata("1") as JSON with camelCase traitType.
10//
11// v2 of goldenticket: the image is no longer a constant. The publisher is
12// the admin and moves it with SetImage, as the planes' SetImageBase does, so
13// it can live where both Adena (any https: image) and gno.land's pages (an
14// allowlist: GitHub, imgur, IPFS) load it.
15//
16// Transfer moves the ticket, so it can reach any Adena account.
17package goldenticket
18
19import (
20 "errors"
21 "strings"
22
23 "gno.land/p/nt/grc721/v0"
24)
25
26const (
27 tokenName = "Golden Ticket"
28 tokenSymbol = "GOLD"
29 ticket = grc721.TokenID("1")
30
31 // The gno icon from gnolang/workshops, served by raw.githubusercontent.com:
32 // gno.land's content policy allows *.githubusercontent.com images.
33 defaultImage = "https://raw.githubusercontent.com/gnolang/workshops/main/assets/gno-icon-white.png"
34
35 errNoTicket = "goldenticket: there is one ticket, id 1"
36 errNotOwner = "goldenticket: caller does not hold the ticket"
37 errBadOwner = "goldenticket: invalid recipient address"
38 errNotAdmin = "goldenticket: caller is not the admin"
39 errBadImage = "goldenticket: image must be an https URL, 12 to 300 characters of [A-Za-z0-9./_-:?=&%~+]"
40)
41
42var (
43 // Token is the collection, read-only: Token.OwnerOf("1"), Token.BalanceOf.
44 Token *grc721.Token
45 ledger *grc721.PrivateLedger
46
47 admin address // the publisher: the only one who sets the image
48 image string // what TokenURI returns, and Render and TokenMetadata show
49)
50
51// init runs once, when the package is added to the chain. This is the only
52// moment cur.Previous() is the publisher, who receives the ticket and is the
53// admin.
54//
55// Under `gno test` there is no publisher and the address is empty. The tests
56// call bootstrap themselves with a real address.
57func init(cur realm) {
58 bootstrap(cur.Previous().Address(), cur)
59}
60
61// bootstrap creates the collection and mints the ticket to publisher; with
62// no valid publisher, under `gno test`, it mints nothing.
63func bootstrap(publisher address, rlm realm) {
64 Token, ledger = grc721.NewToken(tokenName, tokenSymbol, 0, rlm)
65 admin = publisher
66 image = defaultImage
67 if !publisher.IsValid() {
68 return
69 }
70 if err := ledger.Mint(publisher, ticket); err != nil {
71 panic(err)
72 }
73}
74
75// Name and Symbol, as GRC721 tokens have them. Adena reads both from the
76// NewToken event.
77func Name() string { return tokenName }
78func Symbol() string { return tokenSymbol }
79
80// Admin returns who may set the image.
81func Admin() address { return admin }
82
83// Image returns the current image URL.
84func Image() string { return image }
85
86// SetImage points the ticket at another image, at once: TokenURI is computed
87// on every read. Admin only, by a direct call.
88func SetImage(cur realm, url string) {
89 caller := cur.Previous()
90 if !caller.IsUserCall() || caller.Address() != admin {
91 panic(errNotAdmin)
92 }
93 if !validImage(url) {
94 panic(errBadImage)
95 }
96 image = url
97}
98
99// validImage reports whether s is an https URL of 12 to 300 characters of
100// [A-Za-z0-9./_-:?=&%~+]. The charset keeps it safe in JSON and markdown
101// without escaping: no quote, bracket, parenthesis, space or backslash.
102func validImage(s string) bool {
103 if len(s) < 12 || len(s) > 300 || !strings.HasPrefix(s, "https://") {
104 return false
105 }
106 for i := 0; i < len(s); i++ {
107 c := s[i]
108 if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') &&
109 !strings.ContainsRune("./_-:?=&%~+", rune(c)) {
110 return false
111 }
112 }
113 return true
114}
115
116// OwnerOf returns who holds the ticket, or an error for any other id.
117func OwnerOf(tid grc721.TokenID) (address, error) {
118 if tid != ticket {
119 return "", errors.New(errNoTicket)
120 }
121 return Token.OwnerOf(tid)
122}
123
124// BalanceOf returns 1 for the holder of the ticket, 0 for everyone else.
125func BalanceOf(addr address) (int64, error) {
126 return Token.BalanceOf(addr)
127}
128
129// TokenURI returns the image of the ticket: what Adena shows.
130func TokenURI(tid grc721.TokenID) (string, error) {
131 if _, err := OwnerOf(tid); err != nil {
132 return "", err
133 }
134 return image, nil
135}
136
137// TokenMetadata returns the metadata of the ticket as JSON, with the keys
138// Adena reads.
139func TokenMetadata(tid grc721.TokenID) (string, error) {
140 if _, err := OwnerOf(tid); err != nil {
141 return "", err
142 }
143 return `{"name":"` + tokenName + ` #1","description":"A 1/1 on gno.land, minted to check that collections show in Adena.",` +
144 `"image":"` + image + `","attributes":[{"traitType":"edition","value":"1 of 1"},{"traitType":"metal","value":"gold"}]}`, nil
145}
146
147// Transfer gives the ticket to someone else. Only its holder can, by a
148// direct call (maketx call or Adena), not through another realm.
149func Transfer(cur realm, to address) {
150 caller := cur.Previous()
151 if !caller.IsUserCall() {
152 panic(errNotOwner)
153 }
154 if !to.IsValid() {
155 panic(errBadOwner)
156 }
157 holder, err := Token.OwnerOf(ticket)
158 if err != nil || holder != caller.Address() {
159 panic(errNotOwner)
160 }
161 if err := ledger.TransferFrom(holder, holder, to, ticket); err != nil {
162 panic(err)
163 }
164}
165
166// Render shows the ticket and who holds it.
167func Render(_ string) string {
168 var b strings.Builder
169 b.WriteString("# " + tokenName + "\n\n")
170 b.WriteString("A 1/1 GRC721 on gno.land, minted to check that collections show in Adena.\n\n")
171 b.WriteString("\n\n")
172 holder, err := Token.OwnerOf(ticket)
173 if err != nil {
174 b.WriteString("Not minted.\n")
175 return b.String()
176 }
177 b.WriteString("Held by `" + holder.String() + "`.\n")
178 return b.String()
179}