Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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("![" + tokenName + "](" + image + ")\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}