collections.gno
10.14 Kb · 310 lines
1package core
2
3import (
4 "chain"
5 "chain/runtime"
6
7 "gno.land/p/nt/grc721/royalty/v0"
8 "gno.land/p/nt/grc721/v0"
9 "gno.land/p/nt/seqid/v0"
10
11 "gno.land/p/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/minhwa/safe/v1"
12 "gno.land/p/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/minhwa/types/v1"
13)
14
15// --- bound minter only -------------------------------------------------------
16//
17// A collection is created by an admin-approved minter realm and stays bound
18// to it: only that realm can launch, abort, reserve and mint (I-5). The
19// minter is trusted to pass the real creator; core trusts nothing else it
20// is given and validates every field (I-13).
21
22// CreateCollection stores a Draft collection and returns its id. Nobody can
23// mint a Draft, so a creator can configure phases across several messages
24// without a race (OA B2).
25func CreateCollection(cur realm, creator address, spec types.CollectionSpec) string {
26 minter := cur.Previous().PkgPath()
27 assertActiveMinter(minter)
28 assertNotPaused("create")
29 if !creator.IsValid() {
30 panic("invalid creator address")
31 }
32 safe.AssertNonEmptyText("name", spec.Name, MaxNameLen)
33 safe.AssertSymbol("symbol", spec.Symbol)
34 safe.AssertText("description", spec.Description, MaxDescriptionLen)
35 safe.AssertText("params", spec.Params, MaxParamsLen)
36 if spec.MaxSupply <= 0 || spec.MaxSupply > MaxSupplyCap {
37 panic("maxSupply must be 1.." + itoa(MaxSupplyCap))
38 }
39 safe.AssertBps("royaltyBps", spec.RoyaltyBps, RoyaltyCapBps)
40 primary := safe.ParsePayees("primaryPayees", spec.PrimaryPayees, MaxPayees)
41 royal := safe.ParsePayees("royaltyPayees", spec.RoyaltyPayees, MaxPayees)
42
43 rec := mustEngine(spec.EngineID)
44 if !rec.claimed {
45 panic("engine is not claimed by its author yet")
46 }
47 if rec.deprecated {
48 panic("engine is deprecated")
49 }
50 // I-12 exception: the creator's own transaction runs the engine they chose.
51 if err := rec.impl.ValidateParams(spec.Params); err != nil {
52 panic("params rejected by engine: " + err.Error())
53 }
54
55 collCount++
56 cid := itoa(collCount)
57
58 // Each collection gets its own sub-realm identity, so Token.ID() and every
59 // grc721 event carry "…/core#c/<cid>" (design §2.3; verified by
60 // spike/subtoken and core tests on gno v1.7.0).
61 tok, led := grc721.NewToken(spec.Name, spec.Symbol, seqid.ID(uint64(collCount)), cur.Sub("c/"+cid))
62 st := &tokenState{}
63 led.RegisterExtension(&lockExt{cid: cid, st: st})
64 roy, royLed := royalty.NewRoyalty(led, RoyaltyCapBps)
65 if spec.RoyaltyBps > 0 {
66 if err := royLed.SetDefaultRoyalty(royaltyAddress(cid), spec.RoyaltyBps); err != nil {
67 panic(err)
68 }
69 }
70
71 c := &collection{
72 id: cid,
73 name: spec.Name,
74 symbol: spec.Symbol,
75 description: spec.Description,
76 creator: creator,
77 engineID: spec.EngineID,
78 params: spec.Params,
79 maxSupply: spec.MaxSupply,
80 nextTID: 1,
81 royaltyBps: spec.RoyaltyBps,
82 primaryPayees: primary,
83 royaltyPayees: royal,
84 minter: minter,
85 state: types.StateDraft,
86 createdHeight: height(),
87 token: tok,
88 ledger: led,
89 roy: roy,
90 st: st,
91 }
92 collections.Set(collKey(cid), c)
93 byCreator.Set(creator.String()+":"+collKey(cid), cid)
94
95 chain.Emit("CollectionCreated",
96 "cid", cid, "creator", creator.String(), "minter", minter,
97 "engine", spec.EngineID, "token", tok.ID())
98 return cid
99}
100
101// Launch freezes the configuration (I-6): sale window, reveal window and the
102// creator's commitment. The sale must start in the future so a beacon can
103// commit before the first mint.
104func Launch(cur realm, cid string, l types.LaunchSpec) {
105 c := mustCollection(cid)
106 assertBoundMinter(cur, c)
107 h := height()
108 if c.state != types.StateDraft {
109 panic("only a draft can be launched")
110 }
111 if l.StartHeight <= h {
112 panic("startHeight must be in the future")
113 }
114 if l.EndHeight < l.StartHeight || l.EndHeight-l.StartHeight > MaxSaleBlocks {
115 panic("sale window must be 0.." + itoa(MaxSaleBlocks) + " blocks")
116 }
117 if l.RevealWindow < RevealWindowMin || l.RevealWindow > RevealWindowMax {
118 panic("revealWindow must be " + itoa(RevealWindowMin) + ".." + itoa(RevealWindowMax) + " blocks")
119 }
120 commit := safe.AssertHex("creatorCommit", l.CreatorCommit, 32)
121 if usedCommits.Has(commit) {
122 panic("this commitment was already used; derive a new secret")
123 }
124 if l.UseBeacon && beacon == "" {
125 panic("no beacon is configured; launch without useBeacon")
126 }
127
128 usedCommits.Set(commit, cid)
129 c.prov = &provenance{creatorCommit: commit, acc: safe.InitialEntropy(runtime.ChainID(), cid)}
130 c.startHeight, c.endHeight, c.revealWindow = l.StartHeight, l.EndHeight, l.RevealWindow
131 c.useBeacon = l.UseBeacon
132 c.state = types.StateScheduled
133
134 chain.Emit("CollectionScheduled",
135 "cid", cid, "start", itoa(c.startHeight), "end", itoa(c.endHeight),
136 "revealWindow", itoa(c.revealWindow), "useBeacon", boolStr(c.useBeacon),
137 "creatorCommit", commit)
138}
139
140// AbortLaunch returns a launched collection to Draft while nothing has been
141// minted — the way out when a beacon never commits. Used commitments stay
142// used.
143func AbortLaunch(cur realm, cid string) {
144 c := mustCollection(cid)
145 assertBoundMinter(cur, c)
146 refresh(c, height())
147 if c.state == types.StateDraft || c.state == types.StateFinal {
148 panic("only a launched, unfinished collection can be aborted")
149 }
150 if c.minted() > 0 {
151 panic("tokens were minted; the launch can no longer be aborted")
152 }
153 c.prov = nil
154 c.startHeight, c.endHeight, c.revealWindow, c.closeHeight = 0, 0, 0, 0
155 c.useBeacon = false
156 c.state = types.StateDraft
157 chain.Emit("CollectionState", "cid", cid, "state", c.state.String())
158}
159
160// ReserveTo mints the creator's reserve before the sale starts. Reserve
161// mints feed the entropy like any mint, appear in Provenance, and do not
162// count toward wallet caps.
163func ReserveTo(cur realm, cid string, to address, n int64) (first, last int64) {
164 c := mustCollection(cid)
165 assertBoundMinter(cur, c)
166 assertNotPaused("mint")
167 h := height()
168 refresh(c, h)
169 if c.state != types.StateScheduled {
170 panic("reserve is only possible after launch and before the sale starts")
171 }
172 assertMintCount(c, n)
173 if c.reserved+n > c.maxSupply*MaxReserveBps/safe.BpsDenominator {
174 panic("reserve would exceed " + itoa(MaxReserveBps/100) + "% of supply")
175 }
176 first, last = mintBatchTo(c, to, n, h)
177 c.reserved += n
178 emitBatch("Reserved", c, to, first, last)
179 return first, last
180}
181
182// MintTo mints n tokens during the sale. The minter has already checked
183// phase, price, allowlist and wallet cap; core re-checks only what it owns:
184// state, supply, beacon commitment, and the lifetime counter (I-5, I-9).
185func MintTo(cur realm, cid string, to address, n int64) (first, last int64) {
186 c := mustCollection(cid)
187 assertBoundMinter(cur, c)
188 assertNotPaused("mint")
189 h := height()
190 refresh(c, h)
191 if c.state != types.StateMinting {
192 panic("collection is not minting (state " + c.state.String() + ")")
193 }
194 if c.useBeacon && c.prov.beaconCommit == "" {
195 panic("waiting for the beacon commitment; minting has not opened")
196 }
197 assertMintCount(c, n)
198 first, last = mintBatchTo(c, to, n, h)
199 c.mintedBy.Set(to.String(), mintedBy(c, to)+n)
200 if c.minted() == c.maxSupply {
201 c.closeHeight = h
202 c.state = types.StateClosed
203 chain.Emit("CollectionState", "cid", cid, "state", c.state.String())
204 }
205 emitBatch("MintBatch", c, to, first, last)
206 return first, last
207}
208
209func assertMintCount(c *collection, n int64) {
210 if n <= 0 || n > MaxMintPerTx {
211 panic("count must be 1.." + itoa(MaxMintPerTx))
212 }
213 if c.minted()+n > c.maxSupply {
214 panic("only " + itoa(c.maxSupply-c.minted()) + " tokens left")
215 }
216}
217
218// mintBatchTo is the only place tokens come into existence.
219func mintBatchTo(c *collection, to address, n, h int64) (first, last int64) {
220 first = c.nextTID
221 last = first + n - 1
222 for tid := first; tid <= last; tid++ {
223 if err := c.ledger.Mint(to, grc721.TokenID(itoa(tid))); err != nil {
224 panic(err)
225 }
226 }
227 c.nextTID = last + 1
228 c.st.addBatch(first, last, h)
229 c.prov.acc = safe.MixEntropy(c.prov.acc, to, h, first, last)
230 return first, last
231}
232
233func mintedBy(c *collection, addr address) int64 {
234 if v := c.mintedBy.Get(addr.String()); v != nil {
235 return v.(int64)
236 }
237 return 0
238}
239
240// CloseMint materializes Closed once the sale window has passed. Anyone may
241// call it; reveals and Finalize work without it.
242func CloseMint(cur realm, cid string) {
243 assertNoDeposit(cur)
244 c := mustCollection(cid)
245 refresh(c, height())
246 if c.state != types.StateClosed {
247 panic("the sale is not over (state " + c.state.String() + ")")
248 }
249}
250
251// --- creator -----------------------------------------------------------------
252
253// TransferCreator starts a two-step handover (RA M-11).
254func TransferCreator(cur realm, cid string, to address) {
255 assertNoDeposit(cur)
256 c := mustCollection(cid)
257 assertCreator(cur, c)
258 if !to.IsValid() {
259 panic("invalid address")
260 }
261 c.pendingCreator = to
262 chain.Emit("CreatorTransferStarted", "cid", cid, "from", c.creator.String(), "to", to.String())
263}
264
265func AcceptCreator(cur realm, cid string) {
266 assertNoDeposit(cur)
267 c := mustCollection(cid)
268 who := caller(cur)
269 if c.pendingCreator == "" || who != c.pendingCreator {
270 panic("no pending creator transfer to this address")
271 }
272 byCreator.Remove(c.creator.String() + ":" + collKey(cid))
273 byCreator.Set(who.String()+":"+collKey(cid), cid)
274 chain.Emit("CreatorTransferred", "cid", cid, "from", c.creator.String(), "to", who.String())
275 c.creator = who
276 c.pendingCreator = ""
277}
278
279// SetPayees changes the royalty split after Final. The primary-sale split is
280// frozen with the sale (I-6); the royalty receiver stays the collection's
281// sub-treasury, so third-party markets are unaffected.
282func SetPayees(cur realm, cid, payees string) {
283 assertNoDeposit(cur)
284 c := mustCollection(cid)
285 assertCreator(cur, c)
286 if c.state != types.StateFinal {
287 panic("royalty payees can change only after the collection is final")
288 }
289 c.royaltyPayees = safe.ParsePayees("royaltyPayees", payees, MaxPayees)
290 chain.Emit("RoyaltyPayees", "cid", cid, "payees", payees)
291}
292
293func assertCreator(cur realm, c *collection) {
294 if caller(cur) != c.creator {
295 panic("only the collection's creator can do this")
296 }
297}
298
299func assertBoundMinter(cur realm, c *collection) {
300 if cur.Previous().PkgPath() != c.minter {
301 panic("only the collection's bound minter (" + c.minter + ") can do this")
302 }
303}
304
305func boolStr(b bool) string {
306 if b {
307 return "true"
308 }
309 return "false"
310}