Transaction
0B52C9A516985C…55C73D7EBDC2
Block 652,387 · index 0 · indexed
Summary
- Hash
- 0B52C9A516985C4AAC812DF7D537131C363419CD5ACCD1DE741755C73D7EBDC2
- Block
- 652,387
- Size
- 38003 bytes
- Gas used
- 53,765,163 / 135,000,000
- Fee
- 135000 ugnot
- Status
- success
Messages
- Attached funds
- 15000000 ugnot
Arguments · 13
- #1nft
- #2gnomod.toml
- #3module = "gno.land/r/samcrew/launchpad/nft/v1" gno = "0.9"
- #4metadata.gno
- #5package nft import ( "chain" "crypto/sha256" "encoding/hex" "strconv" "strings" "gno.land/p/samcrew/launchpad/meta/v1" ) const ( MetadataStatic = "static" // base URI fixed at creation MetadataReveal = "reveal" // placeholder until one committed reveal MetadataMutable = "mutable" // creator may change the base URI until frozen ) // metadata decides what TokenURI answers. Once frozen, the base URI can never // change again; only then can traits be committed. type metadata struct { mode string baseURI string // empty until a reveal placeholderURI string // reveal: served for every token until Reveal commitment string // reveal: RevealCommitment of the base URI to come committer address // reveal: the creator who made the commitment provenance string // reveal: SHA-256 of the ordered metadata manifest frozen bool revision int64 // base URI changes since creation } // contentAddressed accepts an IPFS path that resolves to the same files for // good: a server behind any other scheme could change them at will, and an // empty CID, a "." or ".." segment, an escape, a query or a fragment could // make a gateway resolve it elsewhere, a mutable IPNS name included. func contentAddressed(s string) bool { return strings.HasPrefix(s, "ipfs://") && len(s) > 7 && s[7] != '/' && !strings.Contains(s, "/.") && !strings.ContainsAny(s, "%?#") && meta.Image(s) == nil } // A base URI is a directory: token n resolves to <base><n>.json. func validBaseURI(s string) bool { return contentAddressed(s) && s[len(s)-1] == '/' } func validHash(s string) bool { if len(s) != 64 { return false } for i := 0; i < len(s); i++ { if c := s[i]; !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') { return false } } return true } // RevealCommitment is what a creator commits to at creation and opens at // Reveal: sha256("launchpad-reveal-v1|<creator>|<salt>|<base URI>"), with a // salt of 64 hex digits the creator keeps secret until then. The salt keeps a // guessed URI from being confirmed before the reveal. The creator's address // keeps another account from reusing the commitment: a collection's ID does // not exist yet when its creator commits, so the account stands in for it. func RevealCommitment(creator address, salt, baseURI string) string { h := sha256.Sum256([]byte("launchpad-reveal-v1|" + creator.String() + "|" + salt + "|" + baseURI)) return hex.EncodeToString(h[:]) } func newMetadata(mode, baseURI, placeholderURI, commitment, provenance string, creator address) metadata { ok := false switch mode { case MetadataStatic, MetadataMutable: ok = validBaseURI(baseURI) && placeholderURI == "" && commitment == "" && provenance == "" case MetadataReveal: // What is hidden must be content-addressed, or the commitment would // say nothing about the files behind it. ok = baseURI == "" && contentAddressed(placeholderURI) && strings.HasSuffix(placeholderURI, ".json") && validHash(commitment) && commitment != strings.Repeat("0", 64) && validHash(provenance) } if !ok { panic("launchpad/nft: invalid metadata terms") } m := metadata{mode: mode, baseURI: baseURI, placeholderURI: placeholderURI, commitment: commitment, provenance: provenance, frozen: mode == MetadataStatic} if mode == MetadataReveal { m.committer = creator } return m } func (m metadata) tokenURI(number int64) string { if m.baseURI == "" { return m.placeholderURI } return m.baseURI + strconv.FormatInt(number, 10) + ".json" } func emitMetadata(id string, m metadata) { chain.Emit("MetadataChanged", "collection", id, "base_uri", m.baseURI, "revision", strconv.FormatInt(m.revision, 10), "frozen", strconv.FormatBool(m.frozen)) } // Reveal publishes the base URI committed at creation, with the salt that // opens the commitment, and fixes it for good. The commitment stays bound to // the account that created the collection, whoever holds the creator role now. func Reveal(cur realm, id, baseURI, salt string) { m := &creatorOf(cur, id).metadata if m.mode != MetadataReveal || m.frozen { panic("launchpad/nft: nothing to reveal") } if !validBaseURI(baseURI) || !validHash(salt) || RevealCommitment(m.committer, salt, baseURI) != m.commitment { panic("launchpad/nft: reveal does not match the commitment") } m.baseURI, m.frozen, m.revision = baseURI, true, 1 emitMetadata(id, *m) } func mutable(cur realm, id string) *metadata { m := &creatorOf(cur, id).metadata if m.mode != MetadataMutable || m.frozen { panic("launchpad/nft: metadata is fixed") } return m } // SetBaseURI points a mutable collection at new metadata. Every change is // announced with its revision number. func SetBaseURI(cur realm, id, baseURI string) { m := mutable(cur, id) if !validBaseURI(baseURI) || baseURI == m.baseURI { panic("launchpad/nft: invalid base URI") } m.baseURI = baseURI m.revision++ emitMetadata(id, *m) } // FreezeMetadata gives up the right to change a mutable collection's base URI. func FreezeMetadata(cur realm, id string) { m := mutable(cur, id) m.frozen = true emitMetadata(id, *m) } // CommitTraits records, once, the Merkle root of the collection's traits. It // waits for frozen metadata so the root cannot outlive the files it describes. // A leaf is sha256(0x00 || "launchpad-trait-v1|<collection>|<number>|<type>=<value>") // in a p/samcrew/launchpad/merkle/v1 tree, where neither the type nor the // value contains "=" or "|": a leaf has then one reading only, and a leaf // that breaks the rule matches no trait offer. The ledger cannot check the // root against the files; whoever relies on it must recompute it from them. func CommitTraits(cur realm, id, root string) { c := creatorOf(cur, id) if !c.metadata.frozen || c.traitsRoot != "" || !validHash(root) { panic("launchpad/nft: traits cannot be committed") } c.traitsRoot = root chain.Emit("TraitsCommitted", "collection", id, "root", root) } // TraitsRoot is empty until the creator commits one. func TraitsRoot(id string) string { return mustCollection(id).traitsRoot }
- #6nft.gno
- #7// Package nft is the Launchpad collection ledger. Each collection is a GRC721 // token whose transfer mode, royalties and metadata rules are fixed at // creation. Collections are created, and their tokens minted, only by issuer // realms: the Launchpad drops realm and any realm the config admin adds. // Holders move, approve and burn their own tokens. The ledger has no admin of // its own: nothing here can move a holder's token or change a collection's // terms, and a paused lane stops only new collections and new mints. package nft import ( "chain" "strconv" "gno.land/p/nt/avl/v0" "gno.land/p/nt/grc721/v0" "gno.land/p/nt/seqid/v0" "gno.land/p/samcrew/launchpad/currency/v1" "gno.land/p/samcrew/launchpad/meta/v1" sm "gno.land/p/samcrew/launchpad/safemath/v1" "gno.land/r/samcrew/launchpad/config/v1" ) const ( dropsPath = "gno.land/r/samcrew/launchpad/drops/v1" marketPath = "gno.land/r/samcrew/launchpad/market/v1" ModeOpen = "open" ModeRoyaltyProtected = "royalty_protected" ModeSoulbound = "soulbound" StatusActive = "active" StatusBurned = "burned" StatusRevoked = "revoked" ) // Info is a collection's public record. The creator may edit the profile // fields until FreezeProfile, hand the creator role over and seal the supply; // everything else is immutable. type Info struct { ID string // "C<n>" GRC721ID string Issuer address // the only realm that mints into the collection Creator address Originator address // the creator at creation, whoever holds the role now PendingCreator address Name string Symbol string Description string Image string Banner string Website string Mode string Revocable bool // soulbound only: the creator may revoke a token MaxSupply int64 // zero means an uncapped open edition Sealed bool // no token can ever be minted again Minted int64 // includes retired tokens; numbers are never reused ProfileFrozen bool } type collection struct { key string // tree key: fixed width, in creation order info Info token *grc721.Token ledger *grc721.PrivateLedger metadata metadata traitsRoot string // Merkle root of the token traits, set once royalties []royalty // at most maxRoyaltyReceivers royaltyBPS int64 markets []address // royalty-protected only, at most maxMarkets retired avl.Tree // token number -> StatusBurned | StatusRevoked } var ( lastID seqid.ID collections avl.Tree // collection key -> *collection holdings avl.Tree // owner -> *avl.Tree of "<collection key>/<number key>" -> custody ID lastCustody seqid.ID // the last custody ID given ) // isIssuer reports whether a realm may create collections and mint into its // own: the drops realm always, and every realm on config's add-only list. func isIssuer(who address) bool { return who == chain.PackageAddress(dropsPath) || config.IsIssuer(config.LedgerNFT, who) } // gate stops a new collection or a new mint while its lane is paused, so the // pauser is a brake on every issuer config may add, whatever that issuer // checks. The drops realm is not asked twice: it applies the same gate itself, // with the currency it charges, before it calls here. func gate(issuer address, lane string) { if issuer != chain.PackageAddress(dropsPath) && config.IsPaused(lane) { panic("launchpad/nft: lane paused") } } // callingRealm returns the realm that called; a wallet is refused. func callingRealm(cur realm) address { if !cur.IsCurrent() || cur.Previous().IsUserCall() { panic("launchpad/nft: issuer realm only") } return cur.Previous().Address() } // actor returns the immediate caller. Coins a wallet attaches to a direct call // would stay in this realm, so they are refused; a calling realm keeps whatever // its own caller sent. func actor(cur realm) address { if !cur.IsCurrent() { panic("launchpad/nft: stale realm context") } if cur.Previous().IsUserCall() { currency.AssertNoCoins(0, cur) } return cur.Previous().Address() } func check(err error) { if err != nil { panic(err.Error()) } } func checkProfile(description, image, banner, website string) { check(meta.Description(description)) check(meta.Image(image)) check(meta.Image(banner)) check(meta.Website(website)) } func mustCollection(id string) *collection { if len(id) >= 2 && len(id) <= 21 && id[0] == 'C' && id[1] != '0' { if n, err := strconv.ParseUint(id[1:], 10, 64); err == nil { if c := collections.Get(seqid.ID(n).String()); c != nil { return c.(*collection) } } } panic("launchpad/nft: unknown collection") } func tokenID(number int64) grc721.TokenID { if number <= 0 { panic("launchpad/nft: invalid token number") } return grc721.TokenID(strconv.FormatInt(number, 10)) } func holdingKey(c *collection, number int64) string { return c.key + "/" + seqid.ID(number).String() } // hold and release keep the holdings index equal to ownership: one tree per // owner, so a page of an account's tokens costs the same wherever it starts. func hold(owner address, c *collection, number int64) { mine, _ := holdings.Get(owner.String()).(*avl.Tree) if mine == nil { mine = avl.NewTree() holdings.Set(owner.String(), mine) } mine.Set(holdingKey(c, number), int64(lastCustody.Next())) } // renew gives a held token a new custody ID, as a move does: a change of its // single-token approval starts a new custody too. func renew(owner address, c *collection, number int64) { holdings.Get(owner.String()).(*avl.Tree).Set(holdingKey(c, number), int64(lastCustody.Next())) } func release(owner address, c *collection, number int64) { mine := holdings.Get(owner.String()).(*avl.Tree) mine.Remove(holdingKey(c, number)) if mine.Size() == 0 { holdings.Remove(owner.String()) } } // Create registers a collection for a creator the calling issuer realm has // authenticated and charged. The issuer is recorded on the collection and no // other realm can mint into it. royalties is "address:bps;address:bps" with // addresses in strictly ascending order, or empty; the metadata arguments are // those of newMetadata. func Create(cur realm, creator address, name, symbol, description, image, banner, website, mode string, revocable bool, maxSupply int64, metadataMode, baseURI, placeholderURI, commitment, provenance, royalties string) string { issuer := callingRealm(cur) if !isIssuer(issuer) { panic("launchpad/nft: issuer realm only") } gate(issuer, config.LaneCollection) if !meta.ValidAddress(creator) || maxSupply < 0 || (mode != ModeOpen && mode != ModeRoyaltyProtected && mode != ModeSoulbound) || (revocable && mode != ModeSoulbound) { panic("launchpad/nft: invalid collection terms") } check(meta.Name(name)) check(meta.Ticker(symbol)) if config.IsReserved(symbol) { panic("launchpad/nft: reserved symbol") } checkProfile(description, image, banner, website) c := &collection{metadata: newMetadata(metadataMode, baseURI, placeholderURI, commitment, provenance, creator)} c.royalties, c.royaltyBPS = parseRoyalties(royalties, mode) if mode == ModeRoyaltyProtected { c.markets = []address{chain.PackageAddress(marketPath)} } n := lastID.Next() id := "C" + strconv.FormatUint(uint64(n), 10) c.key = n.String() c.token, c.ledger = grc721.NewToken(name, symbol, n, cur) c.info = Info{ ID: id, GRC721ID: c.token.ID(), Issuer: issuer, Creator: creator, Originator: creator, Name: name, Symbol: symbol, Description: description, Image: image, Banner: banner, Website: website, Mode: mode, Revocable: revocable, MaxSupply: maxSupply, } collections.Set(c.key, c) chain.Emit("CollectionCreated", "collection", id, "token", c.token.ID(), "issuer", issuer.String(), "creator", creator.String(), "name", name, "symbol", symbol, "mode", mode, "revocable", strconv.FormatBool(revocable), "max_supply", strconv.FormatInt(maxSupply, 10), "metadata", metadataMode, "base_uri", baseURI, "placeholder_uri", placeholderURI, "base_uri_commitment", commitment, "provenance", provenance, "royalties", royalties, "description", description, "image", image, "banner", banner, "website", website) return id } // MintTo issues the next token number. Only the collection's issuer calls it, // after its own accounting of stages and payment. The ledger mints to whoever // the issuer names: it is the issuer's duty to mint a soulbound token only to // the wallet that asked for it. func MintTo(cur realm, id string, recipient address) int64 { issuer := callingRealm(cur) c := mustCollection(id) if issuer != c.info.Issuer { panic("launchpad/nft: the collection's issuer only") } gate(issuer, config.LaneNFTDrops) if !meta.ValidAddress(recipient) || c.info.Sealed || (c.info.MaxSupply > 0 && c.info.Minted >= c.info.MaxSupply) { panic("launchpad/nft: mint denied") } number := sm.Add(c.info.Minted, 1) check(c.ledger.Mint(recipient, tokenID(number))) c.info.Minted = number hold(recipient, c, number) return number } // operator reports whether who may be approved for, or move, a token of c. // Soulbound tokens never move. A royalty-protected token moves only through a // realm on the collection's market list. The ledger cannot make a realm pay: // the creator who lists one vouches that it pays the collection's royalties. func operator(c *collection, who address) bool { switch c.info.Mode { case ModeOpen: return true case ModeRoyaltyProtected: for _, market := range c.markets { if who == market { return true } } } return false } func denied(c *collection) { if c.info.Mode == ModeSoulbound { panic("launchpad/nft: soulbound tokens cannot move") } panic("launchpad/nft: royalty-protected tokens move only through an allowed market") } // TransferFrom moves a token when the collection's mode lets the caller move // one, and the caller owns it or is approved for it. func TransferFrom(cur realm, id string, from, to address, number int64) { who := actor(cur) c := mustCollection(id) if !operator(c, who) { denied(c) } if !meta.ValidAddress(to) { panic("launchpad/nft: invalid recipient") } check(c.ledger.TransferFrom(who, from, to, tokenID(number))) release(from, c, number) hold(to, c, number) } // Approve lets one account move one token; an empty address clears it. A // soulbound token takes no approval at all, a royalty-protected one only a // listed market. func Approve(cur realm, id string, to address, number int64) { who := actor(cur) c := mustCollection(id) if c.info.Mode == ModeSoulbound || (to != "" && !operator(c, to)) { denied(c) } if to != "" && !meta.ValidAddress(to) { panic("launchpad/nft: invalid operator") } before := GetApproved(id, number) check(c.ledger.Approve(who, to, tokenID(number))) if to != before { renew(ownerOf(c, number), c, number) } } // SetApprovalForAll lets, or stops letting, one account move every token the // caller holds in the collection. func SetApprovalForAll(cur realm, id string, to address, approved bool) { who := actor(cur) c := mustCollection(id) if !operator(c, to) { denied(c) } if !meta.ValidAddress(to) { panic("launchpad/nft: invalid operator") } check(c.ledger.SetApprovalForAll(who, to, approved)) } func ownerOf(c *collection, number int64) address { owner, err := c.token.OwnerOf(tokenID(number)) check(err) return owner } func retire(c *collection, owner address, number int64, status string) { tid := tokenID(number) check(c.ledger.Burn(tid)) c.retired.Set(tid.String(), status) release(owner, c, number) } // Burn is the holder's alone, in every mode. No creator, operator or admin // can destroy a token somebody else holds, except through Revoke. func Burn(cur realm, id string, number int64) { who := actor(cur) c := mustCollection(id) if who != ownerOf(c, number) { panic("launchpad/nft: holder only") } retire(c, who, number, StatusBurned) } // Revoke retires a credential. It exists only for soulbound collections // created as revocable, a term every holder could read before claiming. A // holder who burns first leaves a burned token, not a revoked one. func Revoke(cur realm, id string, number int64, reason string) { c := creatorOf(cur, id) if c.info.Mode != ModeSoulbound || !c.info.Revocable { panic("launchpad/nft: not a revocable collection") } switch reason { case "expired", "fraud", "eligibility_lost", "issuer_error", "other": default: panic("launchpad/nft: invalid revocation reason") } holder := ownerOf(c, number) retire(c, holder, number, StatusRevoked) chain.Emit("Revoked", "collection", id, "number", strconv.FormatInt(number, 10), "holder", holder.String(), "reason", reason) } // creatorOf returns the collection when the caller is its creator. func creatorOf(cur realm, id string) *collection { who := actor(cur) c := mustCollection(id) if who != c.info.Creator { panic("launchpad/nft: creator only") } return c } // ProposeCreator starts handing the creator role to another account. Nothing // changes until that account accepts; an empty address withdraws the offer. func ProposeCreator(cur realm, id string, next address) { c := creatorOf(cur, id) if next != "" && (!meta.ValidAddress(next) || next == c.info.Creator) { panic("launchpad/nft: invalid next creator") } c.info.PendingCreator = next chain.Emit("CreatorProposed", "collection", id, "next", next.String()) } // AcceptCreator makes the caller the creator it was proposed as. func AcceptCreator(cur realm, id string) { who := actor(cur) c := mustCollection(id) if c.info.PendingCreator == "" || who != c.info.PendingCreator { panic("launchpad/nft: proposed creator only") } c.info.Creator, c.info.PendingCreator = who, "" chain.Emit("CreatorChanged", "collection", id, "creator", who.String()) } // SetIssuer moves the collection's minting to another issuer realm, for // instance a later version of the one that created it. Only the creator can // choose it, and only among realms allowed to issue. func SetIssuer(cur realm, id, pkgPath string) { c := creatorOf(cur, id) if len(pkgPath) < 12 || len(pkgPath) > 200 || pkgPath[:11] != "gno.land/r/" { panic("launchpad/nft: invalid issuer") } next := chain.PackageAddress(pkgPath) if !isIssuer(next) || next == c.info.Issuer { panic("launchpad/nft: invalid issuer") } c.info.Issuer = next chain.Emit("IssuerChanged", "collection", id, "issuer", next.String(), "path", pkgPath) } // SealSupply ends minting for good, whatever the cap was. func SealSupply(cur realm, id string) { c := creatorOf(cur, id) if c.info.Sealed { panic("launchpad/nft: supply already sealed") } c.info.Sealed = true chain.Emit("SupplySealed", "collection", id, "minted", strconv.FormatInt(c.info.Minted, 10)) } func profileOwner(cur realm, id string) *collection { c := creatorOf(cur, id) if c.info.ProfileFrozen { panic("launchpad/nft: profile frozen") } return c } func SetProfile(cur realm, id, description, image, banner, website string) { c := profileOwner(cur, id) checkProfile(description, image, banner, website) c.info.Description, c.info.Image = description, image c.info.Banner, c.info.Website = banner, website chain.Emit("ProfileChanged", "collection", id, "description", description, "image", image, "banner", banner, "website", website) } func FreezeProfile(cur realm, id string) { profileOwner(cur, id).info.ProfileFrozen = true chain.Emit("ProfileFrozen", "collection", id) } func GetInfo(id string) Info { return mustCollection(id).info } func Count() int64 { return int64(collections.Size()) } func BalanceOf(id string, owner address) int64 { if !meta.ValidAddress(owner) { panic("launchpad/nft: invalid owner") } n, err := mustCollection(id).token.BalanceOf(owner) check(err) return n } func OwnerOf(id string, number int64) address { return ownerOf(mustCollection(id), number) } func TotalSupply(id string) int64 { return mustCollection(id).token.TotalSupply() } // GetApproved returns the empty address when nobody is approved. func GetApproved(id string, number int64) address { a, err := mustCollection(id).token.GetApproved(tokenID(number)) if err == grc721.ErrTokenIdNotApproved { return "" } check(err) return a } func IsApprovedForAll(id string, owner, operator address) bool { if !meta.ValidAddress(owner) { panic("launchpad/nft: invalid owner") } if !meta.ValidAddress(operator) { panic("launchpad/nft: invalid operator") } return mustCollection(id).token.IsApprovedForAll(owner, operator) } func statusOf(c *collection, number int64) string { if number <= 0 || number > c.info.Minted { panic("launchpad/nft: invalid token number") } if status := c.retired.Get(strconv.FormatInt(number, 10)); status != nil { return status.(string) } return StatusActive } // Epoch returns the ID of a token's current custody: a new one at every move // and every change of its single-token approval (the same approval again // changes nothing), never one given before; 0 for a retired token. A market // binds an order to it, so the order dies with the custody it was made under. func Epoch(id string, number int64) int64 { c := mustCollection(id) if statusOf(c, number) != StatusActive { return 0 } return holdings.Get(ownerOf(c, number).String()).(*avl.Tree).Get(holdingKey(c, number)).(int64) } func StatusOf(id string, number int64) string { return statusOf(mustCollection(id), number) } // TokenURI answers for every minted number, retired or not: the metadata of a // burned token is still part of the collection's public record. func TokenURI(id string, number int64) string { c := mustCollection(id) statusOf(c, number) return c.metadata.tokenURI(number) } func Render(path string) string { if path == "" { return "# Launchpad NFT collections\n\n" + strconv.Itoa(collections.Size()) + " collections.\n" } c := mustCollection(path) return "# " + meta.EscapeMarkdown(c.info.Name) + "\n\n" + "Mode: " + c.info.Mode + "\n\n" + "Minted: " + strconv.FormatInt(c.info.Minted, 10) + "\n" }
- #8terms.gno
- #9package nft import ( "chain" "strings" sm "gno.land/p/samcrew/launchpad/safemath/v1" "gno.land/r/samcrew/launchpad/config/v1" ) // Hard limits, fixed for the life of this realm. const ( maxRoyaltyReceivers = 10 maxRoyaltyBPS = 1000 // 10% of a sale, across all receivers maxMarkets = 5 ) // royalty is one receiver's share of every sale, in basis points. type royalty struct { Account address BPS int64 } type RoyaltyPayout struct { Account address Amount int64 } // parseBPS reads a positive decimal with no sign, space or leading zero. func parseBPS(s string) int64 { if s == "" || len(s) > 4 || s[0] == '0' { panic("launchpad/nft: invalid royalty share") } var n int64 for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { panic("launchpad/nft: invalid royalty share") } n = n*10 + int64(s[i]-'0') } return n } // parseRoyalties reads "address:bps;address:bps". Strictly ascending addresses // give every split exactly one encoding and rule out duplicates. func parseRoyalties(encoded, mode string) ([]royalty, int64) { if encoded == "" { if mode == ModeRoyaltyProtected { panic("launchpad/nft: a royalty-protected collection needs royalties") } return nil, 0 } if mode == ModeSoulbound { panic("launchpad/nft: soulbound tokens are never sold") } records := strings.Split(encoded, ";") if len(records) > maxRoyaltyReceivers { panic("launchpad/nft: too many royalty receivers") } out := make([]royalty, 0, len(records)) previous := "" var total int64 for _, record := range records { fields := strings.Split(record, ":") if len(fields) != 2 || fields[0] <= previous { panic("launchpad/nft: invalid royalty list") } who := address(fields[0]) // Unspendable covers every text but the chain's spelling of an account. if config.IsUnspendable(who) { panic("launchpad/nft: invalid royalty receiver") } bps := parseBPS(fields[1]) if total = sm.Add(total, bps); total > maxRoyaltyBPS { panic("launchpad/nft: royalties exceed 10%") } out = append(out, royalty{Account: who, BPS: bps}) previous = fields[0] } return out, total } // validName is the chain's rule for one segment of a package path: a letter // first, then letters and digits, joined by single "_" or "-". func validName(s string) bool { if s == "" || s[0] < 'a' || s[0] > 'z' { return false } joined := false // the previous character joins two runs for i := 1; i < len(s); i++ { switch c := s[i]; { case (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'): joined = false case (c == '_' || c == '-') && !joined: joined = true default: return false } } return !joined } // A market may be any realm the chain can host, a third party's included, so // its path follows the chain's own rule for a realm path: "gno.land/r/", then // names separated by "/", none ending in "_test", in at most 200 bytes. A slot // is permanent: none is given to a path no realm can have. The rule for // issuers is config's and is narrower (plain segments in our own namespace): // an issuer is one of ours, a market need not be. func validMarketPath(path string) bool { if len(path) > 200 || !strings.HasPrefix(path, "gno.land/r/") || strings.HasSuffix(path, "_test") { return false } for _, name := range strings.Split(path[len("gno.land/r/"):], "/") { if !validName(name) { return false } } return true } func RoyaltyBPS(id string) int64 { return mustCollection(id).royaltyBPS } // RoyaltyQuote floors each receiver's share of price. The seller keeps the // rounding remainder. func RoyaltyQuote(id string, price int64) []RoyaltyPayout { royalties := mustCollection(id).royalties out := make([]RoyaltyPayout, len(royalties)) for i, r := range royalties { out[i] = RoyaltyPayout{Account: r.Account, Amount: sm.BPS(price, r.BPS)} } return out } // AllowMarket adds a realm through which a royalty-protected collection's // tokens may be sold. The list starts with the Launchpad market and only // grows: a holder's way out can be widened, never taken away. The ledger // cannot make a realm pay: the creator vouches that the market it lists pays // this collection's royalties, and every holder can read the list. func AllowMarket(cur realm, id, pkgPath string) { c := creatorOf(cur, id) if c.info.Mode != ModeRoyaltyProtected || !validMarketPath(pkgPath) || len(c.markets) >= maxMarkets || operator(c, chain.PackageAddress(pkgPath)) { panic("launchpad/nft: market cannot be added") } market := chain.PackageAddress(pkgPath) c.markets = append(c.markets, market) chain.Emit("MarketAllowed", "collection", id, "market", pkgPath, "address", market.String()) }
- #10views.gno
- #11package nft import ( "strconv" "strings" "gno.land/p/nt/avl/v0" "gno.land/p/nt/seqid/v0" "gno.land/p/samcrew/launchpad/meta/v1" ) // Lists are paged: a zero-based page of 1 to maxPage entries. const maxPage = 50 // int64 values are decimal strings so a JSON client cannot round them. func num(n int64) string { return meta.JSONString(strconv.FormatInt(n, 10)) } // listJSON renders one page of a tree in key order. The tree seeks the first // entry by subtree sizes, so a deep page costs what the first one does. func listJSON(tree *avl.Tree, page, size int64, render func(key string, value any) string) string { if page < 0 || size < 1 || size > maxPage { panic("launchpad/nft: invalid page") } var b strings.Builder b.WriteByte('[') // A page past the end cannot overflow: nothing is multiplied before this. if tree != nil && page <= int64(tree.Size())/size { tree.IterateByOffset(int(page*size), int(size), func(key string, value any) bool { if b.Len() > 1 { b.WriteByte(',') } b.WriteString(render(key, value)) return false }) } b.WriteByte(']') return b.String() } func CollectionJSON(id string) string { c := mustCollection(id) i := c.info return "{" + `"id":` + meta.JSONString(i.ID) + `,"grc721Id":` + meta.JSONString(i.GRC721ID) + `,"issuer":` + meta.JSONString(i.Issuer.String()) + `,"creator":` + meta.JSONString(i.Creator.String()) + `,"originator":` + meta.JSONString(i.Originator.String()) + `,"pendingCreator":` + meta.JSONString(i.PendingCreator.String()) + `,"name":` + meta.JSONString(i.Name) + `,"symbol":` + meta.JSONString(i.Symbol) + `,"description":` + meta.JSONString(i.Description) + `,"image":` + meta.JSONString(i.Image) + `,"banner":` + meta.JSONString(i.Banner) + `,"website":` + meta.JSONString(i.Website) + `,"mode":` + meta.JSONString(i.Mode) + `,"revocable":` + strconv.FormatBool(i.Revocable) + `,"maxSupply":` + num(i.MaxSupply) + `,"sealed":` + strconv.FormatBool(i.Sealed) + `,"minted":` + num(i.Minted) + `,"totalSupply":` + num(c.token.TotalSupply()) + `,"profileFrozen":` + strconv.FormatBool(i.ProfileFrozen) + `,"metadataMode":` + meta.JSONString(c.metadata.mode) + `,"metadataFrozen":` + strconv.FormatBool(c.metadata.frozen) + `,"metadataRevision":` + num(c.metadata.revision) + `,"baseURI":` + meta.JSONString(c.metadata.baseURI) + `,"placeholderURI":` + meta.JSONString(c.metadata.placeholderURI) + `,"baseURICommitment":` + meta.JSONString(c.metadata.commitment) + `,"committer":` + meta.JSONString(c.metadata.committer.String()) + `,"provenanceHash":` + meta.JSONString(c.metadata.provenance) + `,"traitsRoot":` + meta.JSONString(c.traitsRoot) + `,"royaltyBPS":` + num(c.royaltyBPS) + `,"royalties":` + royaltiesJSON(c.royalties) + `,"markets":` + addressesJSON(c.markets) + "}" } func royaltiesJSON(royalties []royalty) string { var b strings.Builder b.WriteByte('[') for i, r := range royalties { if i > 0 { b.WriteByte(',') } b.WriteString(`{"account":` + meta.JSONString(r.Account.String()) + `,"bps":` + num(r.BPS) + "}") } b.WriteByte(']') return b.String() } func addressesJSON(list []address) string { var b strings.Builder b.WriteByte('[') for i, a := range list { if i > 0 { b.WriteByte(',') } b.WriteString(meta.JSONString(a.String())) } b.WriteByte(']') return b.String() } // CapabilitiesJSON tells a wallet or a market what a holder of this // collection can do, without it knowing the modes. "markets" is empty when // any operator may be approved. Royalties are paid by markets at sale. // "royaltyEnforcement" is "none" without royalties; "market_sales" for an open // collection, where the ledger enforces nothing: a market that honours // royalties pays them at a sale, and a direct transfer or any other realm // pays none; "listed_markets" when the ledger lets a token move only through // the realms in "markets". Whether a listed realm pays is the word of the // creator who listed it. "creatorRevoke" is the creator's right to revoke // a token of a revocable soulbound collection. func CapabilitiesJSON(id string) string { c := mustCollection(id) i := c.info enforcement := "none" if c.royaltyBPS > 0 { enforcement = "market_sales" if i.Mode == ModeRoyaltyProtected { enforcement = "listed_markets" } } return "{" + `"schema":"launchpad-nft-capabilities/v1"` + `,"collection":` + meta.JSONString(id) + `,"standard":"grc721"` + `,"mode":` + meta.JSONString(i.Mode) + `,"holderTransfer":` + strconv.FormatBool(i.Mode == ModeOpen) + `,"marketSale":` + strconv.FormatBool(i.Mode != ModeSoulbound) + `,"markets":` + addressesJSON(c.markets) + `,"holderBurn":true` + `,"creatorRevoke":` + strconv.FormatBool(i.Revocable) + `,"maxSupply":` + num(i.MaxSupply) + `,"metadataMode":` + meta.JSONString(c.metadata.mode) + `,"metadataFrozen":` + strconv.FormatBool(c.metadata.frozen) + `,"traitsCommitted":` + strconv.FormatBool(c.traitsRoot != "") + `,"royaltyBPS":` + num(c.royaltyBPS) + `,"royaltyEnforcement":` + meta.JSONString(enforcement) + "}" } // ListCollectionsJSON pages through a summary of every collection, in // creation order. CollectionJSON has the full record. func ListCollectionsJSON(page, size int64) string { return listJSON(&collections, page, size, func(_ string, value any) string { i := value.(*collection).info return "{" + `"id":` + meta.JSONString(i.ID) + `,"creator":` + meta.JSONString(i.Creator.String()) + `,"name":` + meta.JSONString(i.Name) + `,"symbol":` + meta.JSONString(i.Symbol) + `,"image":` + meta.JSONString(i.Image) + `,"mode":` + meta.JSONString(i.Mode) + `,"maxSupply":` + num(i.MaxSupply) + `,"sealed":` + strconv.FormatBool(i.Sealed) + `,"minted":` + num(i.Minted) + "}" }) } func tokenJSON(c *collection, number int64) string { status := statusOf(c, number) var owner address if status == StatusActive { owner = ownerOf(c, number) } return "{" + `"collection":` + meta.JSONString(c.info.ID) + `,"number":` + num(number) + `,"owner":` + meta.JSONString(owner.String()) + `,"status":` + meta.JSONString(status) + `,"uri":` + meta.JSONString(c.metadata.tokenURI(number)) + "}" } // TokenJSON describes one minted number. A retired token has no owner. func TokenJSON(id string, number int64) string { return tokenJSON(mustCollection(id), number) } // ApprovalJSON answers, in one read, whether operator may move an active // token: by a single-token approval or by the owner's collection-wide one. func ApprovalJSON(id string, number int64, operator string) string { c := mustCollection(id) owner := ownerOf(c, number) who := address(operator) if !meta.ValidAddress(who) { panic("launchpad/nft: invalid operator") } return "{" + `"collection":` + meta.JSONString(id) + `,"number":` + num(number) + `,"owner":` + meta.JSONString(owner.String()) + `,"operator":` + meta.JSONString(operator) + `,"tokenApproved":` + strconv.FormatBool(GetApproved(id, number) == who) + `,"collectionApproved":` + strconv.FormatBool(c.token.IsApprovedForAll(owner, who)) + "}" } // TokensJSON pages through a collection's minted numbers in order, retired // ones included. func TokensJSON(id string, page, size int64) string { if page < 0 || size < 1 || size > maxPage { panic("launchpad/nft: invalid page") } c := mustCollection(id) var b strings.Builder b.WriteByte('[') if page <= c.info.Minted/size { for number := page*size + 1; number <= c.info.Minted && size > 0; number, size = number+1, size-1 { if b.Len() > 1 { b.WriteByte(',') } b.WriteString(tokenJSON(c, number)) } } b.WriteByte(']') return b.String() } // HoldingsJSON pages through the tokens an account holds, across every // collection, in collection then number order. func HoldingsJSON(owner string, page, size int64) string { if !meta.ValidAddress(address(owner)) { panic("launchpad/nft: invalid owner") } mine, _ := holdings.Get(owner).(*avl.Tree) return listJSON(mine, page, size, func(key string, _ any) string { parts := strings.Split(key, "/") c := collections.Get(parts[0]).(*collection) number, _ := seqid.FromString(parts[1]) return "{" + `"collection":` + meta.JSONString(c.info.ID) + `,"number":` + num(int64(number)) + `,"uri":` + meta.JSONString(c.metadata.tokenURI(int64(number))) + "}" }) }
- #12/gno.MemPackageType
- #13 MPUserAll
Result log
msg:0,success:true,log:,events:[]