Search Apps Documentation Source Content File Folder Download Copy Actions Download

collection.gno

10.37 Kb · 273 lines
  1// Package memba_collections is the canonical, multi-collection NFT registry
  2// for the Memba open marketplace + launchpad. It is the ONE irreversible
  3// realm: it holds every launched collection's internal grc721 ledger, so it
  4// can never be redeployed without orphaning NFTs. The full frozen ABI lives
  5// in Memba/docs/planning/CANONICAL_COLLECTION_ABI.md (v3, build-ready).
  6//
  7// Design anchors:
  8//   - grc721 Reader/Writer split: the realm holds the concrete *metadataNFT
  9//     behind the unexported membaNFT interface; writes derive the caller from
 10//     unsafe.PreviousRealm().Address() inside crossing wrappers.
 11//   - Royalty is enforced IN-REALM as BPS (never grc721's percent/100 path).
 12//   - MarketTransfer is the ONLY token-movement path; only registered engines
 13//     may call it (the moat). There is no public TransferFrom/gift/airdrop.
 14//   - Both ugnot and GRC20 mint payments transit the realm via a per-denom
 15//     proceeds ledger; CEI ordering is frozen (effects before interactions).
 16package memba_collections
 17
 18import (
 19	"chain"
 20	"chain/runtime/unsafe"
 21	"strconv"
 22
 23	"gno.land/p/samcrew/grc721"
 24	"gno.land/p/nt/avl/v0"
 25)
 26
 27// ── Tunable bounds (constants — permanent) ──────────────────────────────────
 28const (
 29	MaxRoyaltyBPS        = 1000                 // 10% hard ceiling (defensive)
 30	DefaultRoyaltyBPS    = 500                  // 5% applied when creator passes RoyaltySentinel
 31	MinCreatorRoyaltyBPS = 0                    // creators may opt fully out
 32	MinMintPrice         = 1000                 // 0.001 GNOT — anti-truncation
 33	MaxPriceUgnot        = 1_000_000_000_000_000 // 1e15
 34	MaxPrimaryFeeBPS     = 2000                 // 20% — bounds primaryFeeBPS so MaxPriceUgnot*bps < MaxInt64
 35	RoyaltySentinel      = -1                   // CreateCollection royaltyBPS sentinel → DefaultRoyaltyBPS
 36	MaxSlugLen           = 64
 37)
 38
 39// AdminAddress is the Samouraï 2-of-2 multisig (samcrew-core-test1). Seeds
 40// platformAdmin, pauser, and feeRecipient at genesis; all three are mutable.
 41const AdminAddress = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0"
 42
 43// ── Platform state (mutable; platformAdmin → memba_dao executor on mainnet) ─
 44var (
 45	platformAdmin        address
 46	pendingPlatformAdmin address
 47	pauser               address // SEPARATE fast-pause role (multisig)
 48	feeRecipient         address // createFee + primaryFee sink (DAO treasury)
 49	createFee            int64   // anti-spam launch fee
 50	primaryFeeBPS        int64   // platform cut on primary mints (default 0; <= MaxPrimaryFeeBPS)
 51	maxCreatorRoyaltyBPS int64   // creator-set royalty cap (default 750)
 52	allowedDenoms        = avl.NewTree() // denom key -> bool (curated mint tokens; seed "ugnot")
 53	registeredMarkets    = avl.NewTree() // marketAddr string -> bool (DRAIN KEY)
 54	paused               bool            // global pause
 55	collections          = avl.NewTree() // collectionID "creator/slug" -> *collection
 56)
 57
 58func init() {
 59	platformAdmin = address(AdminAddress)
 60	pauser = address(AdminAddress)
 61	feeRecipient = address(AdminAddress)
 62	createFee = 1_000_000 // 1 GNOT
 63	primaryFeeBPS = 0
 64	maxCreatorRoyaltyBPS = 750
 65	allowedDenoms.Set("ugnot", true)
 66}
 67
 68// membaNFT is the subset of *grc721.metadataNFT the realm calls (verified
 69// against grc721_metadata.gno). NO royalty methods — royalty is in-realm.
 70// Burn takes no caller (wrapper own-checks); SetTokenURI returns (bool, error).
 71type membaNFT interface {
 72	Name() string
 73	Symbol() string
 74	TokenCount() int64
 75	BalanceOf(owner address) (int64, error)
 76	OwnerOf(tid grc721.TokenID) (address, error)
 77	GetApproved(tid grc721.TokenID) (address, error)
 78	IsApprovedForAll(owner, operator address) bool
 79	TokenURI(tid grc721.TokenID) (string, error)
 80	Mint(to address, tid grc721.TokenID) error
 81	Approve(caller, to address, tid grc721.TokenID) error
 82	SetApprovalForAll(caller, operator address, approved bool) error
 83	TransferFrom(caller, from, to address, tid grc721.TokenID) error
 84	SetTokenURI(caller address, tid grc721.TokenID, tURI grc721.TokenURI) (bool, error)
 85	Burn(tid grc721.TokenID) error
 86}
 87
 88type royalty struct {
 89	recip address
 90	bps   int64
 91}
 92
 93type collection struct {
 94	nft          membaNFT // *grc721.metadataNFT (internal ledger)
 95	creator      address  // immutable record of who launched it
 96	admin        address  // mutable (2-step; platformAdmin break-glass)
 97	pendingAdmin address
 98	// royalty — ALL in-realm, BPS
 99	royaltyRecip address
100	royaltyBPS   int64
101	tokenRoyalty *avl.Tree // tokenId -> royalty (per-token override; PRESENCE wins)
102	// mint config
103	phase              int    // 0 draft, 1 allowlist, 2 public, 3 closed
104	allowlistRoot      string // hex sha256 root (tagged-hash; leaf=sha256(0x00‖addr.String()‖":"‖maxQty))
105	mintPrice          int64  // [MinMintPrice, MaxPriceUgnot]
106	payDenom           string // "" / "ugnot" = native; else an allowedDenoms key
107	maxSupply          int64  // 0 = unlimited; gate on nextAutoTokenID (NOT TokenCount)
108	maxPerWallet       int64  // 0 = unlimited (UX guard)
109	mintStartBlock     int64  // 0 = open; else mint allowed only at/after this height
110	mintCooldownBlocks int64  // 0 = none; per-wallet cooldown
111	mintedByWallet     *avl.Tree // minter -> count
112	lastMintBlock      *avl.Tree // minter -> last mint height
113	nextAutoTokenID    int64     // sequential id for ALL mints; ALSO the supply counter
114	mintCustody        address   // fixed-at-create creator sink for withdrawn proceeds
115	proceeds           *avl.Tree // denom -> accrued creatorAmt
116	meta               *avl.Tree // extensible per-collection flags (platformAdmin)
117	paused             bool      // per-collection pause
118	mintGuard          bool      // reentrancy guard for mint + withdraw + refund
119}
120
121// ── Helpers ─────────────────────────────────────────────────────────────────
122
123func mustGet(id string) *collection {
124	v, ok := collections.Get(id)
125	if !ok {
126		panic("collection not found: " + id)
127	}
128	return v.(*collection)
129}
130
131func caller() address { return unsafe.PreviousRealm().Address() }
132
133func assertPlatformAdmin() {
134	if caller() != platformAdmin {
135		panic("platform admin only")
136	}
137}
138
139func assertCollectionAdmin(c *collection) {
140	if caller() != c.admin {
141		panic("collection admin only")
142	}
143}
144
145func assertNotPaused(c *collection) {
146	if paused || c.paused {
147		panic("paused")
148	}
149}
150
151func itoa(n int64) string { return strconv.FormatInt(n, 10) }
152
153// validSlug enforces the frozen charset ^[a-z0-9-]{1,64}$ (B-2). Rejecting
154// '/' and ':' prevents collectionID ("creator/slug") namespace spoofing.
155func validSlug(s string) bool {
156	if len(s) == 0 || len(s) > MaxSlugLen {
157		return false
158	}
159	for i := 0; i < len(s); i++ {
160		c := s[i]
161		if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
162			return false
163		}
164	}
165	return true
166}
167
168// ── Launchpad: CreateCollection + 2-step collection admin transfer ──────────
169
170// CreateCollection launches a new collection. Open + fee-gated: anyone may
171// call it, paying createFee in ugnot. royaltyBPS == RoyaltySentinel(-1) uses
172// DefaultRoyaltyBPS; otherwise it is clamped to [MinCreatorRoyaltyBPS,
173// maxCreatorRoyaltyBPS]. name/symbol are COSMETIC only — identity is the
174// derived collectionID = caller/slug.
175func CreateCollection(cur realm, slug, name, symbol string, royaltyBPS int64, royaltyRecip, mintCustody address, maxSupply, maxPerWallet int64) string {
176	if !unsafe.PreviousRealm().IsUserCall() {
177		panic("must be a direct user call")
178	}
179	return createCollectionInternal(cur, caller(), unsafe.OriginSend(), slug, name, symbol, royaltyBPS, royaltyRecip, mintCustody, maxSupply, maxPerWallet)
180}
181
182// createCollectionInternal holds the full create logic with the caller and
183// sent coins passed explicitly, so it is unit-testable without the IsUserCall
184// guard (which is unreachable for a direct test-body cross-call).
185func createCollectionInternal(cur realm, cr address, sentCoins chain.Coins, slug, name, symbol string, royaltyBPS int64, royaltyRecip, mintCustody address, maxSupply, maxPerWallet int64) string {
186	if paused {
187		panic("paused")
188	}
189	if !validSlug(slug) {
190		panic("invalid slug: must match ^[a-z0-9-]{1,64}$")
191	}
192	id := cr.String() + "/" + slug
193	if _, ok := collections.Get(id); ok {
194		panic("collection exists: " + id)
195	}
196	// createFee gate (native ugnot). Any ugnot beyond createFee is refunded to
197	// the creator — including the entire envelope when createFee==0, so a free
198	// create never traps attached funds (LOW stuck-funds trap).
199	sent := sumDenom(sentCoins, "ugnot")
200	if createFee > 0 {
201		if sent < createFee {
202			panic("insufficient createFee")
203		}
204		sendNative(cur, feeRecipient, createFee)
205	}
206	if over := sent - createFee; over > 0 {
207		sendNative(cur, cr, over)
208	}
209	// royalty resolution
210	rbps := royaltyBPS
211	if rbps == RoyaltySentinel {
212		rbps = DefaultRoyaltyBPS
213	}
214	if rbps < MinCreatorRoyaltyBPS {
215		rbps = MinCreatorRoyaltyBPS
216	}
217	if rbps > maxCreatorRoyaltyBPS {
218		rbps = maxCreatorRoyaltyBPS
219	}
220	if mintCustody == "" {
221		mintCustody = cr
222	}
223	if royaltyRecip == "" {
224		royaltyRecip = cr
225	}
226	c := &collection{
227		nft:             grc721.NewNFTWithMetadata(0, cur, name, symbol),
228		creator:         cr,
229		admin:           cr,
230		royaltyRecip:    royaltyRecip,
231		royaltyBPS:      rbps,
232		tokenRoyalty:    avl.NewTree(),
233		maxSupply:       maxSupply,
234		maxPerWallet:    maxPerWallet,
235		mintedByWallet:  avl.NewTree(),
236		lastMintBlock:   avl.NewTree(),
237		mintCustody:     mintCustody,
238		proceeds:        avl.NewTree(),
239		meta:            avl.NewTree(),
240	}
241	collections.Set(id, c)
242	chain.Emit("CollectionCreated",
243		"collectionID", id,
244		"creator", cr.String(),
245		"name", name,
246		"symbol", symbol,
247		"royaltyBPS", itoa(rbps),
248		"royaltyRecip", royaltyRecip.String(),
249		"maxSupply", itoa(maxSupply),
250		"createFee", itoa(createFee),
251		"block", itoa(chainHeight()),
252	)
253	return id
254}
255
256// SetCollectionAdmin proposes a new collection admin (step 1 of 2).
257func SetCollectionAdmin(cur realm, id string, newAdmin address) {
258	c := mustGet(id)
259	assertCollectionAdmin(c)
260	c.pendingAdmin = newAdmin
261	chain.Emit("CollectionAdminTransferred", "collectionID", id, "pending", newAdmin.String())
262}
263
264// AcceptCollectionAdmin completes the transfer (step 2 of 2).
265func AcceptCollectionAdmin(cur realm, id string) {
266	c := mustGet(id)
267	if caller() != c.pendingAdmin {
268		panic("not pending admin")
269	}
270	c.admin = c.pendingAdmin
271	c.pendingAdmin = ""
272	chain.Emit("CollectionAdminAccepted", "collectionID", id, "admin", c.admin.String())
273}