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}