Realm
Pearl
gno.land/r/samcrew/launchpad/drops/v1
Overview
Network
pearl-1
Realm Path
gno.land/r/samcrew/launchpad/drops/v1
Exported Functions
0
State Entries
13
Source Files
4
Total Package Entries
41
Exported Functions
No exported functions found.
State
13 state entries
Source Code
FILES
drops.gno
go
1// Package drops creates Launchpad NFT collections and runs their primary
2// mints. It charges the collection fee and each mint price, and pays the
3// treasury and the creator inside the same transaction: it holds no funds
4// between calls and has no admin.
5package drops
6
7import (
8 "chain"
9 "strconv"
10 "time"
11
12 "gno.land/p/nt/avl/v0"
13 "gno.land/p/samcrew/launchpad/currency/v1"
14 "gno.land/p/samcrew/launchpad/merkle/v1"
15 "gno.land/p/samcrew/launchpad/meta/v1"
16 sm "gno.land/p/samcrew/launchpad/safemath/v1"
17 "gno.land/r/samcrew/launchpad/config/v1"
18 "gno.land/r/samcrew/launchpad/nft/v1"
19)
20
21const (
22 KindFixed = "fixed" // one price, open to every wallet
23 KindAllowlist = "allowlist" // only addresses under a Merkle root, each with its own allowance
24 KindHolder = "holder" // one mint per token held in another collection
25 KindDutch = "dutch" // the price falls in a straight line until the stage ends
26
27 // Hard limits, fixed for the life of this realm.
28 maxPrimaryFeeBPS = 500 // 5% of a mint price
29 maxStages = 10
30 maxStageSeconds = 365 * 24 * 60 * 60
31 maxPerWallet = 1_000_000
32)
33
34// stage is one mint window of a collection, open during [Start, End).
35type stage struct {
36 Kind string
37 Start int64
38 End int64
39 Price int64 // per token; for a dutch stage, the price at Start
40 Floor int64 // dutch only: the price reached at End
41 Currency string
42 FeeBPS int64 // protocol fee on each mint, pinned when the creator signed
43 SupplyCap int64 // 0 means no cap beyond the collection's
44 PerWallet int64 // most one wallet may mint; an allowlist sets it per address
45 Root string // allowlist only
46 Gate string // holder only: the collection whose tokens each allow one mint
47 // Holder only: the gate collection's tokens numbered up to it, all minted
48 // when the stage was placed, open a mint; later ones do not, so no loop of
49 // collections gated on each other mints its own gate tokens.
50 GateLimit int64
51 Minted int64
52}
53
54var (
55 stages avl.Tree // collection ID -> []stage, at most maxStages
56 minted avl.Tree // "<collection>/<stage>/<address>" -> int64
57 gated avl.Tree // "<collection>/<stage>/<gate token number>" -> true
58)
59
60func directCaller(cur realm) address {
61 if !cur.IsCurrent() || !cur.Previous().IsUserCall() {
62 panic("launchpad/drops: direct user call required")
63 }
64 return cur.Previous().Address()
65}
66
67// CreateCollection registers the caller's collection in the NFT ledger and
68// pays the collection fee to the treasury. maxFee is the highest fee the
69// caller accepts: a fee above it aborts before anything is taken, whatever
70// the caller's allowance. Native coins are attached for exactly the fee; a
71// token is pulled for exactly the fee. The collection terms are documented
72// on nft.Create.
73func CreateCollection(cur realm, name, symbol, description, image, banner,
74 website, mode string, revocable bool, maxSupply int64, metadataMode,
75 baseURI, placeholderURI, commitment, provenance, royalties,
76 currencyKey string, maxFee int64) string {
77 creator := directCaller(cur)
78 p := policy(config.LaneCollection, currencyKey)
79 if p.collectionFee > maxFee {
80 panic("launchpad/drops: collection fee above the accepted maximum")
81 }
82 held := take(cur, currencyKey, p.collectionFee, p.collectionFee)
83 id := nft.Create(cross(cur), creator, name, symbol, description, image,
84 banner, website, mode, revocable, maxSupply, metadataMode, baseURI,
85 placeholderURI, commitment, provenance, royalties)
86 pay(cur, currencyKey, p.treasury, p.collectionFee)
87 settled(cur, currencyKey, held)
88 chain.Emit("CollectionFeePaid", "collection", id, "creator", creator.String(),
89 "currency", currencyKey, "fee", strconv.FormatInt(p.collectionFee, 10),
90 "treasury", p.treasury.String())
91 return id
92}
93
94func stagesOf(id string) []stage {
95 if list := stages.Get(id); list != nil {
96 return list.([]stage)
97 }
98 return nil
99}
100
101// requireCreator aborts unless the caller is the collection's creator. The
102// creator may be a realm, a DAO for instance: what it signs here moves no
103// funds. Coins attached to a user's call would stay in this realm, so they
104// are refused; coins a realm's transaction carries belong to that realm.
105func requireCreator(cur realm, id string) {
106 if !cur.IsCurrent() || cur.Previous().Address() != nft.GetInfo(id).Creator {
107 panic("launchpad/drops: creator only")
108 }
109 if cur.Previous().IsUserCall() {
110 currency.AssertNoCoins(0, cur)
111 }
112}
113
114// emitStage publishes every term of a stage, so a reader of events alone
115// knows what each stage sells.
116func emitStage(id string, index int, s stage) {
117 chain.Emit("StageSet", "collection", id, "stage", strconv.Itoa(index),
118 "kind", s.Kind, "start", strconv.FormatInt(s.Start, 10),
119 "end", strconv.FormatInt(s.End, 10), "currency", s.Currency,
120 "price", strconv.FormatInt(s.Price, 10),
121 "floor", strconv.FormatInt(s.Floor, 10),
122 "fee_bps", strconv.FormatInt(s.FeeBPS, 10),
123 "supply_cap", strconv.FormatInt(s.SupplyCap, 10),
124 "per_wallet", strconv.FormatInt(s.PerWallet, 10),
125 "root", s.Root, "gate", s.Gate, "gate_limit", strconv.FormatInt(s.GateLimit, 10))
126}
127
128func validRoot(s string) bool {
129 h, err := merkle.ParseHash(s)
130 return err == nil && merkle.FormatHash(h) == s
131}
132
133// newStage validates a stage of collection id that has not started. Fields a
134// kind does not use must be zero, so a stage reads the same to everyone. A
135// holder stage is gated on another collection, and only on the tokens that
136// collection had minted when the stage was placed: gated on its own, or on a
137// collection gated on it in turn, every token minted would open one more mint.
138func newStage(id, kind string, start, end, price, floor, supplyCap, perWallet int64,
139 root, gate, currencyKey string) stage {
140 ok := start >= time.Now().Unix() && end > start && end-start <= maxStageSeconds &&
141 price >= 0 && supplyCap >= 0 && perWallet >= 0 && perWallet <= maxPerWallet
142 var gateLimit int64
143 switch kind {
144 case KindFixed:
145 ok = ok && perWallet > 0 && floor == 0 && root == "" && gate == ""
146 case KindAllowlist:
147 ok = ok && perWallet == 0 && floor == 0 && validRoot(root) && gate == ""
148 case KindHolder:
149 ok = ok && perWallet > 0 && floor == 0 && root == "" && gate != "" && gate != id
150 if ok {
151 gateLimit = nft.GetInfo(gate).Minted // an unknown gate collection aborts
152 }
153 case KindDutch:
154 ok = ok && perWallet > 0 && floor >= 0 && floor < price && root == "" && gate == ""
155 default:
156 ok = false
157 }
158 if !ok {
159 panic("launchpad/drops: invalid stage")
160 }
161 return stage{Kind: kind, Start: start, End: end, Price: price, Floor: floor,
162 Currency: currencyKey, SupplyCap: supplyCap, PerWallet: perWallet,
163 Root: root, Gate: gate, GateLimit: gateLimit}
164}
165
166// place writes s at index (len(list) appends) after checking that the caller
167// is the collection's creator, the mint lane accepts the currency, the
168// protocol fee is within what the creator accepts and no other stage
169// overlaps. The fee is pinned on the stage: a later change does not reach it.
170func place(cur realm, id string, index int, s stage, maxFeeBPS int64) {
171 requireCreator(cur, id)
172 s.FeeBPS = policy(config.LaneNFTDrops, s.Currency).primaryFeeBPS
173 if s.FeeBPS > maxFeeBPS || s.FeeBPS > maxPrimaryFeeBPS {
174 panic("launchpad/drops: protocol fee above the accepted maximum")
175 }
176 list := stagesOf(id)
177 for i, other := range list {
178 if i != index && s.Start < other.End && other.Start < s.End {
179 panic("launchpad/drops: stages overlap")
180 }
181 }
182 if index == len(list) {
183 list = append(list, s)
184 } else {
185 list[index] = s
186 }
187 stages.Set(id, list)
188 emitStage(id, index, s)
189}
190
191// AddStage schedules a mint window and returns its index. The arguments are
192// the terms of the stage, then the highest protocol fee the creator accepts.
193// A collection has ten stages for its whole life: a stage that ended keeps
194// its slot.
195func AddStage(cur realm, id, kind string, start, end, price, floor, supplyCap,
196 perWallet int64, root, gate, currencyKey string, maxFeeBPS int64) int {
197 index := len(stagesOf(id))
198 if index >= maxStages {
199 panic("launchpad/drops: too many stages")
200 }
201 place(cur, id, index, newStage(id, kind, start, end, price, floor, supplyCap,
202 perWallet, root, gate, currencyKey), maxFeeBPS)
203 return index
204}
205
206// EditStage replaces a stage that has not started. Once a stage has opened
207// its terms are final, whatever the replacement would have been: it can only
208// be ended.
209func EditStage(cur realm, id string, index int, kind string, start, end, price,
210 floor, supplyCap, perWallet int64, root, gate, currencyKey string,
211 maxFeeBPS int64) {
212 list := stagesOf(id)
213 if index < 0 || index >= len(list) {
214 panic("launchpad/drops: unknown stage")
215 }
216 if time.Now().Unix() >= list[index].Start {
217 panic("launchpad/drops: stage already started")
218 }
219 place(cur, id, index, newStage(id, kind, start, end, price, floor, supplyCap,
220 perWallet, root, gate, currencyKey), maxFeeBPS)
221}
222
223// EndStage closes an open stage at once and for good. It asks no lane: a
224// creator stops its own sale whatever is paused.
225func EndStage(cur realm, id string, index int) {
226 requireCreator(cur, id)
227 list := stagesOf(id)
228 if index < 0 || index >= len(list) {
229 panic("launchpad/drops: unknown stage")
230 }
231 s := &list[index]
232 now := time.Now().Unix()
233 if now < s.Start || now >= s.End {
234 panic("launchpad/drops: stage closed")
235 }
236 s.End = now
237 stages.Set(id, list)
238 emitStage(id, index, *s)
239}
240
241// priceAt is the stage price at a time inside its window.
242func priceAt(s stage, now int64) int64 {
243 if s.Kind != KindDutch {
244 return s.Price
245 }
246 return s.Price - sm.MulDiv(s.Price-s.Floor, now-s.Start, s.End-s.Start)
247}
248
249func stageKey(id string, index int) string { return id + "/" + strconv.Itoa(index) + "/" }
250
251// AllowlistLeaf is the Merkle leaf that lets who mint up to allowance tokens
252// in one stage: sha256(0x00 || "launchpad-drop-v1|<collection>|<stage>|<address>|<allowance>").
253// Only the chain's spelling of an address makes a leaf: a list written in
254// capitals would name accounts that can never claim.
255func AllowlistLeaf(id string, index int, who address, allowance int64) merkle.Hash {
256 if !meta.ValidAddress(who) {
257 panic("launchpad/drops: invalid account")
258 }
259 return merkle.Leaf([]byte("launchpad-drop-v1|" + id + "|" + strconv.Itoa(index) +
260 "|" + who.String() + "|" + strconv.FormatInt(allowance, 10)))
261}
262
263// entitled checks what the stage's kind requires of the buyer and returns how
264// many tokens that wallet may mint in the stage. For a holder stage it also
265// returns the key under which the gate token must be recorded as used.
266func entitled(s stage, id string, index int, buyer address, allowance int64,
267 proof string, gateNumber int64) (limit int64, gateKey string) {
268 switch s.Kind {
269 case KindAllowlist:
270 root, _ := merkle.ParseHash(s.Root)
271 path, err := merkle.ParseProof(proof)
272 if err != nil || gateNumber != 0 || allowance > maxPerWallet ||
273 !merkle.Verify(root, AllowlistLeaf(id, index, buyer, allowance), path) {
274 panic("launchpad/drops: not on the allowlist")
275 }
276 return allowance, ""
277 case KindHolder:
278 gateKey = stageKey(id, index) + strconv.FormatInt(gateNumber, 10)
279 if allowance != 0 || proof != "" || gated.Has(gateKey) ||
280 nft.OwnerOf(s.Gate, gateNumber) != buyer {
281 panic("launchpad/drops: gate token missing or already used")
282 }
283 if gateNumber > s.GateLimit {
284 panic("launchpad/drops: gate token minted after the stage was scheduled")
285 }
286 default:
287 if allowance != 0 || proof != "" || gateNumber != 0 {
288 panic("launchpad/drops: this stage takes no allowance, proof or gate token")
289 }
290 }
291 return s.PerWallet, gateKey
292}
293
294// Mint sells the caller one token of an open stage and returns its number.
295// currencyKey and offered are what the caller accepts to pay: the stage must
296// be priced in that currency, and offered is the most it will take. Native
297// coins are attached for exactly offered and the difference with the price
298// comes back, which is what makes a falling price payable; a token is pulled
299// for exactly the price. The price is split between the treasury (the fee
300// pinned on the stage) and the creator. allowance and proof are for an
301// allowlist stage, gateNumber for a holder stage; elsewhere they are zero and
302// empty.
303func Mint(cur realm, id string, index int, currencyKey string, offered,
304 allowance int64, proof string, gateNumber int64) int64 {
305 buyer := directCaller(cur)
306 list := stagesOf(id)
307 if index < 0 || index >= len(list) {
308 panic("launchpad/drops: unknown stage")
309 }
310 s := &list[index]
311 if s.Currency != currencyKey {
312 panic("launchpad/drops: stage priced in another currency")
313 }
314 now := time.Now().Unix()
315 if now < s.Start || now >= s.End || (s.SupplyCap > 0 && s.Minted >= s.SupplyCap) {
316 panic("launchpad/drops: stage closed")
317 }
318 p := policy(config.LaneNFTDrops, s.Currency)
319 wallet := stageKey(id, index) + buyer.String()
320 var count int64
321 if v := minted.Get(wallet); v != nil {
322 count = v.(int64)
323 }
324 limit, gateKey := entitled(*s, id, index, buyer, allowance, proof, gateNumber)
325 if count >= limit {
326 panic("launchpad/drops: wallet limit reached")
327 }
328 price := priceAt(*s, now)
329 held := take(cur, s.Currency, price, offered)
330 number := nft.MintTo(cross(cur), id, buyer)
331 creator := nft.GetInfo(id).Creator
332 fee := sm.BPS(price, s.FeeBPS)
333 pay(cur, s.Currency, p.treasury, fee)
334 pay(cur, s.Currency, creator, price-fee)
335 settled(cur, s.Currency, held)
336 s.Minted++
337 stages.Set(id, list)
338 minted.Set(wallet, count+1)
339 if gateKey != "" {
340 gated.Set(gateKey, true)
341 }
342 chain.Emit("Minted", "collection", id, "number", strconv.FormatInt(number, 10),
343 "stage", strconv.Itoa(index), "buyer", buyer.String(),
344 "currency", s.Currency, "price", strconv.FormatInt(price, 10),
345 "fee", strconv.FormatInt(fee, 10), "treasury", p.treasury.String(),
346 "creator", creator.String())
347 return number
348}
349
350func Render(path string) string {
351 return "# Launchpad NFT drops\n\n" + strconv.Itoa(stages.Size()) +
352 " collections have mint stages.\n"
353}
354Raw Package Data
Raw JSON data