collections.gno
13.43 Kb · 387 lines
1package core
2
3import (
4 "chain"
5 "chain/runtime"
6 "strings"
7
8 "gno.land/p/nt/grc721/royalty/v0"
9 "gno.land/p/nt/grc721/v0"
10 "gno.land/p/nt/seqid/v0"
11
12 "gno.land/p/g1pq6pxhgxk2uw5shg5le6glfydpxcu74k0zh069/sinopia/safe/v1"
13 "gno.land/p/g1pq6pxhgxk2uw5shg5le6glfydpxcu74k0zh069/sinopia/types/v1"
14)
15
16// --- bound minter only -------------------------------------------------------
17//
18// A collection is created by an admin-approved minter realm and stays bound
19// to it: only that realm can launch, abort, reserve and mint (I-5). The
20// minter is trusted to pass the real creator; core trusts nothing else it
21// is given and validates every field (I-13).
22
23// CreateCollection stores a Draft collection and returns its id. Nobody can
24// mint a Draft, so a creator can configure phases across several messages
25// without a race (OA B2).
26func CreateCollection(cur realm, creator address, spec types.CollectionSpec) string {
27 minter := cur.Previous().PkgPath()
28 assertActiveMinter(minter)
29 assertNotPaused("create")
30 if !creator.IsValid() {
31 panic("invalid creator address")
32 }
33 safe.AssertNonEmptyText("name", spec.Name, MaxNameLen)
34 safe.AssertSymbol("symbol", spec.Symbol)
35 safe.AssertText("description", spec.Description, MaxDescriptionLen)
36 safe.AssertText("params", spec.Params, MaxParamsLen)
37 if spec.MaxSupply <= 0 || spec.MaxSupply > MaxSupplyCap {
38 panic("maxSupply must be 1.." + itoa(MaxSupplyCap))
39 }
40 safe.AssertBps("royaltyBps", spec.RoyaltyBps, RoyaltyCapBps)
41 primary := safe.ParsePayees("primaryPayees", spec.PrimaryPayees, MaxPayees)
42 royal := safe.ParsePayees("royaltyPayees", spec.RoyaltyPayees, MaxPayees)
43 next := itoa(collCount + 1)
44 assertPayees("primaryPayees", primary, next, spec.EngineID)
45 assertPayees("royaltyPayees", royal, next, spec.EngineID)
46
47 rec := mustEngine(spec.EngineID)
48 if !rec.claimed {
49 panic("engine is not claimed by its author yet")
50 }
51 if rec.deprecated {
52 panic("engine is deprecated")
53 }
54 // I-12 exception: the creator's own transaction runs the engine they chose.
55 if err := rec.impl.ValidateParams(spec.Params); err != nil {
56 panic("params rejected by engine: " + err.Error())
57 }
58
59 collCount++
60 cid := itoa(collCount)
61
62 // Each collection gets its own sub-realm identity, so Token.ID() and every
63 // grc721 event carry "…/core#c/<cid>" (design §2.3; verified by
64 // spike/subtoken and core tests on gno v1.7.0).
65 tok, led := grc721.NewToken(spec.Name, spec.Symbol, seqid.ID(uint64(collCount)), cur.Sub("c/"+cid))
66 st := &tokenState{}
67 led.RegisterExtension(&lockExt{cid: cid, st: st})
68 roy, royLed := royalty.NewRoyalty(led, RoyaltyCapBps)
69 if spec.RoyaltyBps > 0 {
70 if err := royLed.SetDefaultRoyalty(royaltyAddress(cid), spec.RoyaltyBps); err != nil {
71 panic(err)
72 }
73 }
74
75 c := &collection{
76 id: cid,
77 name: spec.Name,
78 symbol: spec.Symbol,
79 description: spec.Description,
80 creator: creator,
81 engineID: spec.EngineID,
82 params: spec.Params,
83 maxSupply: spec.MaxSupply,
84 nextTID: 1,
85 royaltyBps: spec.RoyaltyBps,
86 primaryPayees: primary,
87 royaltyPayees: royal,
88 minter: minter,
89 state: types.StateDraft,
90 createdHeight: height(),
91 token: tok,
92 ledger: led,
93 roy: roy,
94 st: st,
95 }
96 collections.Set(collKey(cid), c)
97 byCreator.Set(creator.String()+":"+collKey(cid), cid)
98
99 chain.Emit("CollectionCreated",
100 "cid", cid, "creator", creator.String(), "minter", minter,
101 "engine", spec.EngineID, "token", tok.ID())
102 return cid
103}
104
105// Launch freezes the configuration (I-6): sale window, reveal window and the
106// creator's commitment. The sale must start in the future so a beacon can
107// commit before the first mint.
108func Launch(cur realm, cid string, l types.LaunchSpec) {
109 c := mustCollection(cid)
110 assertBoundMinter(cur, c)
111 h := height()
112 if c.state != types.StateDraft {
113 panic("only a draft can be launched")
114 }
115 if l.StartHeight <= h {
116 panic("startHeight must be in the future")
117 }
118 if l.EndHeight < l.StartHeight || l.EndHeight-l.StartHeight > MaxSaleBlocks {
119 panic("sale window must be 0.." + itoa(MaxSaleBlocks) + " blocks")
120 }
121 if l.RevealWindow < RevealWindowMin || l.RevealWindow > RevealWindowMax {
122 panic("revealWindow must be " + itoa(RevealWindowMin) + ".." + itoa(RevealWindowMax) + " blocks")
123 }
124 commit := safe.AssertHex("creatorCommit", l.CreatorCommit, 32)
125 // Commitments are domain-separated by chain, collection and role
126 // (safe.Commit), so only this collection's own history matters: a secret
127 // that went public here is never accepted again (SA-L1, RA M-5).
128 if isBurned(c, commit) {
129 panic("this commitment's secret was already revealed; derive a new secret")
130 }
131 if l.UseBeacon && beacon == "" {
132 panic("no beacon is configured; launch without useBeacon")
133 }
134 // SA-M2: without the beacon the creator alone knows the only secret and
135 // can grind the last mint for the map it wants. While the platform has
136 // a beacon, nobody may opt out of it.
137 if !l.UseBeacon && beacon != "" {
138 panic("the platform beacon is configured; launch with useBeacon")
139 }
140
141 c.prov = &provenance{creatorCommit: commit, acc: safe.InitialEntropy(runtime.ChainID(), cid)}
142 c.startHeight, c.endHeight, c.revealWindow = l.StartHeight, l.EndHeight, l.RevealWindow
143 c.useBeacon = l.UseBeacon
144 c.primaryBps = fees.PrimaryBps
145 c.state = types.StateScheduled
146
147 chain.Emit("CollectionScheduled",
148 "cid", cid, "start", itoa(c.startHeight), "end", itoa(c.endHeight),
149 "revealWindow", itoa(c.revealWindow), "useBeacon", boolStr(c.useBeacon),
150 "creatorCommit", commit)
151}
152
153// AbortLaunch returns a launched collection to Draft while nothing has been
154// minted — the way out when a beacon never commits. A commitment whose
155// secret was revealed is burned for this collection; unrevealed ones (the
156// beacon's deterministic one included) may be committed again (SA-L1).
157func AbortLaunch(cur realm, cid string) {
158 c := mustCollection(cid)
159 assertBoundMinter(cur, c)
160 refresh(c, height())
161 if c.state == types.StateDraft || c.state == types.StateFinal {
162 panic("only a launched, unfinished collection can be aborted")
163 }
164 if c.minted() > 0 {
165 panic("tokens were minted; the launch can no longer be aborted")
166 }
167 if c.prov.creatorRevealed {
168 c.burnedCommits = append(c.burnedCommits, c.prov.creatorCommit)
169 }
170 if c.prov.beaconRevealed {
171 c.burnedCommits = append(c.burnedCommits, c.prov.beaconCommit)
172 }
173 c.prov = nil
174 c.startHeight, c.endHeight, c.revealWindow, c.closeHeight = 0, 0, 0, 0
175 c.useBeacon = false
176 c.state = types.StateDraft
177 chain.Emit("CollectionState", "cid", cid, "state", c.state.String())
178}
179
180// ReserveTo mints the creator's reserve before the sale starts. Reserve
181// mints feed the entropy like any mint, appear in Provenance, and do not
182// count toward wallet caps.
183func ReserveTo(cur realm, cid string, to address, n int64) (first, last int64) {
184 c := mustCollection(cid)
185 assertBoundMinter(cur, c)
186 assertNotPaused("mint")
187 h := height()
188 refresh(c, h)
189 if c.state != types.StateScheduled {
190 panic("reserve is only possible after launch and before the sale starts")
191 }
192 // SA-M2: a reserve is the creator's own allocation; it needs a map the
193 // creator cannot steer, i.e. the beacon.
194 if !c.useBeacon {
195 panic("a creator reserve needs a beacon launch")
196 }
197 // A reserve is a mint, and the beacon may only commit while nothing is
198 // minted: reserving first would leave the collection unmintable and
199 // unabortable (found on Onyx, collection 1).
200 if c.useBeacon && c.prov.beaconCommit == "" {
201 panic("waiting for the beacon commitment; reserve after the beacon commits")
202 }
203 assertMintCount(c, n)
204 if c.reserved+n > c.maxSupply*MaxReserveBps/safe.BpsDenominator {
205 panic("reserve would exceed " + itoa(MaxReserveBps/100) + "% of supply")
206 }
207 first, last = mintBatchTo(c, to, n, h)
208 c.reserved += n
209 emitBatch("Reserved", c, to, first, last)
210 return first, last
211}
212
213// MintTo mints n tokens during the sale. The minter has already checked
214// phase, price, allowlist and wallet cap; core re-checks only what it owns:
215// state, supply, beacon commitment, and the lifetime counter (I-5, I-9).
216func MintTo(cur realm, cid string, to address, n int64) (first, last int64) {
217 c := mustCollection(cid)
218 assertBoundMinter(cur, c)
219 assertNotPaused("mint")
220 h := height()
221 refresh(c, h)
222 if c.state != types.StateMinting {
223 panic("collection is not minting (state " + c.state.String() + ")")
224 }
225 if c.useBeacon && c.prov.beaconCommit == "" {
226 panic("waiting for the beacon commitment; minting has not opened")
227 }
228 assertMintCount(c, n)
229 first, last = mintBatchTo(c, to, n, h)
230 c.mintedBy.Set(to.String(), mintedBy(c, to)+n)
231 if c.minted() == c.maxSupply {
232 c.closeHeight = h
233 c.state = types.StateClosed
234 chain.Emit("CollectionState", "cid", cid, "state", c.state.String())
235 }
236 emitBatch("MintBatch", c, to, first, last)
237 return first, last
238}
239
240func assertMintCount(c *collection, n int64) {
241 if n <= 0 || n > MaxMintPerTx {
242 panic("count must be 1.." + itoa(MaxMintPerTx))
243 }
244 if c.minted()+n > c.maxSupply {
245 panic("only " + itoa(c.maxSupply-c.minted()) + " tokens left")
246 }
247}
248
249// mintBatchTo is the only place tokens come into existence.
250func mintBatchTo(c *collection, to address, n, h int64) (first, last int64) {
251 first = c.nextTID
252 last = first + n - 1
253 for tid := first; tid <= last; tid++ {
254 if err := c.ledger.Mint(to, grc721.TokenID(itoa(tid))); err != nil {
255 panic(err)
256 }
257 }
258 c.nextTID = last + 1
259 c.st.addBatch(first, last, h)
260 c.prov.acc = safe.MixEntropy(c.prov.acc, to, h, first, last)
261 return first, last
262}
263
264func mintedBy(c *collection, addr address) int64 {
265 if v := c.mintedBy.Get(addr.String()); v != nil {
266 return v.(int64)
267 }
268 return 0
269}
270
271// CloseMint materializes Closed once the sale window has passed. Anyone may
272// call it; reveals and Finalize work without it.
273func CloseMint(cur realm, cid string) {
274 assertNoDeposit(cur)
275 c := mustCollection(cid)
276 refresh(c, height())
277 if c.state != types.StateClosed {
278 panic("the sale is not over (state " + c.state.String() + ")")
279 }
280}
281
282// --- creator -----------------------------------------------------------------
283
284// TransferCreator starts a two-step handover (RA M-11).
285func TransferCreator(cur realm, cid string, to address) {
286 assertNoDeposit(cur)
287 c := mustCollection(cid)
288 assertCreator(cur, c)
289 if !to.IsValid() {
290 panic("invalid address")
291 }
292 c.pendingCreator = to
293 chain.Emit("CreatorTransferStarted", "cid", cid, "from", c.creator.String(), "to", to.String())
294}
295
296func AcceptCreator(cur realm, cid string) {
297 assertNoDeposit(cur)
298 c := mustCollection(cid)
299 who := caller(cur)
300 if c.pendingCreator == "" || who != c.pendingCreator {
301 panic("no pending creator transfer to this address")
302 }
303 byCreator.Remove(c.creator.String() + ":" + collKey(cid))
304 byCreator.Set(who.String()+":"+collKey(cid), cid)
305 chain.Emit("CreatorTransferred", "cid", cid, "from", c.creator.String(), "to", who.String())
306 c.creator = who
307 c.pendingCreator = ""
308}
309
310// SetPayees changes the royalty split after Final. The primary-sale split is
311// frozen with the sale (I-6); the royalty receiver stays the collection's
312// sub-treasury, so third-party markets are unaffected. Royalties earned so
313// far are first paid to the current payees (SA-I4).
314func SetPayees(cur realm, cid, payees string) {
315 assertNoDeposit(cur)
316 c := mustCollection(cid)
317 assertCreator(cur, c)
318 if c.state != types.StateFinal {
319 panic("royalty payees can change only after the collection is final")
320 }
321 ps := safe.ParsePayees("royaltyPayees", payees, MaxPayees)
322 assertPayees("royaltyPayees", ps, cid, c.engineID)
323 distribute(cur, c)
324 c.royaltyPayees = ps
325 chain.Emit("RoyaltyPayees", "cid", cid, "payees", payees)
326}
327
328// assertPayees refuses payees that can never spend what they receive
329// (SA-L5): the platform's own realms and sub-addresses. cid is the
330// collection being configured, engineID its engine adapter.
331func assertPayees(field string, ps []types.Payee, cid, engineID string) {
332 for _, p := range ps {
333 assertSpendable(field, p.Addr, cid, engineID)
334 }
335}
336
337// assertSpendable panics for core, core's sub-addresses of collection cid
338// (token identity c/<cid>, royalty treasury roy/<cid>), every minter ever
339// approved, the engine adapter, and the platform realms next to core
340// (market, reader, gallery, the genesis adapter). None of them has a path
341// that spends an incoming payment, so the coins would be stranded (I-3).
342func assertSpendable(field string, addr address, cid, engineID string) {
343 bad := []address{chain.PackageAddress(selfPath)}
344 if cid != "" {
345 bad = append(bad, chain.DerivePkgSubAddr(selfPath, "c/"+cid), royaltyAddress(cid))
346 }
347 if engineID != "" {
348 bad = append(bad, chain.PackageAddress(engineID))
349 }
350 base := strings.TrimSuffix(selfPath, "/core")
351 for _, sib := range platformRealms {
352 bad = append(bad, chain.PackageAddress(base+"/"+sib))
353 }
354 for _, b := range bad {
355 if addr == b {
356 panic(field + ": " + addr.String() + " is a platform realm address that cannot spend; use a wallet")
357 }
358 }
359 minters.Iterate("", "", func(path string, _ any) bool {
360 if addr == chain.PackageAddress(path) {
361 panic(field + ": " + addr.String() + " is the minter realm " + path + "; use a wallet")
362 }
363 return false
364 })
365}
366
367// platformRealms are the realm paths next to core (README package map).
368var platformRealms = []string{"mint/v1", "market/v1", "engines/genesis", "reader", "gallery"}
369
370func assertCreator(cur realm, c *collection) {
371 if caller(cur) != c.creator {
372 panic("only the collection's creator can do this")
373 }
374}
375
376func assertBoundMinter(cur realm, c *collection) {
377 if cur.Previous().PkgPath() != c.minter {
378 panic("only the collection's bound minter (" + c.minter + ") can do this")
379 }
380}
381
382func boolStr(b bool) string {
383 if b {
384 return "true"
385 }
386 return "false"
387}