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