Search Apps Documentation Source Content File Folder Download Copy Actions Download

nftmarket.gno

8.08 Kb · 245 lines
  1// Package nftmarket is a multi-collection NFT marketplace realm for gno.land,
  2// in the spirit of OpenSea but built for GnoVM.
  3//
  4// It bundles three capabilities in a single realm:
  5//
  6//  1. Collection factory — any user can create their own NFT collection with
  7//     CreateCollection and becomes its creator (royalty + mint-proceeds
  8//     recipient).
  9//  2. Open minting — any user can mint a token from any collection with Mint,
 10//     paying the collection's mint price (which goes to the creator).
 11//  3. Marketplace — a token owner lists an NFT for a fixed price with List,
 12//     and anyone buys it with Buy. The sale price is split between the seller,
 13//     the collection creator (royalty) and the platform (fee).
 14//
 15// Why one realm instead of separate "nft" and "market" realms?
 16// In gno.land's GRC721 design, only the realm that holds the concrete
 17// *grc721.BasicNFT may move its tokens — across a realm boundary it exposes a
 18// read-only view (see p/demo/tokens/grc721/igrc721.gno). Keeping minting and
 19// the market in the same realm lets the market escrow and transfer sold NFTs
 20// internally, without fragile cross-realm approvals.
 21//
 22// Payments use the native coin (ugnot). Buyers and minters attach coins to the
 23// call (gnokey ... -send "<amount>ugnot"); the realm escrows them and pays the
 24// recipients out via the banker. All amounts are in ugnot (1 GNOT = 1_000_000
 25// ugnot).
 26package gnogle_nftmarket
 27
 28import (
 29	"chain"
 30	"chain/banker"
 31	"chain/runtime"
 32	"chain/runtime/unsafe"
 33	"strconv"
 34
 35	"gno.land/p/g18wk4a80cr7dqa25vfka2yug5n3pd50udled6y3/grc721"
 36	"gno.land/p/nt/avl/v0"
 37	"gno.land/p/nt/ufmt/v0"
 38)
 39
 40const (
 41	ugnot = "ugnot"
 42
 43	// bpsDenominator is the basis-points denominator (10000 bps = 100%).
 44	bpsDenominator = 10000
 45
 46	// maxRoyaltyBps caps a collection's secondary-sale royalty at 50%.
 47	maxRoyaltyBps = 5000
 48
 49	// maxFeeBps caps the platform fee at 10%.
 50	maxFeeBps = 1000
 51
 52	// maxSlugLen bounds a collection id.
 53	maxSlugLen = 40
 54)
 55
 56var zeroAddr address // the empty/invalid address
 57
 58// Collection is one NFT collection created by a user.
 59type Collection struct {
 60	id         string  // unique slug, e.g. "cryptopunks"
 61	name       string  // human-readable name
 62	symbol     string  // ticker, <= 11 chars of [A-Za-z0-9_-]
 63	creator    address // receives mint proceeds and royalties
 64	baseURI    string  // metadata base; tokenURI = baseURI + tokenID
 65	mintPrice  int64   // ugnot charged per mint (0 = free), paid to creator
 66	maxSupply  int64   // hard cap (0 = unlimited)
 67	royaltyBps int64   // creator royalty on secondary sales, in bps
 68	minted     int64   // number minted so far; the next token id is minted+1
 69	burned     int64   // number of tokens burned
 70	sealed     bool    // when true, no further minting (supply capped at minted)
 71	verified   bool    // admin-set "verified collection" badge
 72	createdAt  int64   // block height at creation
 73	nft        *grc721.BasicNFT
 74}
 75
 76// Listing is an active fixed-price sale. While listed, the NFT is escrowed in
 77// the realm's own account (its on-chain owner is the realm address).
 78type Listing struct {
 79	collID    string
 80	tokenID   string
 81	seller    address
 82	price     int64 // ugnot
 83	createdAt int64 // block height
 84}
 85
 86var (
 87	collections avl.Tree // collID -> *Collection
 88	listings    avl.Tree // "collID/tokenID" -> *Listing
 89	collOrder   []string // collection ids in creation order, for rendering
 90
 91	admin  address       // platform admin; empty until ClaimAdmin is called
 92	feeBps int64   = 250 // platform fee on secondary sales (2.5%)
 93	feePot int64         // ugnot collected as fees, withdrawable by admin
 94)
 95
 96// --- collection factory -----------------------------------------------------
 97
 98// CreateCollection registers a new NFT collection owned by the caller and
 99// returns its id. mintPrice and maxSupply may be 0 (free / unlimited).
100// royaltyBps is the creator's cut of every future secondary sale (0..5000).
101func CreateCollection(cur realm, id, name, symbol, baseURI string, mintPrice, maxSupply, royaltyBps int64) string {
102	assertUserCall(cur)
103	creator := cur.Previous().Address()
104
105	if !validSlug(id) {
106		panic("collection id must be 1-40 chars of [a-z0-9-]")
107	}
108	if _, exists := collections.Get(id); exists {
109		panic("collection id already taken: " + id)
110	}
111	if mintPrice < 0 {
112		panic("mintPrice cannot be negative")
113	}
114	if maxSupply < 0 {
115		panic("maxSupply cannot be negative")
116	}
117	if royaltyBps < 0 || royaltyBps > maxRoyaltyBps {
118		panic(ufmt.Sprintf("royaltyBps must be between 0 and %d", maxRoyaltyBps))
119	}
120
121	// NewBasicNFT validates name/symbol and panics on invalid input.
122	nft := grc721.NewBasicNFT(0, cur, name, symbol)
123
124	collections.Set(id, &Collection{
125		id:         id,
126		name:       name,
127		symbol:     symbol,
128		creator:    creator,
129		baseURI:    baseURI,
130		mintPrice:  mintPrice,
131		maxSupply:  maxSupply,
132		royaltyBps: royaltyBps,
133		minted:     0,
134		createdAt:  runtime.ChainHeight(),
135		nft:        nft,
136	})
137	collOrder = append(collOrder, id)
138
139	chain.Emit("CreateCollection", "id", id, "creator", creator.String(), "symbol", symbol)
140	return id
141}
142
143// Mint mints the next token of a collection to the caller, who must attach
144// exactly the collection's mint price in ugnot. The proceeds go to the
145// collection creator. Returns the newly minted token id.
146func Mint(cur realm, collID string) string {
147	assertUserCall(cur)
148	coll := mustGetCollection(collID)
149	minter := cur.Previous().Address()
150
151	paid := receivedUgnot()
152	if paid != coll.mintPrice {
153		panic(ufmt.Sprintf("must send exactly %d ugnot to mint (sent %d)", coll.mintPrice, paid))
154	}
155
156	tokenID := mintOne(coll, minter)
157
158	// Primary sale: the creator receives the full mint price.
159	payout(cur, coll.creator, paid)
160
161	chain.Emit("Mint", "collection", collID, "tokenId", tokenID, "minter", minter.String())
162	return tokenID
163}
164
165// mintOne mints the next sequential token of coll to minter and returns its id.
166// Enforces the supply cap. Shared by Mint and MintBatch.
167func mintOne(coll *Collection, minter address) string {
168	if coll.maxSupply > 0 && coll.minted >= coll.maxSupply {
169		panic("collection is sold out")
170	}
171	tokenID := strconv.FormatInt(coll.minted+1, 10)
172	if err := coll.nft.Mint(minter, grc721.TokenID(tokenID)); err != nil {
173		panic(err)
174	}
175	coll.minted++
176	return tokenID
177}
178
179// --- shared helpers ---------------------------------------------------------
180
181// assertUserCall rejects non-EOA callers. unsafe.OriginSend() only describes a
182// real receipt at this realm when the caller is a pure user (maketx call);
183// an intermediate code realm or `maketx run` envelope could otherwise make the
184// realm pay out its own pre-existing balance. See r/demo/disperse.
185func assertUserCall(cur realm) {
186	if !cur.Previous().IsUserCall() {
187		panic("only direct user calls (gnokey maketx call) are accepted")
188	}
189}
190
191// receivedUgnot returns the amount of ugnot attached to the current call and
192// rejects any other denomination.
193func receivedUgnot() int64 {
194	sent := unsafe.OriginSend()
195	for _, c := range sent {
196		if c.Denom != ugnot {
197			panic("only ugnot is accepted as payment")
198		}
199	}
200	return sent.AmountOf(ugnot)
201}
202
203// payout sends amount ugnot from the realm's escrow to addr. No-op for amount<=0.
204func payout(cur realm, addr address, amount int64) {
205	if amount <= 0 {
206		return
207	}
208	b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
209	b.SendCoins(cur.Address(), addr, chain.Coins{chain.NewCoin(ugnot, amount)})
210}
211
212func mustGetCollection(id string) *Collection {
213	v, ok := collections.Get(id)
214	if !ok {
215		panic("collection not found: " + id)
216	}
217	return v.(*Collection)
218}
219
220func listingKey(collID, tokenID string) string { return collID + "/" + tokenID }
221
222func getListing(collID, tokenID string) (*Listing, bool) {
223	v, ok := listings.Get(listingKey(collID, tokenID))
224	if !ok {
225		return nil, false
226	}
227	return v.(*Listing), true
228}
229
230// now returns the current block height, used to timestamp records.
231func now() int64 { return runtime.ChainHeight() }
232
233// validSlug reports whether s is a valid collection id: 1..maxSlugLen chars of
234// lowercase letters, digits and hyphens.
235func validSlug(s string) bool {
236	if len(s) == 0 || len(s) > maxSlugLen {
237		return false
238	}
239	for _, c := range s {
240		if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
241			return false
242		}
243	}
244	return true
245}