Search Apps Documentation Source Content File Folder Download Copy Actions Download

mint.gno

8.40 Kb · 279 lines
  1package memba_collections
  2
  3import (
  4	"chain"
  5	"chain/runtime/unsafe"
  6	"math/overflow"
  7	"strings"
  8
  9	"gno.land/p/samcrew/grc721"
 10)
 11
 12// ── proceeds ledger + small helpers ─────────────────────────────────────────
 13
 14func walletCount(c *collection, who address) int64 {
 15	if v, ok := c.mintedByWallet.Get(who.String()); ok {
 16		return v.(int64)
 17	}
 18	return 0
 19}
 20
 21func proceedsOf(c *collection, key string) int64 {
 22	if v, ok := c.proceeds.Get(key); ok {
 23		return v.(int64)
 24	}
 25	return 0
 26}
 27
 28func creditProceeds(c *collection, denom string, amt int64) {
 29	key := denomKey(denom)
 30	c.proceeds.Set(key, proceedsOf(c, key)+amt)
 31}
 32
 33func assertDenomAllowed(denom string) {
 34	if isNativeDenom(denom) {
 35		return
 36	}
 37	if _, ok := allowedDenoms.Get(denom); !ok {
 38		panic("denom not allowed: " + denom)
 39	}
 40}
 41
 42func boolStr(b bool) string {
 43	if b {
 44		return "true"
 45	}
 46	return "false"
 47}
 48
 49// mintChecks enforces the non-supply gating: maxPerWallet, mintStartBlock,
 50// per-wallet cooldown. Supply is gated inside mintToken (on nextAutoTokenID).
 51func mintChecks(c *collection, minter address) {
 52	if c.maxPerWallet > 0 && walletCount(c, minter)+1 > c.maxPerWallet {
 53		panic("per-wallet mint limit reached")
 54	}
 55	h := chainHeight()
 56	if c.mintStartBlock > 0 && h < c.mintStartBlock {
 57		panic("mint not started")
 58	}
 59	if c.mintCooldownBlocks > 0 {
 60		if v, ok := c.lastMintBlock.Get(minter.String()); ok {
 61			if h-v.(int64) < c.mintCooldownBlocks {
 62				panic("mint cooldown active")
 63			}
 64		}
 65	}
 66}
 67
 68// mintToken performs the raw mint: supply gate on the mint-count (nextAutoTokenID,
 69// NOT TokenCount — Burn never reopens a slot, S-2), auto-assigns the sequential
 70// tid, sets the URI, and bumps the counter. Returns the tid string.
 71func mintToken(cur realm, c *collection, to address, tokenURI string) string {
 72	if c.maxSupply > 0 && c.nextAutoTokenID >= c.maxSupply {
 73		panic("max supply reached")
 74	}
 75	tidStr := itoa(c.nextAutoTokenID)
 76	tid := grc721.TokenID(tidStr)
 77	if err := c.nft.Mint(to, tid); err != nil {
 78		panic(err.Error())
 79	}
 80	if tokenURI != "" {
 81		if _, err := c.nft.SetTokenURI(to, tid, grc721.TokenURI(tokenURI)); err != nil {
 82			panic(err.Error())
 83		}
 84	}
 85	c.nextAutoTokenID++
 86	return tidStr
 87}
 88
 89// ── Admin mint (no payment) ─────────────────────────────────────────────────
 90
 91// Mint is the admin no-pay mint. Auto-assigns the next sequential tid.
 92func Mint(cur realm, id string, to address, tokenURI string) string {
 93	c := mustGet(id)
 94	assertCollectionAdmin(c)
 95	assertNotPaused(c)
 96	tid := mintToken(cur, c, to, tokenURI)
 97	chain.Emit("Mint",
 98		"collectionID", id,
 99		"tokenId", tid,
100		"minter", caller().String(),
101		"to", to.String(),
102		"block", itoa(chainHeight()),
103	)
104	return tid
105}
106
107// ── Public + allowlist paid mints ───────────────────────────────────────────
108
109// MintPublic mints in the public phase, paying mintPrice in payDenom.
110func MintPublic(cur realm, id, tokenURI string) string {
111	if !unsafe.PreviousRealm().IsUserCall() {
112		panic("must be a direct user call")
113	}
114	return mintPublicInternal(cur, id, caller(), unsafe.OriginSend(), tokenURI)
115}
116
117func mintPublicInternal(cur realm, id string, minter address, sent chain.Coins, tokenURI string) string {
118	c := mustGet(id)
119	if c.phase != PhasePublic {
120		panic("not in public phase")
121	}
122	return paidMint(cur, c, id, minter, sent, tokenURI, PhasePublic, false)
123}
124
125// MintAllowlist mints in the allowlist phase. The caller proves membership via
126// a Merkle proof of (caller, maxQty); per-wallet quantity is enforced.
127//
128// `proof` is the comma-separated list of hex-encoded sibling hashes. It MUST
129// cross the ABI as a single string: a vm/MsgCall arg cannot encode a []string
130// (the VM only converts scalars and base64 []byte — gno convert.go), so a
131// []string proof param would make this entrypoint uncallable from a wallet.
132func MintAllowlist(cur realm, id string, proof string, maxQty int64, tokenURI string) string {
133	if !unsafe.PreviousRealm().IsUserCall() {
134		panic("must be a direct user call")
135	}
136	return mintAllowlistInternal(cur, id, caller(), unsafe.OriginSend(), splitProof(proof), maxQty, tokenURI)
137}
138
139// splitProof parses the comma-joined hex sibling list into the []string the
140// in-realm verifier expects. An empty string yields no siblings (a single-leaf
141// allowlist, where leaf must equal root).
142func splitProof(proof string) []string {
143	if proof == "" {
144		return nil
145	}
146	return strings.Split(proof, ",")
147}
148
149func mintAllowlistInternal(cur realm, id string, minter address, sent chain.Coins, proof []string, maxQty int64, tokenURI string) string {
150	c := mustGet(id)
151	if c.phase != PhaseAllowlist {
152		panic("not in allowlist phase")
153	}
154	if !verifyAllowlist(c.allowlistRoot, minter, maxQty, proof) {
155		panic("invalid allowlist proof")
156	}
157	if walletCount(c, minter)+1 > maxQty {
158		panic("allowlist quantity exceeded")
159	}
160	return paidMint(cur, c, id, minter, sent, tokenURI, PhaseAllowlist, true)
161}
162
163// paidMint is the shared CEI-ordered paid-mint path (B-1):
164//   Checks → guard, !paused, gating, denom re-check (E-5), native amount.
165//   Effects → mint token, bump counters, credit proceeds (BEFORE any teller).
166//   Interactions → native fee/refund, or grc20 pull + fee (RealmTeller).
167func paidMint(cur realm, c *collection, id string, minter address, sent chain.Coins, tokenURI string, phase int, isAllowlist bool) string {
168	if c.mintGuard {
169		panic("reentrancy")
170	}
171	c.mintGuard = true
172	defer func() { c.mintGuard = false }()
173
174	// ── Checks ──
175	assertNotPaused(c)
176	mintChecks(c, minter)
177	denom := c.payDenom
178	assertDenomAllowed(denom) // E-5: re-check at mint time
179	price := c.mintPrice
180
181	platformCut := overflow.Mul64p(price, primaryFeeBPS) / 10000
182	creatorAmt := price - platformCut
183
184	var paid int64
185	native := isNativeDenom(denom)
186	if native {
187		paid = sumDenom(sent, "ugnot")
188		if paid < price {
189			panic("insufficient payment")
190		}
191	} else if sumDenom(sent, "ugnot") > 0 {
192		// Reject ugnot attached to a grc20-priced mint: it would otherwise
193		// enter the realm untracked and un-withdrawable (LOW stuck-funds trap).
194		panic("no native payment on a grc20 mint")
195	}
196
197	// ── Effects (before any value movement) ──
198	tid := mintToken(cur, c, minter, tokenURI)
199	c.mintedByWallet.Set(minter.String(), walletCount(c, minter)+1)
200	c.lastMintBlock.Set(minter.String(), chainHeight())
201	creditProceeds(c, denom, creatorAmt)
202
203	// ── Interactions ──
204	if native {
205		if platformCut > 0 {
206			sendNative(cur, feeRecipient, platformCut)
207		}
208		if over := paid - price; over > 0 {
209			sendNative(cur, minter, over)
210		}
211	} else {
212		teller := grc20Teller(cur, denom)
213		if err := teller.TransferFrom(0, cur, minter, selfAddr(), price); err != nil {
214			panic(err.Error())
215		}
216		if platformCut > 0 {
217			if err := teller.Transfer(0, cur, feeRecipient, platformCut); err != nil {
218				panic(err.Error())
219			}
220		}
221	}
222
223	isSelf := minter == c.admin || minter == c.royaltyRecip
224	evName := "MintPublic"
225	if isAllowlist {
226		evName = "MintAllowlist"
227	}
228	chain.Emit(evName,
229		"collectionID", id,
230		"tokenId", tid,
231		"minter", minter.String(),
232		"payer", minter.String(),
233		"price", itoa(price),
234		"denom", denomKey(denom),
235		"primaryFee", itoa(platformCut),
236		"creatorAmt", itoa(creatorAmt),
237		"phase", itoa(int64(phase)),
238		"isSelfMint", boolStr(isSelf),
239		"mintedAfter", itoa(walletCount(c, minter)),
240		"block", itoa(chainHeight()),
241	)
242	return tid
243}
244
245// ── Proceeds withdrawal ─────────────────────────────────────────────────────
246
247// WithdrawProceeds sends a collection's accrued proceeds in `denom` to its
248// fixed mintCustody. CEI: zero the ledger entry BEFORE sending.
249func WithdrawProceeds(cur realm, id, denom string) {
250	c := mustGet(id)
251	assertCollectionAdmin(c)
252	if c.mintGuard {
253		panic("reentrancy")
254	}
255	c.mintGuard = true
256	defer func() { c.mintGuard = false }()
257
258	key := denomKey(denom)
259	amt := proceedsOf(c, key)
260	if amt <= 0 {
261		panic("no proceeds")
262	}
263	c.proceeds.Set(key, int64(0)) // CEI: zero first
264
265	if isNativeDenom(denom) {
266		sendNative(cur, c.mintCustody, amt)
267	} else {
268		teller := grc20Teller(cur, denom)
269		if err := teller.Transfer(0, cur, c.mintCustody, amt); err != nil {
270			panic(err.Error())
271		}
272	}
273	chain.Emit("ProceedsWithdrawn",
274		"collectionID", id,
275		"denom", key,
276		"amount", itoa(amt),
277		"to", c.mintCustody.String(),
278	)
279}