collections.gno
10.45 Kb · 316 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/sinopia/safe/v1"
12 "gno.land/p/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/sinopia/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 // A reserve is a mint, and the beacon may only commit while nothing is
173 // minted: reserving first would leave the collection unmintable and
174 // unabortable (found on Onyx, collection 1).
175 if c.useBeacon && c.prov.beaconCommit == "" {
176 panic("waiting for the beacon commitment; reserve after the beacon commits")
177 }
178 assertMintCount(c, n)
179 if c.reserved+n > c.maxSupply*MaxReserveBps/safe.BpsDenominator {
180 panic("reserve would exceed " + itoa(MaxReserveBps/100) + "% of supply")
181 }
182 first, last = mintBatchTo(c, to, n, h)
183 c.reserved += n
184 emitBatch("Reserved", c, to, first, last)
185 return first, last
186}
187
188// MintTo mints n tokens during the sale. The minter has already checked
189// phase, price, allowlist and wallet cap; core re-checks only what it owns:
190// state, supply, beacon commitment, and the lifetime counter (I-5, I-9).
191func MintTo(cur realm, cid string, to address, n int64) (first, last int64) {
192 c := mustCollection(cid)
193 assertBoundMinter(cur, c)
194 assertNotPaused("mint")
195 h := height()
196 refresh(c, h)
197 if c.state != types.StateMinting {
198 panic("collection is not minting (state " + c.state.String() + ")")
199 }
200 if c.useBeacon && c.prov.beaconCommit == "" {
201 panic("waiting for the beacon commitment; minting has not opened")
202 }
203 assertMintCount(c, n)
204 first, last = mintBatchTo(c, to, n, h)
205 c.mintedBy.Set(to.String(), mintedBy(c, to)+n)
206 if c.minted() == c.maxSupply {
207 c.closeHeight = h
208 c.state = types.StateClosed
209 chain.Emit("CollectionState", "cid", cid, "state", c.state.String())
210 }
211 emitBatch("MintBatch", c, to, first, last)
212 return first, last
213}
214
215func assertMintCount(c *collection, n int64) {
216 if n <= 0 || n > MaxMintPerTx {
217 panic("count must be 1.." + itoa(MaxMintPerTx))
218 }
219 if c.minted()+n > c.maxSupply {
220 panic("only " + itoa(c.maxSupply-c.minted()) + " tokens left")
221 }
222}
223
224// mintBatchTo is the only place tokens come into existence.
225func mintBatchTo(c *collection, to address, n, h int64) (first, last int64) {
226 first = c.nextTID
227 last = first + n - 1
228 for tid := first; tid <= last; tid++ {
229 if err := c.ledger.Mint(to, grc721.TokenID(itoa(tid))); err != nil {
230 panic(err)
231 }
232 }
233 c.nextTID = last + 1
234 c.st.addBatch(first, last, h)
235 c.prov.acc = safe.MixEntropy(c.prov.acc, to, h, first, last)
236 return first, last
237}
238
239func mintedBy(c *collection, addr address) int64 {
240 if v := c.mintedBy.Get(addr.String()); v != nil {
241 return v.(int64)
242 }
243 return 0
244}
245
246// CloseMint materializes Closed once the sale window has passed. Anyone may
247// call it; reveals and Finalize work without it.
248func CloseMint(cur realm, cid string) {
249 assertNoDeposit(cur)
250 c := mustCollection(cid)
251 refresh(c, height())
252 if c.state != types.StateClosed {
253 panic("the sale is not over (state " + c.state.String() + ")")
254 }
255}
256
257// --- creator -----------------------------------------------------------------
258
259// TransferCreator starts a two-step handover (RA M-11).
260func TransferCreator(cur realm, cid string, to address) {
261 assertNoDeposit(cur)
262 c := mustCollection(cid)
263 assertCreator(cur, c)
264 if !to.IsValid() {
265 panic("invalid address")
266 }
267 c.pendingCreator = to
268 chain.Emit("CreatorTransferStarted", "cid", cid, "from", c.creator.String(), "to", to.String())
269}
270
271func AcceptCreator(cur realm, cid string) {
272 assertNoDeposit(cur)
273 c := mustCollection(cid)
274 who := caller(cur)
275 if c.pendingCreator == "" || who != c.pendingCreator {
276 panic("no pending creator transfer to this address")
277 }
278 byCreator.Remove(c.creator.String() + ":" + collKey(cid))
279 byCreator.Set(who.String()+":"+collKey(cid), cid)
280 chain.Emit("CreatorTransferred", "cid", cid, "from", c.creator.String(), "to", who.String())
281 c.creator = who
282 c.pendingCreator = ""
283}
284
285// SetPayees changes the royalty split after Final. The primary-sale split is
286// frozen with the sale (I-6); the royalty receiver stays the collection's
287// sub-treasury, so third-party markets are unaffected.
288func SetPayees(cur realm, cid, payees string) {
289 assertNoDeposit(cur)
290 c := mustCollection(cid)
291 assertCreator(cur, c)
292 if c.state != types.StateFinal {
293 panic("royalty payees can change only after the collection is final")
294 }
295 c.royaltyPayees = safe.ParsePayees("royaltyPayees", payees, MaxPayees)
296 chain.Emit("RoyaltyPayees", "cid", cid, "payees", payees)
297}
298
299func assertCreator(cur realm, c *collection) {
300 if caller(cur) != c.creator {
301 panic("only the collection's creator can do this")
302 }
303}
304
305func assertBoundMinter(cur realm, c *collection) {
306 if cur.Previous().PkgPath() != c.minter {
307 panic("only the collection's bound minter (" + c.minter + ") can do this")
308 }
309}
310
311func boolStr(b bool) string {
312 if b {
313 return "true"
314 }
315 return "false"
316}