Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}