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.

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}