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}