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

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/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	// 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}