← Back to Realms

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

Raw Package Data

Raw JSON data