Transaction
FB547C3CB5A47E…D1054FC08C15
Block 556,000 · index 0 · indexed
Summary
- Hash
- FB547C3CB5A47E835F93FBEAFA7089AD816E5B812D8ED01A0470D1054FC08C15
- Block
- 556,000
- Size
- 10956 bytes
- Gas used
- 15,836,423 / 30,000,000
- Fee
- 60000 ugnot
- Status
- success
Messages
- Attached funds
- 3000000 ugnot
Arguments · 9
- #1goldenticket
- #2gnomod.toml
- #3module = "gno.land/r/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/goldenticket/v2" gno = "0.9"
- #4goldenticket.gno
- #5// Package goldenticket is a 1/1 GRC721 NFT: one golden ticket, minted to the // publisher when the package is added. It checks that a gno.land collection // shows in the Adena wallet, built the way gnofly's planes are: // // - the collection is a gno.land/p/nt/grc721/v0 token made with NewToken, // whose NewToken and Transfer events Adena finds on its indexer; // - the read functions take a grc721.TokenID and return (value, error), as // Adena calls them: OwnerOf("1"), BalanceOf("g1..."), TokenURI("1") as // the <img src>, TokenMetadata("1") as JSON with camelCase traitType. // // v2 of goldenticket: the image is no longer a constant. The publisher is // the admin and moves it with SetImage, as the planes' SetImageBase does, so // it can live where both Adena (any https: image) and gno.land's pages (an // allowlist: GitHub, imgur, IPFS) load it. // // Transfer moves the ticket, so it can reach any Adena account. package goldenticket import ( "errors" "strings" "gno.land/p/nt/grc721/v0" ) const ( tokenName = "Golden Ticket" tokenSymbol = "GOLD" ticket = grc721.TokenID("1") // The gno icon from gnolang/workshops, served by raw.githubusercontent.com: // gno.land's content policy allows *.githubusercontent.com images. defaultImage = "https://raw.githubusercontent.com/gnolang/workshops/main/assets/gno-icon-white.png" errNoTicket = "goldenticket: there is one ticket, id 1" errNotOwner = "goldenticket: caller does not hold the ticket" errBadOwner = "goldenticket: invalid recipient address" errNotAdmin = "goldenticket: caller is not the admin" errBadImage = "goldenticket: image must be an https URL, 12 to 300 characters of [A-Za-z0-9./_-:?=&%~+]" ) var ( // Token is the collection, read-only: Token.OwnerOf("1"), Token.BalanceOf. Token *grc721.Token ledger *grc721.PrivateLedger admin address // the publisher: the only one who sets the image image string // what TokenURI returns, and Render and TokenMetadata show ) // init runs once, when the package is added to the chain. This is the only // moment cur.Previous() is the publisher, who receives the ticket and is the // admin. // // Under `gno test` there is no publisher and the address is empty. The tests // call bootstrap themselves with a real address. func init(cur realm) { bootstrap(cur.Previous().Address(), cur) } // bootstrap creates the collection and mints the ticket to publisher; with // no valid publisher, under `gno test`, it mints nothing. func bootstrap(publisher address, rlm realm) { Token, ledger = grc721.NewToken(tokenName, tokenSymbol, 0, rlm) admin = publisher image = defaultImage if !publisher.IsValid() { return } if err := ledger.Mint(publisher, ticket); err != nil { panic(err) } } // Name and Symbol, as GRC721 tokens have them. Adena reads both from the // NewToken event. func Name() string { return tokenName } func Symbol() string { return tokenSymbol } // Admin returns who may set the image. func Admin() address { return admin } // Image returns the current image URL. func Image() string { return image } // SetImage points the ticket at another image, at once: TokenURI is computed // on every read. Admin only, by a direct call. func SetImage(cur realm, url string) { caller := cur.Previous() if !caller.IsUserCall() || caller.Address() != admin { panic(errNotAdmin) } if !validImage(url) { panic(errBadImage) } image = url } // validImage reports whether s is an https URL of 12 to 300 characters of // [A-Za-z0-9./_-:?=&%~+]. The charset keeps it safe in JSON and markdown // without escaping: no quote, bracket, parenthesis, space or backslash. func validImage(s string) bool { if len(s) < 12 || len(s) > 300 || !strings.HasPrefix(s, "https://") { return false } for i := 0; i < len(s); i++ { c := s[i] if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && !strings.ContainsRune("./_-:?=&%~+", rune(c)) { return false } } return true } // OwnerOf returns who holds the ticket, or an error for any other id. func OwnerOf(tid grc721.TokenID) (address, error) { if tid != ticket { return "", errors.New(errNoTicket) } return Token.OwnerOf(tid) } // BalanceOf returns 1 for the holder of the ticket, 0 for everyone else. func BalanceOf(addr address) (int64, error) { return Token.BalanceOf(addr) } // TokenURI returns the image of the ticket: what Adena shows. func TokenURI(tid grc721.TokenID) (string, error) { if _, err := OwnerOf(tid); err != nil { return "", err } return image, nil } // TokenMetadata returns the metadata of the ticket as JSON, with the keys // Adena reads. func TokenMetadata(tid grc721.TokenID) (string, error) { if _, err := OwnerOf(tid); err != nil { return "", err } return `{"name":"` + tokenName + ` #1","description":"A 1/1 on gno.land, minted to check that collections show in Adena.",` + `"image":"` + image + `","attributes":[{"traitType":"edition","value":"1 of 1"},{"traitType":"metal","value":"gold"}]}`, nil } // Transfer gives the ticket to someone else. Only its holder can, by a // direct call (maketx call or Adena), not through another realm. func Transfer(cur realm, to address) { caller := cur.Previous() if !caller.IsUserCall() { panic(errNotOwner) } if !to.IsValid() { panic(errBadOwner) } holder, err := Token.OwnerOf(ticket) if err != nil || holder != caller.Address() { panic(errNotOwner) } if err := ledger.TransferFrom(holder, holder, to, ticket); err != nil { panic(err) } } // Render shows the ticket and who holds it. func Render(_ string) string { var b strings.Builder b.WriteString("# " + tokenName + "\n\n") b.WriteString("A 1/1 GRC721 on gno.land, minted to check that collections show in Adena.\n\n") b.WriteString("\n\n") holder, err := Token.OwnerOf(ticket) if err != nil { b.WriteString("Not minted.\n") return b.String() } b.WriteString("Held by `" + holder.String() + "`.\n") return b.String() }
- #6goldenticket_test.gno
- #7package goldenticket import ( "strings" "testing" "gno.land/p/nt/grc721/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( aliceAddr = testutils.TestAddress("alice") bobAddr = testutils.TestAddress("bob") ) // reset redoes what init does on chain, with alice as the publisher. func reset(cur realm) { func(cur realm) { bootstrap(aliceAddr, cur) }(cross(cur)) } // The reads, called as Adena calls them: the id a quoted string, two values. func TestReads(cur realm, t *testing.T) { reset(cur) owner, err := OwnerOf("1") uassert.NoError(t, err) uassert.Equal(t, aliceAddr, owner) n, err := BalanceOf(aliceAddr) uassert.NoError(t, err) uassert.Equal(t, int64(1), n) n, err = BalanceOf(bobAddr) uassert.NoError(t, err) uassert.Equal(t, int64(0), n) uri, err := TokenURI("1") uassert.NoError(t, err) uassert.Equal(t, defaultImage, uri) uassert.True(t, strings.HasPrefix(uri, "https://")) meta, err := TokenMetadata("1") uassert.NoError(t, err) uassert.Equal(t, `{"name":"Golden Ticket #1","description":"A 1/1 on gno.land, minted to check that collections show in Adena.",`+ `"image":"`+defaultImage+`","attributes":[{"traitType":"edition","value":"1 of 1"},{"traitType":"metal","value":"gold"}]}`, meta) uassert.Equal(t, int64(1), Token.TotalSupply()) uassert.Equal(t, "Golden Ticket", Name()) uassert.Equal(t, "GOLD", Symbol()) // Any other id is an error, not a panic. for _, bad := range []grc721.TokenID{"", "0", "2", "01", " 1"} { _, err = OwnerOf(bad) uassert.ErrorContains(t, err, errNoTicket, string(bad)) _, err = TokenURI(bad) uassert.ErrorContains(t, err, errNoTicket, string(bad)) _, err = TokenMetadata(bad) uassert.ErrorContains(t, err, errNoTicket, string(bad)) } } func TestTransfer(cur realm, t *testing.T) { reset(cur) // Only the holder moves the ticket. testing.SetRealm(testing.NewUserRealm(bobAddr)) uassert.AbortsWithMessage(t, cur, errNotOwner, func() { Transfer(cross(cur), bobAddr) }) testing.SetRealm(testing.NewUserRealm(aliceAddr)) uassert.AbortsWithMessage(t, cur, errBadOwner, func() { Transfer(cross(cur), address("nope")) }) Transfer(cross(cur), bobAddr) owner, err := OwnerOf("1") uassert.NoError(t, err) uassert.Equal(t, bobAddr, owner) n, _ := BalanceOf(aliceAddr) uassert.Equal(t, int64(0), n) n, _ = BalanceOf(bobAddr) uassert.Equal(t, int64(1), n) // Not through another realm. testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/relay")) uassert.AbortsWithMessage(t, cur, errNotOwner, func() { Transfer(cross(cur), aliceAddr) }) } func TestRender(cur realm, t *testing.T) { reset(cur) out := Render("") uassert.True(t, strings.Contains(out, "# Golden Ticket\n")) uassert.True(t, strings.Contains(out, "")) uassert.True(t, strings.Contains(out, "Held by `"+aliceAddr.String()+"`.")) } func TestSetImage(cur realm, t *testing.T) { reset(cur) uassert.Equal(t, aliceAddr, Admin()) uassert.Equal(t, defaultImage, Image()) // Only the admin, and not through another realm. testing.SetRealm(testing.NewUserRealm(bobAddr)) uassert.AbortsWithMessage(t, cur, errNotAdmin, func() { SetImage(cross(cur), "https://example.org/a.png") }) testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/relay")) uassert.AbortsWithMessage(t, cur, errNotAdmin, func() { SetImage(cross(cur), "https://example.org/a.png") }) testing.SetRealm(testing.NewUserRealm(aliceAddr)) for _, bad := range []string{ "", "http://example.org/a.png", "https://", "javascript:alert(1)", "https://example.org/a b.png", `https://example.org/"a.png`, "https://example.org/a.png), } { uassert.AbortsWithMessage(t, cur, errBadImage, func() { SetImage(cross(cur), bad) }) } next := "https://ipfs.io/ipfs/bafy123/ticket.png?size=350&v=2" SetImage(cross(cur), next) uassert.Equal(t, next, Image()) uri, err := TokenURI("1") uassert.NoError(t, err) uassert.Equal(t, next, uri) meta, _ := TokenMetadata("1") uassert.True(t, strings.Contains(meta, `"image":"`+next+`"`)) uassert.True(t, strings.Contains(Render(""), "("+next+")")) // The admin stays the publisher after the ticket moves. Transfer(cross(cur), bobAddr) testing.SetRealm(testing.NewUserRealm(bobAddr)) uassert.AbortsWithMessage(t, cur, errNotAdmin, func() { SetImage(cross(cur), defaultImage) }) }
- #8/gno.MemPackageType
- #9 MPUserAll
Result log
msg:0,success:true,log:,events:[]