Search Apps Documentation Source Content File Folder Download Copy Actions Download

nftmarket.gno

7.77 Kb · 238 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 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	createdAt  int64   // block height at creation
 72	nft        *grc721.BasicNFT
 73}
 74
 75// Listing is an active fixed-price sale. While listed, the NFT is escrowed in
 76// the realm's own account (its on-chain owner is the realm address).
 77type Listing struct {
 78	collID    string
 79	tokenID   string
 80	seller    address
 81	price     int64 // ugnot
 82	createdAt int64 // block height
 83}
 84
 85var (
 86	collections avl.Tree // collID -> *Collection
 87	listings    avl.Tree // "collID/tokenID" -> *Listing
 88	collOrder   []string // collection ids in creation order, for rendering
 89
 90	admin  address       // platform admin; empty until ClaimAdmin is called
 91	feeBps int64   = 250 // platform fee on secondary sales (2.5%)
 92	feePot int64         // ugnot collected as fees, withdrawable by admin
 93)
 94
 95// --- collection factory -----------------------------------------------------
 96
 97// CreateCollection registers a new NFT collection owned by the caller and
 98// returns its id. mintPrice and maxSupply may be 0 (free / unlimited).
 99// royaltyBps is the creator's cut of every future secondary sale (0..5000).
100func CreateCollection(cur realm, id, name, symbol, baseURI string, mintPrice, maxSupply, royaltyBps int64) string {
101	assertUserCall(cur)
102	creator := cur.Previous().Address()
103
104	if !validSlug(id) {
105		panic("collection id must be 1-40 chars of [a-z0-9-]")
106	}
107	if _, exists := collections.Get(id); exists {
108		panic("collection id already taken: " + id)
109	}
110	if mintPrice < 0 {
111		panic("mintPrice cannot be negative")
112	}
113	if maxSupply < 0 {
114		panic("maxSupply cannot be negative")
115	}
116	if royaltyBps < 0 || royaltyBps > maxRoyaltyBps {
117		panic(ufmt.Sprintf("royaltyBps must be between 0 and %d", maxRoyaltyBps))
118	}
119
120	// NewBasicNFT validates name/symbol and panics on invalid input.
121	nft := grc721.NewBasicNFT(0, cur, name, symbol)
122
123	collections.Set(id, &Collection{
124		id:         id,
125		name:       name,
126		symbol:     symbol,
127		creator:    creator,
128		baseURI:    baseURI,
129		mintPrice:  mintPrice,
130		maxSupply:  maxSupply,
131		royaltyBps: royaltyBps,
132		minted:     0,
133		createdAt:  runtime.ChainHeight(),
134		nft:        nft,
135	})
136	collOrder = append(collOrder, id)
137
138	chain.Emit("CreateCollection", "id", id, "creator", creator.String(), "symbol", symbol)
139	return id
140}
141
142// Mint mints the next token of a collection to the caller, who must attach
143// exactly the collection's mint price in ugnot. The proceeds go to the
144// collection creator. Returns the newly minted token id.
145func Mint(cur realm, collID string) string {
146	assertUserCall(cur)
147	coll := mustGetCollection(collID)
148	minter := cur.Previous().Address()
149
150	if coll.maxSupply > 0 && coll.minted >= coll.maxSupply {
151		panic("collection is sold out")
152	}
153
154	paid := receivedUgnot()
155	if paid != coll.mintPrice {
156		panic(ufmt.Sprintf("must send exactly %d ugnot to mint (sent %d)", coll.mintPrice, paid))
157	}
158
159	tokenID := strconv.FormatInt(coll.minted+1, 10)
160	if err := coll.nft.Mint(minter, grc721.TokenID(tokenID)); err != nil {
161		panic(err)
162	}
163	coll.minted++
164
165	// Primary sale: the creator receives the full mint price.
166	payout(cur, coll.creator, paid)
167
168	chain.Emit("Mint", "collection", collID, "tokenId", tokenID, "minter", minter.String())
169	return tokenID
170}
171
172// --- shared helpers ---------------------------------------------------------
173
174// assertUserCall rejects non-EOA callers. unsafe.OriginSend() only describes a
175// real receipt at this realm when the caller is a pure user (maketx call);
176// an intermediate code realm or `maketx run` envelope could otherwise make the
177// realm pay out its own pre-existing balance. See r/demo/disperse.
178func assertUserCall(cur realm) {
179	if !cur.Previous().IsUserCall() {
180		panic("only direct user calls (gnokey maketx call) are accepted")
181	}
182}
183
184// receivedUgnot returns the amount of ugnot attached to the current call and
185// rejects any other denomination.
186func receivedUgnot() int64 {
187	sent := unsafe.OriginSend()
188	for _, c := range sent {
189		if c.Denom != ugnot {
190			panic("only ugnot is accepted as payment")
191		}
192	}
193	return sent.AmountOf(ugnot)
194}
195
196// payout sends amount ugnot from the realm's escrow to addr. No-op for amount<=0.
197func payout(cur realm, addr address, amount int64) {
198	if amount <= 0 {
199		return
200	}
201	b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
202	b.SendCoins(cur.Address(), addr, chain.Coins{chain.NewCoin(ugnot, amount)})
203}
204
205func mustGetCollection(id string) *Collection {
206	v, ok := collections.Get(id)
207	if !ok {
208		panic("collection not found: " + id)
209	}
210	return v.(*Collection)
211}
212
213func listingKey(collID, tokenID string) string { return collID + "/" + tokenID }
214
215func getListing(collID, tokenID string) (*Listing, bool) {
216	v, ok := listings.Get(listingKey(collID, tokenID))
217	if !ok {
218		return nil, false
219	}
220	return v.(*Listing), true
221}
222
223// now returns the current block height, used to timestamp records.
224func now() int64 { return runtime.ChainHeight() }
225
226// validSlug reports whether s is a valid collection id: 1..maxSlugLen chars of
227// lowercase letters, digits and hyphens.
228func validSlug(s string) bool {
229	if len(s) == 0 || len(s) > maxSlugLen {
230		return false
231	}
232	for _, c := range s {
233		if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
234			return false
235		}
236	}
237	return true
238}