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.

mint.gno

9.81 Kb · 317 lines
  1// Package mint is the platform's first minter (design §2.5.3): sale
  2// phases, fixed price or Dutch auction, live and merkle allowlists, lifetime
  3// wallet caps, and payment settlement. It is replaceable: core binds each
  4// collection to the minter that created it, so a future mint/v2 serves new
  5// collections without touching sales in progress.
  6//
  7// Creator flow (one multi-message transaction from the frontend; safety does
  8// not depend on atomicity because nobody can mint a Draft):
  9//
 10//	Create -> AddPhase x1..3 -> Launch
 11//
 12// Collector flow: Mint, with Price / Phases / Eligible as the read side.
 13package mint
 14
 15import (
 16	"chain"
 17	"chain/runtime"
 18	"strconv"
 19
 20	"gno.land/p/nt/avl/v0"
 21	"gno.land/p/nt/ufmt/v0"
 22
 23	"gno.land/p/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/sinopia/safe/v1"
 24	"gno.land/p/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/sinopia/types/v1"
 25	"gno.land/r/g19dzk54hhsdc3gllpksuj6gu4a3e6fl8cr6rn4w/sinopia/core"
 26)
 27
 28const MaxPhases = 3
 29
 30// sale is this minter's per-collection state. Phases can change only while
 31// core says Draft; after Launch they are frozen with the rest (I-6).
 32type sale struct {
 33	phases []types.Phase
 34}
 35
 36var sales avl.Tree // cid -> *sale
 37
 38// Create opens a Draft collection for the calling wallet and pays the
 39// protocol's create fee from the attached ugnot (excess refunded).
 40// Empty payee strings default to "creator:10000". expectedCID, when set,
 41// makes the transaction fail instead of creating a different id than the
 42// frontend showed (OA B2).
 43func Create(cur realm, name, symbol, description, engineID, params string,
 44	maxSupply, royaltyBps int64, primaryPayees, royaltyPayees, expectedCID string,
 45) string {
 46	paid := received(cur)
 47	creator := cur.Previous().Address()
 48	fee := core.FeeConfig()
 49	if paid < fee.CreateFee {
 50		panic("create fee is " + itoa(fee.CreateFee) + "ugnot, sent " + itoa(paid))
 51	}
 52	self := creator.String() + ":10000"
 53	if primaryPayees == "" {
 54		primaryPayees = self
 55	}
 56	if royaltyPayees == "" {
 57		royaltyPayees = self
 58	}
 59
 60	cid := core.CreateCollection(cross(cur), creator, types.CollectionSpec{
 61		Name: name, Symbol: symbol, Description: description,
 62		EngineID: engineID, Params: params,
 63		MaxSupply: maxSupply, RoyaltyBps: royaltyBps,
 64		PrimaryPayees: primaryPayees, RoyaltyPayees: royaltyPayees,
 65	})
 66	if expectedCID != "" && cid != expectedCID {
 67		panic("collection id would be " + cid + ", expected " + expectedCID + "; retry")
 68	}
 69	sales.Set(cid, &sale{})
 70
 71	pay(cur, fee.Treasury, fee.CreateFee)
 72	pay(cur, creator, paid-fee.CreateFee)
 73	return cid
 74}
 75
 76// AddPhase appends a sale phase. Phases are ordered and do not overlap.
 77// kind is "allow" (set required) or "public" (set empty); a fixed price has
 78// startPrice == floorPrice and zero steps; walletCap 0 means no cap.
 79func AddPhase(cur realm, cid, kind, set string,
 80	startHeight, endHeight, startPrice, floorPrice, stepBlocks, stepAmount, walletCap int64,
 81) {
 82	assertNoDeposit(cur)
 83	s := mustDraftSale(cur, cid)
 84	if len(s.phases) >= MaxPhases {
 85		panic("at most " + itoa(MaxPhases) + " phases")
 86	}
 87	p := types.Phase{
 88		Kind: kind, Set: set,
 89		StartHeight: startHeight, EndHeight: endHeight,
 90		StartPrice: startPrice, FloorPrice: floorPrice,
 91		StepBlocks: stepBlocks, StepAmount: stepAmount,
 92		WalletCap: walletCap,
 93	}
 94	if err := p.Validate(); err != nil {
 95		panic(err)
 96	}
 97	if set != "" {
 98		parseSet(set)
 99	}
100	if n := len(s.phases); n > 0 && p.StartHeight <= s.phases[n-1].EndHeight {
101		panic("phases must not overlap: start after height " + itoa(s.phases[n-1].EndHeight))
102	}
103	s.phases = append(s.phases, p)
104	chain.Emit("PhaseAdded", "cid", cid, "index", itoa(int64(len(s.phases)-1)), "kind", kind, "set", set)
105}
106
107// ClearPhases removes every phase of a Draft.
108func ClearPhases(cur realm, cid string) {
109	assertNoDeposit(cur)
110	s := mustDraftSale(cur, cid)
111	s.phases = nil
112	chain.Emit("PhasesCleared", "cid", cid)
113}
114
115// Launch freezes the sale. creatorCommit is safe.Commit(chainID, cid,
116// "creator", secret), computed off-chain — never send the secret to a node
117// before reveal.
118func Launch(cur realm, cid, creatorCommit string, revealWindow int64, useBeacon bool) {
119	assertNoDeposit(cur)
120	s := mustDraftSale(cur, cid)
121	if len(s.phases) == 0 {
122		panic("add at least one phase before launching")
123	}
124	core.Launch(cross(cur), cid, types.LaunchSpec{
125		StartHeight:   s.phases[0].StartHeight,
126		EndHeight:     s.phases[len(s.phases)-1].EndHeight,
127		RevealWindow:  revealWindow,
128		CreatorCommit: creatorCommit,
129		UseBeacon:     useBeacon,
130	})
131}
132
133// Abort returns a launched collection with no mints to Draft (core rules).
134func Abort(cur realm, cid string) {
135	assertNoDeposit(cur)
136	assertCreator(cur, cid)
137	core.AbortLaunch(cross(cur), cid)
138}
139
140// Reserve mints the creator's reserve to the creator before the sale.
141func Reserve(cur realm, cid string, count int64) string {
142	assertNoDeposit(cur)
143	creator := assertCreator(cur, cid)
144	first, last := core.ReserveTo(cross(cur), cid, creator, count)
145	return itoa(first) + "-" + itoa(last)
146}
147
148// Mint buys count tokens in the phase open at this block, at that block's
149// unit price, never above maxUnitPrice (a Dutch step cannot surprise the
150// buyer). proof is the merkle proof for merkle sets, otherwise "". Returns
151// "first-last". Every attached unit is paid out or refunded (I-3, I-4).
152func Mint(cur realm, cid string, count, maxUnitPrice int64, proof string) string {
153	paid := received(cur)
154	buyer := cur.Previous().Address()
155	h := runtime.ChainHeight()
156	p, ok := mustSale(cid).phaseAt(h)
157	if !ok {
158		panic("no sale phase is open at height " + itoa(h))
159	}
160	if count <= 0 || count > core.MaxMintPerTx {
161		panic("count must be 1.." + itoa(core.MaxMintPerTx))
162	}
163	if ok, reason := eligible(cid, p.Set, buyer, proof); !ok {
164		panic(reason)
165	}
166	if p.WalletCap > 0 && core.MintedBy(cid, buyer)+count > p.WalletCap {
167		panic("wallet cap: " + itoa(p.WalletCap) + " per wallet, already minted " + itoa(core.MintedBy(cid, buyer)))
168	}
169	price := p.PriceAt(h)
170	if price > maxUnitPrice {
171		panic("unit price at this block is " + itoa(price) + "ugnot, above maxUnitPrice " + itoa(maxUnitPrice))
172	}
173	cost := price * count
174	if paid < cost {
175		panic("send " + itoa(cost) + "ugnot, sent " + itoa(paid))
176	}
177
178	first, last := core.MintTo(cross(cur), cid, buyer, count)
179
180	fee := core.FeeConfig()
181	protocol := safe.BpsOf(cost, fee.PrimaryBps)
182	payees := core.PrimaryPayees(cid)
183	shares := safe.SplitByBps(cost-protocol, payees)
184	pay(cur, fee.Treasury, protocol)
185	for i, payee := range payees {
186		pay(cur, payee.Addr, shares[i])
187	}
188	pay(cur, buyer, paid-cost)
189
190	chain.Emit("Sale", "cid", cid, "buyer", buyer.String(), "first", itoa(first), "last", itoa(last),
191		"unitPrice", itoa(price), "protocolFee", itoa(protocol))
192	return itoa(first) + "-" + itoa(last)
193}
194
195func (s *sale) phaseAt(h int64) (types.Phase, bool) {
196	for _, p := range s.phases {
197		if p.Covers(h) {
198			return p, true
199		}
200	}
201	return types.Phase{}, false
202}
203
204func mustSale(cid string) *sale {
205	v := sales.Get(cid)
206	if v == nil {
207		panic("collection " + cid + " was not created by this minter")
208	}
209	return v.(*sale)
210}
211
212func mustDraftSale(cur realm, cid string) *sale {
213	assertCreator(cur, cid)
214	if core.Info(cid).State != types.StateDraft {
215		panic("phases are frozen after launch")
216	}
217	return mustSale(cid)
218}
219
220func assertCreator(cur realm, cid string) address {
221	who := cur.Previous().Address()
222	if core.Info(cid).Creator != who {
223		panic("only the collection's creator can do this")
224	}
225	return who
226}
227
228// --- reads -------------------------------------------------------------------
229
230// Price is the unit price at height h, or -1 when no phase covers h.
231func Price(cid string, h int64) int64 {
232	p, ok := mustSale(cid).phaseAt(h)
233	if !ok {
234		return -1
235	}
236	return p.PriceAt(h)
237}
238
239// Phases is the phase list as JSON.
240func Phases(cid string) string {
241	w := &safe.JSONWriter{}
242	w.BeginArray()
243	for _, p := range mustSale(cid).phases {
244		w.BeginObject()
245		w.StrField("kind", p.Kind)
246		w.StrField("set", p.Set)
247		w.IntField("startHeight", p.StartHeight)
248		w.IntField("endHeight", p.EndHeight)
249		w.IntField("startPrice", p.StartPrice)
250		w.IntField("floorPrice", p.FloorPrice)
251		w.IntField("stepBlocks", p.StepBlocks)
252		w.IntField("stepAmount", p.StepAmount)
253		w.IntField("walletCap", p.WalletCap)
254		w.EndObject()
255	}
256	w.EndArray()
257	return w.String()
258}
259
260// Eligible answers "can addr mint now, and how many?" with the same checks
261// Mint runs, so the UI never disagrees with the chain (OA B3). JSON:
262// {phase, kind, eligible, reason, remainingCap (-1 = no cap), price}.
263func Eligible(cid string, addr address, proof string) string {
264	h := runtime.ChainHeight()
265	w := &safe.JSONWriter{}
266	w.BeginObject()
267	idx, p := -1, types.Phase{}
268	for i, q := range mustSale(cid).phases {
269		if q.Covers(h) {
270			idx, p = i, q
271			break
272		}
273	}
274	w.IntField("phase", int64(idx))
275	if idx < 0 {
276		w.BoolField("eligible", false)
277		w.StrField("reason", "no sale phase is open at height "+itoa(h))
278		w.EndObject()
279		return w.String()
280	}
281	ok, reason := eligible(cid, p.Set, addr, proof)
282	remaining := int64(-1)
283	if p.WalletCap > 0 {
284		remaining = p.WalletCap - core.MintedBy(cid, addr)
285		if remaining < 0 {
286			remaining = 0
287		}
288		if remaining == 0 && ok {
289			ok, reason = false, "wallet cap reached"
290		}
291	}
292	if ok && core.Info(cid).State != types.StateMinting {
293		ok, reason = false, "collection is not minting"
294	}
295	w.StrField("kind", p.Kind)
296	w.BoolField("eligible", ok)
297	w.StrField("reason", reason)
298	w.IntField("remainingCap", remaining)
299	w.IntField("price", p.PriceAt(h))
300	w.EndObject()
301	return w.String()
302}
303
304func Render(path string) string {
305	if path == "" {
306		return "# mint/v1\n\nSale phases of the platform's collections. Use `:<cid>` for one collection.\n"
307	}
308	s := mustSale(path) // only known collection ids get this far
309	out := "# Sale of collection " + path + "\n\n"
310	for i, p := range s.phases {
311		out += ufmt.Sprintf("%d. %s phase, heights %d-%d, price %d -> %d ugnot, wallet cap %d\n",
312			i+1, p.Kind, p.StartHeight, p.EndHeight, p.StartPrice, p.FloorPrice, p.WalletCap)
313	}
314	return out
315}
316
317func itoa(n int64) string { return strconv.FormatInt(n, 10) }