Transaction
E25D45C0EB2EB2…252A615AA222
Block 556,230 · index 0 · indexed
Summary
- Hash
- E25D45C0EB2EB23E8B6FBC975D7A073DA61E987D536CB39C8925252A615AA222
- Block
- 556,230
- Size
- 25425 bytes
- Gas used
- 34,184,532 / 200,000,000
- Fee
- 200000 ugnot
- Status
- success
Messages
- Attached funds
- 25000000 ugnot
Arguments · 23
- #1skins
- #2buy.gno
- #3package skins import ( "chain" "chain/banker" // Only for OriginSend, in Buy. Caller identity always comes from // cur.Previous(), never from this package. "chain/runtime/unsafe" "gno.land/p/nt/grc721/v0" ) // Buy buys one token of a skin design for the caller and returns its id. The // exact price must be attached to the transaction (-send "<price>ugnot") and // is forwarded to the treasury of the game realm before the call returns. // // An address owns at most one token of a design: a skin cannot be given // away, so a second copy would be of no use. It must be called directly by a // user (maketx call): a call relayed by another realm or by a maketx run // script is refused. func Buy(cur realm, skinID string) int64 { buyer := callerOf(0, cur) // Paid-call guard: two checks that are only correct together. // // (a) The caller is a plain user account. Only then is the -send // envelope guaranteed to have landed at this realm's address. if !cur.Previous().IsUserCall() { panic(errNotUserCall) } d := getDesign(skinID) if d == nil { panic(errUnknownSkin) } if !d.listed { panic(errSkinUnlisted) } if d.maxSupply > 0 && d.minted >= d.maxSupply { panic(errSoldOut) } key := ownedKey(buyer, skinID) if owned.Has(key) { panic(errAlreadyOwned) } // (b) The envelope is exactly the price: one coin, the right denom, the // right amount. sent := unsafe.OriginSend() if len(sent) != 1 || sent[0].Denom != payDenom || sent[0].Amount != d.price { panic("gnofly: wrong payment, send exactly " + itoa(d.price) + payDenom) } treasury := roles.Treasury() payment := chain.NewCoins(chain.NewCoin(payDenom, d.price)) banker.NewBanker(banker.BankerTypeOriginSend, cur).SendCoins(cur.Address(), treasury, payment) id := nextID nextID++ tid := grc721.TokenID(itoa(id)) if err := ledger.Mint(buyer, tid); err != nil { panic(err) } tokenSkin.Set(string(tid), skinID) owned.Set(key, id) d.minted++ chain.Emit("SkinBought", "id", itoa(id), "skin", skinID, "buyer", buyer.String(), "price", itoa(d.price), "treasury", treasury.String(), ) return id } // Burn destroys a skin token. Only its owner can. The storage deposit paid at // purchase comes back; the price does not. The owner can buy the design // again, while it is listed. func Burn(cur realm, id int64) { caller := callerOf(0, cur) skinID := skinOf(id) if skinID == "" { panic(errNotMinted) } if ownerOf(id) != caller { panic(errNotSkinOwner) } tid := grc721.TokenID(itoa(id)) if err := ledger.Burn(tid); err != nil { panic(err) } tokenSkin.Remove(string(tid)) owned.Remove(ownedKey(caller, skinID)) chain.Emit("SkinBurned", "id", itoa(id), "skin", skinID, "owner", caller.String()) } // ownedKey is the key of owned: the address, then the design. func ownedKey(addr address, skinID string) string { return addr.String() + "/" + skinID } // skinOf returns the design of token id, "" when there is no such token. func skinOf(id int64) string { v := tokenSkin.Get(itoa(id)) if v == nil { return "" } return v.(string) } // ownerOf returns who owns token id, "" for none. func ownerOf(id int64) address { owner, err := token.OwnerOf(grc721.TokenID(itoa(id))) if err != nil { return "" } return owner }
- #4catalog.gno
- #5package skins // catalog is the skin catalog at deploy, one design per line, sorted by id: // // id|name|model|pilot|body|wing|accent|price // // The rules are those of SetSkin (see validate.gno); the tests check every // line. The website reads this file too, at build, to serve the metadata of // each design at https://gnofly.xyz/nft/skins/<id>.json: a design added later // with SetSkin needs a line here as well for its JSON to exist. // // Classic, the look every pilot flies by default, is not here: it is free and // drawn by the client, not a token. const catalog = `baron|Red Baron|biplane|none|#c1121f|#780000|#fdf0d5|7000000 gnome|Garden Gnome|prop|none|#2a9d8f|#e9c46a|#264653|77000000 goldie|Golden Goose|jet|none|#ffd60a|#ffc300|#000814|7777000000 stealth|Night Shift|jet|none|#22223b|#4a4e69|#f72585|777000000`
- #6doc.gno
- #7// v0 - Unaudited: This is an initial version that has not yet been formally audited. // Use in production at your own risk. // // Package skins is the catalog of plane skins of gnofly, a multiplayer plane // game, and the tokens of the skins sold. Every skin bought is a GRC721 // token on gno.land/p/nt/grc721/v0, one per buyer and design. // // The tokens are soulbound: there is no TransferFrom, no Approve and no // SetApprovalForAll, and the token object is not exported, so nothing can // move a skin away from its buyer. Its owner can Burn it, which gives back // the storage deposit (not the price). // // Supply is unlimited by default; an admin can cap a design. Classic, the // look every pilot flies by default, is free and is not a token. // // Writers: // // - anyone buys a skin with its price attached (Buy), and burns their own // (Burn); // - the admins of the game realm edit the catalog (SetSkin, SetSkinListed). // // The realm keeps no roles and no money: admins and treasury are those of // gno.land/r/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/game/v0, and every // sale is forwarded to the treasury in the same transaction. // // A token stores only its owner (the ledger), its design and an entry in the // buyer's list: no metadata record and no URI. TokenURI is computed from the // design. Reads are plain functions meant for vm/qeval: CatalogJSON, SkinsOf, // OwnerOf, SkinOf, TokenURI, Render. package skins
- #8errors.gno
- #9package skins // The refusals of this realm, as string constants: a string costs its length // in storage where an errors.New value costs about 1.15 KB (see the planes // realm). A panic with a string aborts the call with the same message. const ( errSpoofedRealm = "gnofly: spoofed realm" errNotAdmin = "gnofly: caller is not an admin" errBadCatalog = "gnofly: corrupt catalog line" errInvalidID = "gnofly: id must be 1 to 32 characters of [a-z0-9-]" errReservedID = "gnofly: classic is the free look of every pilot, not a skin to sell" errInvalidName = "gnofly: name must be 1 to 40 characters of [A-Za-z0-9 '.!-]" errInvalidModel = "gnofly: model must be 1 to 16 characters of [a-z0-9-]" errInvalidPilot = "gnofly: pilot must be none or gnome" errInvalidColor = "gnofly: colors must be #rrggbb in lowercase" errInvalidPrice = "gnofly: price must be between 1 and 1000000000000 ugnot" errInvalidSupply = "gnofly: max supply must be 0 (unlimited) or more" errUnknownSkin = "gnofly: unknown skin" errSkinUnlisted = "gnofly: this skin is not for sale" errSoldOut = "gnofly: every copy of this skin is sold" errAlreadyOwned = "gnofly: this address already owns this skin" errNotUserCall = "gnofly: Buy must be called directly by a user (maketx call)" errNotMinted = "gnofly: no such skin token" errNotSkinOwner = "gnofly: caller does not own this skin token" )
- #10gnomod.toml
- #11module = "gno.land/r/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/nfts/skins" gno = "0.9"
- #12json.gno
- #13package skins import ( "errors" "strings" "gno.land/p/nt/grc721/v0" "gno.land/p/onbloc/json/v0" ) // CatalogJSON returns every design, sorted by id, as a JSON array: // // [ { "id": "baron", "name": "Red Baron", "model": "biplane", "pilot": "none", // "body": "#c1121f", "wing": "#780000", "accent": "#fdf0d5", // "price": 1000000, "listed": true, "minted": 3, "maxSupply": 0 } ] // // The output has no whitespace. maxSupply 0 is unlimited. Classic, the free // look, is not in it: the client draws it without a token. func CatalogJSON() string { items := []*json.Node{} designs.Iterate("", "", func(_ string, v any) bool { d := v.(*design) b := json.Builder(). WriteString("id", d.id). WriteString("name", d.name). WriteString("model", d.model). WriteString("pilot", d.pilot). WriteString("body", d.body). WriteString("wing", d.wing). WriteString("accent", d.accent). WriteNumber("price", float64(d.price)). WriteBool("listed", d.listed). WriteNumber("minted", float64(d.minted)). WriteNumber("maxSupply", float64(d.maxSupply)) items = append(items, b.Node()) return false }) return encode(json.ArrayNode("", items)) } // SkinsOf returns the designs addr owns a token of, sorted by id, as a JSON // array: ["baron","goldie"]. It is [] for an address without skins. func SkinsOf(addr address) string { ids := []*json.Node{} prefix := addr.String() + "/" if validAddress(addr) { // Keys sort as "<addr>/<design>": the designs of addr are one range, // "/" + 1 is "0". owned.Iterate(prefix, addr.String()+"0", func(key string, _ any) bool { ids = append(ids, json.StringNode("", strings.TrimPrefix(key, prefix))) return false }) } return encode(json.ArrayNode("", ids)) } // The read functions a wallet calls take a grc721.TokenID and return an // error rather than panic, the way Adena calls them: OwnerOf("1") into two // values, TokenURI("1") as the image it shows, TokenMetadata("1") as JSON // for the traits. The errors are made on the call, not stored (errors.gno). // OwnerOf returns who owns skin token tid, or an error when there is none. func OwnerOf(tid grc721.TokenID) (address, error) { id, err := mintedID(tid) if err != nil { return "", err } return ownerOf(id), nil } // BalanceOf returns how many skin tokens addr owns, one per design at most. func BalanceOf(addr address) (int64, error) { return token.BalanceOf(addr) } // TokenMetadata returns the metadata of skin token tid as JSON, the same for // every token of a design, with the keys Adena reads: // // {"name":"Red Baron","description":"A gnofly skin, a biplane. ...", // "image":"https://gnofly.xyz/nft/skins/baron.png", // "attributes":[{"traitType":"model","value":"biplane"}, ...]} func TokenMetadata(tid grc721.TokenID) (string, error) { id, err := mintedID(tid) if err != nil { return "", err } d := getDesign(skinOf(id)) if d == nil { return "", errors.New(errNotMinted) } pilot := "" if d.pilot == "gnome" { pilot = " flown by a gnome" } b := json.Builder(). WriteString("name", d.name). WriteString("description", "A gnofly skin, a "+d.model+pilot+". Bound to its buyer: it cannot be transferred."). WriteString("image", uriBase+d.id+".png"). WriteArray("attributes", func(ab *json.ArrayBuilder) { for _, t := range [][2]string{ {"model", d.model}, {"pilot", d.pilot}, {"body", d.body}, {"wing", d.wing}, {"accent", d.accent}, {"soulbound", "yes"}, } { ab.WriteObject(func(o *json.NodeBuilder) { o.WriteString("traitType", t[0]).WriteString("value", t[1]) }) } }) return encode(b.Node()), nil } // mintedID reads a token id, "1" and up in decimal, and checks it exists. func mintedID(tid grc721.TokenID) (int64, error) { s := string(tid) n := int64(0) for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' || i > 15 || (i == 0 && s[i] == '0') { return 0, errors.New(errNotMinted) } n = n*10 + int64(s[i]-'0') } if n == 0 || ownerOf(n) == "" { return 0, errors.New(errNotMinted) } return n, nil } // SkinOf returns the design of skin token id, "" when there is no such token. func SkinOf(id int64) string { return skinOf(id) } // TokenURI returns the image of skin token tid, // "https://gnofly.xyz/nft/skins/<design>.png": what a wallet shows (Adena // puts it in an <img>). The metadata is TokenMetadata, or // "https://gnofly.xyz/nft/skins/<design>.json" on the website. It is // computed, not stored: a token costs no URI in deposit. func TokenURI(tid grc721.TokenID) (string, error) { id, err := mintedID(tid) if err != nil { return "", err } return uriBase + skinOf(id) + ".png", nil } // encode marshals a document built above; it cannot fail on one. func encode(n *json.Node) string { out, err := json.Marshal(n) if err != nil { panic(err) } return string(out) }
- #14render.gno
- #15package skins import "strings" // Render returns the shop as markdown, for gnoweb: // // "" the catalog // "skin/<id>" one design // "player/<addr>" the skins of one address // // Everything printed went through validate.gno when it was written, or is // an address the chain gave the realm; the caller-supplied id and address are // printed only once found valid. func Render(path string) string { switch { case path == "": return renderHome() case strings.HasPrefix(path, "skin/"): if d := getDesign(strings.TrimPrefix(path, "skin/")); d != nil { return renderDesign(d) } case strings.HasPrefix(path, "player/"): if addr := address(strings.TrimPrefix(path, "player/")); validAddress(addr) { return renderPlayer(addr) } } return "# gnofly skins\n\nPage not found. Go back to the [shop](" + link("") + ").\n" } func link(path string) string { if path == "" { return webPath } return webPath + ":" + path } func renderHome() string { var b strings.Builder b.WriteString("# gnofly skins\n\n") b.WriteString("Paint jobs for the planes of gnofly. Each one bought is a token bound to its buyer: it cannot be\n") b.WriteString("transferred. Buy one with `Buy` and its price attached, then fly it from the hangar of the game.\n") b.WriteString("Classic is free for everyone and is not sold here.\n\n") b.WriteString("| Skin | Model | Price | Sold | Supply | For sale |\n|---|---|---|---|---|---|\n") designs.Iterate("", "", func(_ string, v any) bool { d := v.(*design) b.WriteString("| " + designLink(d) + " | " + d.model + " | " + gnot(d.price) + " | " + itoa(d.minted) + " | " + supplyText(d.maxSupply) + " | " + yesNo(d.listed) + " |\n") return false }) b.WriteString("\nTreasury: " + roles.Treasury().String() + "\n") return b.String() } func renderDesign(d *design) string { var b strings.Builder b.WriteString("# " + d.name + "\n\n") b.WriteString("| | |\n|---|---|\n") b.WriteString("| Id | " + d.id + " |\n") b.WriteString("| Model | " + d.model + " |\n") b.WriteString("| Pilot | " + d.pilot + " |\n") b.WriteString("| Body | `" + d.body + "` |\n") b.WriteString("| Wing | `" + d.wing + "` |\n") b.WriteString("| Accent | `" + d.accent + "` |\n") b.WriteString("| Price | " + gnot(d.price) + " |\n") b.WriteString("| Sold | " + itoa(d.minted) + " of " + supplyText(d.maxSupply) + " |\n") b.WriteString("| For sale | " + yesNo(d.listed) + " |\n") b.WriteString("| Metadata | `" + uriBase + d.id + ".json` |\n") b.WriteString("\n[Shop](" + link("") + ")\n") return b.String() } func renderPlayer(addr address) string { var b strings.Builder b.WriteString("# Pilot " + addr.String() + "\n\n") n := 0 prefix := addr.String() + "/" owned.Iterate(prefix, addr.String()+"0", func(key string, v any) bool { if n == 0 { b.WriteString("| Skin | Token |\n|---|---|\n") } d := getDesign(strings.TrimPrefix(key, prefix)) if d != nil { b.WriteString("| " + designLink(d) + " | " + itoa(v.(int64)) + " |\n") } n++ return false }) if n == 0 { b.WriteString("No skin yet: this pilot flies Classic.\n") } b.WriteString("\n[Shop](" + link("") + ")\n") return b.String() } func designLink(d *design) string { return "[" + d.name + "](" + link("skin/"+d.id) + ")" } func supplyText(n int64) string { if n == 0 { return "unlimited" } return itoa(n) } // gnot writes ugnot as GNOT: 1000000 -> "1 GNOT", 1500000 -> "1.5 GNOT". func gnot(ugnot int64) string { whole, frac := ugnot/1000000, ugnot%1000000 if frac == 0 { return itoa(whole) + " GNOT" } f := itoa(frac + 1000000)[1:] for f[len(f)-1] == '0' { f = f[:len(f)-1] } return itoa(whole) + "." + f + " GNOT" } func yesNo(b bool) string { if b { return "yes" } return "no" }
- #16roles.gno
- #17package skins import "gno.land/r/g1t2kg2vtr3fukg43eujkn6x53gfdyakhngt4sfd/gnofly/game/v0" // The skins realm keeps no roles of its own. Who administers it and where // its money goes are the answers of the game realm, asked again at every // call: an admin removed there is refused here in the same block, and a // treasury moved there receives the next sale. // authority is the set of questions this realm asks about roles. type authority interface { IsAdmin(addr address) bool Treasury() address } // gameRoles answers with the roles of the game realm. type gameRoles struct{} func (gameRoles) IsAdmin(addr address) bool { return game.IsAdmin(addr) } func (gameRoles) Treasury() address { return game.Treasury() } // roles is the authority of this realm: the game realm, always. // // It is a variable for one reason. Under `gno test` an imported realm is // initialized without a deployer, and a test of this package has no way to // become an admin of the game realm, so the tests put their own answers // here. Nothing outside the test files assigns it, and there is no setter. var roles authority = gameRoles{} // callerOf returns the verified caller of a crossing function. rlm must be // the cur of the crossing function calling this helper: a stale or forged // realm value fails IsCurrent, and Previous is only meaningful on a live one. // // It takes the realm in second position on purpose, like p/nt/ownable does: // a function whose first parameter is a realm is a crossing function. func callerOf(_ int, rlm realm) address { if !rlm.IsCurrent() { panic(errSpoofedRealm) } return rlm.Previous().Address() } // assertAdmin panics unless the caller is an admin of the game realm, and // returns the caller. func assertAdmin(_ int, rlm realm) address { caller := callerOf(0, rlm) if !roles.IsAdmin(caller) { panic(errNotAdmin) } return caller }
- #18skins.gno
- #19package skins import ( "chain" "strconv" "strings" "gno.land/p/nt/bptree/v0" "gno.land/p/nt/grc721/v0" ) // design is one skin of the catalog: what it looks like and how it sells. type design struct { id string name string model string // a model the client draws (web/src/world/planes.ts) pilot string // none or gnome body string // colours, "#rrggbb" wing string accent string price int64 // ugnot maxSupply int64 // how many tokens of it can ever be minted, 0 for no limit minted int64 // tokens minted so far, burned ones included listed bool // an unlisted design cannot be bought, owners keep theirs } // The state. Nothing is exported by reference: the ledger and the trees are // /p/ types with mutator methods. The token itself is not exported either: // its tellers move tokens, and a skin never moves (soulbound, see doc.gno). var ( token *grc721.Token ledger *grc721.PrivateLedger designs bptree.BPTree // design id -> *design // Per token, as little as possible: the skins are bought for a few GNOT // and every byte is a deposit. The ledger keeps the owner; tokenSkin the // design, as a string; owned answers "does addr own design x" and lists // the designs of addr, keyed "<addr>/<design>", value the token id. tokenSkin bptree.BPTree // token id -> design id owned bptree.BPTree // "<addr>/<design>" -> token id nextID int64 // the id of the next token, 1 first webPath string // "/r/.../gnofly/nfts/skins", for the links of Render ) const ( tokenName = "gnofly skins" tokenSymbol = "SKIN" payDenom = "ugnot" maxPrice = int64(1000000000000) // uriBase is where the website serves the metadata of each design: a // token's URI is <uriBase><design>.json. It is computed on read, not // stored per token. uriBase = "https://gnofly.xyz/nft/skins/" classic = "classic" // the free look, never a token ) func init(cur realm) { bootstrap(0, cur) } // bootstrap (re)creates the state and seeds the catalog. The tests call it to // start over. func bootstrap(_ int, rlm realm) { token, ledger = grc721.NewToken(tokenName, tokenSymbol, 0, rlm) designs = bptree.BPTree{} tokenSkin = bptree.BPTree{} owned = bptree.BPTree{} nextID = 1 webPath = webPathOf(rlm.PkgPath()) for _, line := range strings.Split(catalog, "\n") { f := strings.Split(line, "|") if len(f) != 8 { panic(errBadCatalog) } price, err := strconv.ParseInt(f[7], 10, 64) if err != nil { panic(errBadCatalog) } putDesign(f[0], f[1], f[2], f[3], f[4], f[5], f[6], price, 0) } } // getDesign returns the design registered under id, or nil. func getDesign(id string) *design { v := designs.Get(id) if v == nil { return nil } return v.(*design) } // putDesign validates and stores a design. An existing one keeps its count // and its listed flag; a new one starts listed. func putDesign(id, name, model, pilot, body, wing, accent string, price, maxSupply int64) { if !validID(id) { panic(errInvalidID) } if id == classic { panic(errReservedID) } if !validName(name) { panic(errInvalidName) } if !validModel(model) { panic(errInvalidModel) } if pilot != "none" && pilot != "gnome" { panic(errInvalidPilot) } if !validColor(body) || !validColor(wing) || !validColor(accent) { panic(errInvalidColor) } if price < 1 || price > maxPrice { panic(errInvalidPrice) } if maxSupply < 0 { panic(errInvalidSupply) } d := &design{id: id, listed: true} if old := getDesign(id); old != nil { d.minted, d.listed = old.minted, old.listed } d.name, d.model, d.pilot = name, model, pilot d.body, d.wing, d.accent = body, wing, accent d.price, d.maxSupply = price, maxSupply designs.Set(id, d) } // SetSkin creates or updates a design. Admin only. // // id is [a-z0-9-]{1,32} and not "classic", name [A-Za-z0-9 '.!-]{1,40}, model // [a-z0-9-]{1,16}, pilot none or gnome, colours "#rrggbb" in lowercase, price // 1 to 1e12 ugnot, maxSupply 0 for unlimited. A max supply under what is // already minted stops the sales, it takes nothing back. Owners keep their // tokens whatever happens to the design; its look is read from here, so a // change of colours repaints every copy. func SetSkin(cur realm, id, name, model, pilot, body, wing, accent string, price, maxSupply int64) { assertAdmin(0, cur) putDesign(id, name, model, pilot, body, wing, accent, price, maxSupply) chain.Emit("SkinSet", "id", id, "price", itoa(price), "maxSupply", itoa(maxSupply)) } // SetSkinListed puts a design on or off sale. Admin only. func SetSkinListed(cur realm, id string, listed bool) { assertAdmin(0, cur) d := getDesign(id) if d == nil { panic(errUnknownSkin) } d.listed = listed chain.Emit("SkinListed", "id", id, "listed", boolString(listed)) } // webPathOf turns "gno.land/r/..." into "/r/...". func webPathOf(pkgPath string) string { for i := 0; i < len(pkgPath); i++ { if pkgPath[i] == '/' { return pkgPath[i:] } } return "" } func itoa(n int64) string { return strconv.FormatInt(n, 10) } func boolString(b bool) string { if b { return "true" } return "false" }
- #20validate.gno
- #21package skins // The string rules below are what makes the hand-built JSON and the markdown // of this realm safe: every string that is stored and later printed went // through one of them, and none of them lets a quote, a backslash, a pipe, a // bracket or a control character in. const ( maxIDLen = 32 maxModelLen = 16 maxNameLen = 40 ) // validID reports whether s matches [a-z0-9-]{1,32}. func validID(s string) bool { return isSlug(s, maxIDLen) } // validModel reports whether s matches [a-z0-9-]{1,16}. func validModel(s string) bool { return isSlug(s, maxModelLen) } // validName reports whether s matches [A-Za-z0-9 '.!-]{1,40}. The charset is // deliberately narrow so that a name can be embedded in JSON and markdown // without escaping. func validName(s string) bool { if len(s) == 0 || len(s) > maxNameLen { return false } for i := 0; i < len(s); i++ { c := s[i] switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': case c == ' ', c == '\'', c == '.', c == '!', c == '-': default: return false } } return true } // validColor reports whether s is "#rrggbb" with lowercase hex digits. func validColor(s string) bool { if len(s) != 7 || s[0] != '#' { return false } for i := 1; i < len(s); i++ { c := s[i] if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') { return false } } return true } // validAddress reports whether addr is a valid address in its canonical, // lowercase form. // // Bech32 can also be written in all capitals, and address.IsValid accepts // that. To this realm the capitals would be a second key for the same // account: a second player record, a second line on a leaderboard, an admin // who can never be matched to a caller. Bech32 does not allow mixing cases, // so looking at the first character is enough. func validAddress(addr address) bool { return addr.IsValid() && addr[0] == 'g' } // isSlug reports whether s is 1 to max characters of [a-z0-9-]. func isSlug(s string, max int) bool { if len(s) == 0 || len(s) > max { return false } for i := 0; i < len(s); i++ { c := s[i] if !(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '-' { return false } } return true }
- #22/gno.MemPackageType
- #23 MPUserAll
Result log
msg:0,success:true,log:,events:[]