Smart Contract

FlowtyDrops

A.befbaccb5032a457.FlowtyDrops

Deployed

1h ago
Feb 28, 2026, 09:42:23 PM UTC

Dependents

0 imports
1import NonFungibleToken from 0x1d7e57aa55817448
2import FungibleToken from 0xf233dcee88fe0abe
3import MetadataViews from 0x1d7e57aa55817448
4import AddressUtils from 0xa340dc0a4ec828ab
5import FungibleTokenMetadataViews from 0xf233dcee88fe0abe
6import FungibleTokenRouter from 0x707c0b39a8d689cb
7
8// FlowtyDrops is a contract to help collections manage their primary sale needs on flow.
9// Multiple drops can be made for a single contract (like how TopShot has had lots of pack drops),
10// and can be split into phases to represent different behaviors over the course of a drop
11access(all) contract FlowtyDrops {
12    // The total number of nfts minted by this contract
13    access(all) var TotalMinted: UInt64
14
15    access(all) let ContainerStoragePath: StoragePath
16    access(all) let ContainerPublicPath: PublicPath
17
18    access(all) event DropAdded(address: Address, id: UInt64, name: String, description: String, imageUrl: String, start: UInt64?, end: UInt64?, nftType: String)
19    access(all) event DropRemoved(address: Address, id: UInt64)
20    access(all) event Minted(address: Address, dropID: UInt64, phaseID: UInt64, nftID: UInt64, nftType: String, totalMinted: UInt64)
21    access(all) event PhaseAdded(dropID: UInt64, dropAddress: Address, id: UInt64, index: Int, activeCheckerType: String, pricerType: String, addressVerifierType: String)
22    access(all) event PhaseRemoved(dropID: UInt64, dropAddress: Address, id: UInt64)
23
24    access(all) entitlement Owner
25    access(all) entitlement EditPhase
26
27    // Interface to expose all the components necessary to participate in a drop
28    // and to ask questions about a drop.
29    access(all) resource interface DropPublic {
30        access(all) view fun borrowPhasePublic(index: Int): &{PhasePublic}
31        access(all) view fun borrowActivePhases(): [&{PhasePublic}]
32        access(all) view fun borrowAllPhases(): [&{PhasePublic}]
33        access(all) fun mint(
34            payment: @{FungibleToken.Vault},
35            amount: Int,
36            phaseIndex: Int,
37            expectedType: Type,
38            receiverCap: Capability<&{NonFungibleToken.CollectionPublic}>,
39            commissionReceiver: Capability<&{FungibleToken.Receiver}>?,
40            data: {String: AnyStruct}
41        ): @{FungibleToken.Vault} {
42            pre {
43                self.getDetails().paymentTokenTypes[payment.getType().identifier] == true: "unsupported payment token type"
44                receiverCap.check(): "unvalid nft receiver capability"
45                commissionReceiver == nil || commissionReceiver!.check(): "commission receiver must be nil or a valid capability"
46                self.getType() == Type<@Drop>(): "unsupported type implementing DropPublic"
47                expectedType.isSubtype(of: Type<@{NonFungibleToken.NFT}>()): "expected type must be an NFT"
48                expectedType.identifier == self.getDetails().nftType: "expected type does not match drop details type"
49                receiverCap.check(): "receiver capability is not valid"
50            }
51        }
52        access(all) view fun getDetails(): DropDetails
53    }
54
55    // A phase represents a stage of a drop. Some drops will only have one
56    // phase, while others could have many. For example, a drop with an allow list
57    // and a public mint would likely have two phases.
58    access(all) resource Phase: PhasePublic {
59        access(all) event ResourceDestroyed(uuid: UInt64 = self.uuid)
60
61        access(all) let details: PhaseDetails
62
63        access(all) let data: {String: AnyStruct}
64        access(all) let resources: @{String: AnyResource}
65
66        // returns whether this phase of a drop has started.
67        access(all) view fun isActive(): Bool {
68            return self.details.activeChecker.hasStarted() && !self.details.activeChecker.hasEnded()
69        }
70
71        access(all) view fun getDetails(): PhaseDetails {
72            return self.details
73        }
74
75        access(EditPhase) view fun borrowActiveCheckerAuth(): auth(Mutate) &{ActiveChecker} {
76            return &self.details.activeChecker
77        }
78
79        access(EditPhase) view fun borrowPricerAuth(): auth(Mutate) &{Pricer} {
80            return &self.details.pricer
81        }
82
83        access(EditPhase) view fun borrowAddressVerifierAuth(): auth(Mutate) &{AddressVerifier} {
84            return &self.details.addressVerifier
85        }
86
87        init(details: PhaseDetails) {
88            self.details = details
89
90            self.data = {}
91            self.resources <- {}
92        }
93    }
94
95    // The primary resource of this contract. A drop has some top-level details, and some phase-specific details which are encapsulated
96    // by each phase.
97    access(all) resource Drop: DropPublic {
98        access(all) event ResourceDestroyed(
99            uuid: UInt64 = self.uuid,
100            minterAddress: Address = self.minterCap.address,
101            nftType: String = self.details.nftType,
102            totalMinted: Int = self.details.totalMinted
103        )
104
105        // phases represent the stages of a drop. For example, a drop might have an allowlist and a public mint phase.
106        access(self) let phases: @[Phase]
107        // the details of a drop. This includes things like display information and total number of mints
108        access(self) let details: DropDetails
109        // capability to mint nfts with. Regardless of where a drop is hosted, the minter itself is what is responsible for creating nfts
110        // and is used by the drop's mint method.
111        access(self) let minterCap: Capability<&{Minter}>
112
113        // general-purpose property bags which are needed to ensure extensibility of this resource
114        access(all) let data: {String: AnyStruct}
115        access(all) let resources: @{String: AnyResource}
116
117        access(all) fun mint(
118            payment: @{FungibleToken.Vault},
119            amount: Int,
120            phaseIndex: Int,
121            expectedType: Type,
122            receiverCap: Capability<&{NonFungibleToken.CollectionPublic}>,
123            commissionReceiver: Capability<&{FungibleToken.Receiver}>?,
124            data: {String: AnyStruct}
125        ): @{FungibleToken.Vault} {
126            pre {
127                self.phases.length > phaseIndex: "phase index is too high"
128            }
129
130            // validate the payment vault amount and type
131            let phase: &Phase = &self.phases[phaseIndex]
132            assert(
133                phase.details.addressVerifier.canMint(addr: receiverCap.address, num: amount, totalMinted: self.details.minters[receiverCap.address] ?? 0, data: {}),
134                message: "receiver address has exceeded their mint capacity"
135            )
136
137            let paymentAmount = phase.details.pricer.getPrice(num: amount, paymentTokenType: payment.getType(), minter: receiverCap.address)
138            assert(payment.balance >= paymentAmount, message: "payment balance is lower than payment amount")
139            let withdrawn <- payment.withdraw(amount: paymentAmount) // make sure that we have a fresh vault resource
140
141            // take commission
142            if commissionReceiver != nil && commissionReceiver!.check() {
143                let commission <- withdrawn.withdraw(amount: self.details.commissionRate * withdrawn.balance)
144                commissionReceiver!.borrow()!.deposit(from: <-commission)
145            }
146
147            // The balance of the payment sent to the creator is equal to the paymentAmount - fees
148            assert(paymentAmount * (1.0 - self.details.commissionRate) == withdrawn.balance, message: "incorrect payment amount")
149            assert(phase.details.pricer.getPaymentTypes().contains(withdrawn.getType()), message: "unsupported payment type")
150            assert(phase.details.activeChecker.hasStarted() && !phase.details.activeChecker.hasEnded(), message: "phase is not active")
151
152            // mint the nfts
153            let minter = self.minterCap.borrow() ?? panic("minter capability could not be borrowed")
154            let mintedNFTs: @[{NonFungibleToken.NFT}] <- minter.mint(payment: <-withdrawn, amount: amount, phase: phase, data: data)
155            assert(mintedNFTs.length == amount, message: "incorrect number of items returned")
156
157            // distribute to receiver
158            let receiver = receiverCap.borrow() ?? panic("could not borrow receiver capability")
159            self.details.addMinted(num: mintedNFTs.length, addr: receiverCap.address)
160
161            while mintedNFTs.length > 0 {
162                let nft <- mintedNFTs.removeFirst()
163
164                let nftType = nft.getType()
165                FlowtyDrops.TotalMinted = FlowtyDrops.TotalMinted + 1
166                emit Minted(address: receiverCap.address, dropID: self.uuid, phaseID: phase.uuid, nftID: nft.id, nftType: nftType.identifier, totalMinted: FlowtyDrops.TotalMinted)
167
168                // validate that every nft is the right type
169                assert(nftType == expectedType, message: "unexpected nft type was minted")
170    
171                receiver.deposit(token: <-nft)
172            }
173
174            // cleanup
175            destroy mintedNFTs
176
177            // return excess payment
178            return <- payment
179        }
180
181        access(Owner) view fun borrowPhase(index: Int): auth(EditPhase) &Phase {
182            return &self.phases[index]
183        }
184
185
186        access(all) view fun borrowPhasePublic(index: Int): &{PhasePublic} {
187            return &self.phases[index]
188        }
189
190        access(all) view fun borrowActivePhases(): [&{PhasePublic}] {
191            var arr: [&{PhasePublic}] = []
192            var count = 0
193            while count < self.phases.length {
194                let ref = self.borrowPhasePublic(index: count)
195                let activeChecker = ref.getDetails().activeChecker
196                if activeChecker.hasStarted() && !activeChecker.hasEnded() {
197                    arr = arr.concat([ref])
198                }
199
200                count = count + 1
201            }
202
203            return arr
204        }
205
206        access(all) view fun borrowAllPhases(): [&{PhasePublic}] {
207            var arr: [&{PhasePublic}] = []
208            var index = 0
209            while index < self.phases.length {
210                let ref = self.borrowPhasePublic(index: index)
211                arr = arr.concat([ref])
212                index = index + 1
213            }
214
215            return arr
216        }
217
218        access(Owner) fun addPhase(_ phase: @Phase) {
219            emit PhaseAdded(
220                dropID: self.uuid,
221                dropAddress: self.owner!.address,
222                id: phase.uuid,
223                index: self.phases.length,
224                activeCheckerType: phase.details.activeChecker.getType().identifier,
225                pricerType: phase.details.pricer.getType().identifier,
226                addressVerifierType: phase.details.addressVerifier.getType().identifier
227            )
228            self.phases.append(<-phase)
229        }
230
231        access(Owner) fun removePhase(index: Int): @Phase {
232            pre {
233                self.phases.length > index: "index is greater than length of phases"
234            }
235
236            let phase <- self.phases.remove(at: index)
237            emit PhaseRemoved(dropID: self.uuid, dropAddress: self.owner!.address, id: phase.uuid)
238
239            return <- phase
240        }
241
242        access(all) view fun getDetails(): DropDetails {
243            return self.details
244        }
245
246        init(details: DropDetails, minterCap: Capability<&{Minter}>, phases: @[Phase]) {
247            pre {
248                minterCap.check(): "minter capability is not valid"
249            }
250
251            self.phases <- phases
252            self.details = details
253            self.minterCap = minterCap
254
255            self.data = {}
256            self.resources <- {}
257        }
258    }
259
260    access(all) struct DropDetails {
261        access(all) let display: MetadataViews.Display
262        access(all) let medias: MetadataViews.Medias?
263        access(all) var totalMinted: Int
264        access(all) var minters: {Address: Int}
265        access(all) let commissionRate: UFix64
266        access(all) let nftType: String
267        access(all) let paymentTokenTypes: {String: Bool}
268
269        access(all) let data: {String: AnyStruct}
270
271        access(contract) fun addMinted(num: Int, addr: Address) {
272            self.totalMinted = self.totalMinted + num
273            if self.minters[addr] == nil {
274                self.minters[addr] = 0
275            }
276
277            self.minters[addr] = self.minters[addr]! + num
278        }
279
280        init(display: MetadataViews.Display, medias: MetadataViews.Medias?, commissionRate: UFix64, nftType: String, paymentTokenTypes: {String: Bool}) {
281            pre {
282                nftType != "": "nftType should be a composite type identifier"
283            }
284
285            self.display = display
286            self.medias = medias
287            self.totalMinted = 0
288            self.commissionRate = commissionRate
289            self.minters = {}
290            self.nftType = nftType
291            self.paymentTokenTypes = paymentTokenTypes
292
293            self.data = {}
294        }
295    }
296
297    // An ActiveChecker represents a phase being on or off, and holds information
298    // about whether a phase has started or not.
299    access(all) struct interface ActiveChecker {
300        // Signal that a phase has started. If the phase has not ended, it means that this activeChecker's phase
301        // is active
302        access(all) view fun hasStarted(): Bool
303        // Signal that a phase has ended. If an ActiveChecker has ended, minting will not work. That could mean
304        // the drop is over, or it could mean another phase has begun.
305        access(all) view fun hasEnded(): Bool
306
307        access(all) view fun getStart(): UInt64?
308        access(all) view fun getEnd(): UInt64?
309    }
310
311    access(all) resource interface PhasePublic {
312        // What does a phase need to be able to answer/manage?
313        // - What are the details of the phase being interactive with?
314        // - How many items are left in the current phase?
315        // - Can Address x mint on a phase?
316        // - What is the cost to mint for the phase I am interested in (for address x)?
317        access(all) view fun getDetails(): PhaseDetails
318        access(all) view fun isActive(): Bool
319    }
320
321    access(all) struct PhaseDetails {
322        // handles whether a phase is on or not
323        access(all) let activeChecker: {ActiveChecker}
324
325        // display information about a phase
326        access(all) let display: MetadataViews.Display?
327
328        // handles the pricing of a phase
329        access(all) let pricer: {Pricer}
330
331        // verifies whether an address is able to mint
332        access(all) let addressVerifier: {AddressVerifier}
333
334        // placecholder data dictionary to allow new fields to be accessed
335        access(all) let data: {String: AnyStruct}
336
337        init(activeChecker: {ActiveChecker}, display: MetadataViews.Display?, pricer: {Pricer}, addressVerifier: {AddressVerifier}) {
338            self.activeChecker = activeChecker
339            self.display = display
340            self.pricer = pricer
341            self.addressVerifier = addressVerifier
342
343            self.data = {}
344        }
345    }
346
347    // The AddressVerifier interface is responsible for determining whether an address is permitted to mint or not
348    access(all) struct interface AddressVerifier {
349        access(all) fun canMint(addr: Address, num: Int, totalMinted: Int, data: {String: AnyStruct}): Bool {
350            return true
351        }
352
353        access(all) fun remainingForAddress(addr: Address, totalMinted: Int): Int? {
354            return nil
355        }
356
357        access(all) view fun getMaxPerMint(addr: Address?, totalMinted: Int, data: {String: AnyStruct}): Int? {
358            return nil
359        }
360    }
361
362    // The pricer interface is responsible for the cost of a mint. It can vary by phase
363    access(all) struct interface Pricer {
364        access(all) fun getPrice(num: Int, paymentTokenType: Type, minter: Address?): UFix64
365        access(all) fun getPaymentTypes(): [Type]
366    }
367
368    access(all) resource interface Minter {
369        // mint is only able to be called either by this contract (FlowtyDrops) or the implementing contract.
370        // In its default implementation, it is assumed that the receiver capability for payment is the FungibleTokenRouter
371        access(contract) fun mint(payment: @{FungibleToken.Vault}, amount: Int, phase: &FlowtyDrops.Phase, data: {String: AnyStruct}): @[{NonFungibleToken.NFT}] {
372            let resourceAddress = AddressUtils.parseAddress(self.getType())!
373            let receiver = getAccount(resourceAddress).capabilities.get<&{FungibleToken.Receiver}>(FungibleTokenRouter.PublicPath).borrow()
374                ?? panic("missing receiver at fungible token router path")
375            receiver.deposit(from: <-payment)
376
377            let nfts: @[{NonFungibleToken.NFT}] <- []
378
379            var count = 0
380            while count < amount {
381                count = count + 1
382                nfts.append(<- self.createNextNFT())
383            }
384
385            return <- nfts
386        }
387
388        // required so that the minter interface has a way to create NFTs on its implementing resource
389        access(contract) fun createNextNFT(): @{NonFungibleToken.NFT}
390    }
391    
392    // Struct to wrap obtaining a Drop container. Intended for use with the ViewResolver contract interface
393    access(all) struct DropResolver {
394        access(self) let cap: Capability<&{ContainerPublic}>
395
396        access(all) fun borrowContainer(): &{ContainerPublic}? {
397            return self.cap.borrow()
398        }
399
400        init(cap: Capability<&{ContainerPublic}>) {
401            pre {
402                cap.check(): "container capability is not valid"
403            }
404
405            self.cap = cap
406        }
407    }
408
409    access(all) resource interface ContainerPublic {
410        access(all) fun borrowDropPublic(id: UInt64): &{DropPublic}?
411        access(all) fun getIDs(): [UInt64]
412    }
413
414    // Container holds drops so that one address can host more than one drop at once
415    access(all) resource Container: ContainerPublic {
416        access(self) let drops: @{UInt64: Drop}
417
418        access(all) let data: {String: AnyStruct}
419        access(all) let resources: @{String: AnyResource}
420
421        access(Owner) fun addDrop(_ drop: @Drop) {
422            let details = drop.getDetails()
423
424            let phases = drop.borrowAllPhases()
425            assert(phases.length > 0, message: "drops must have at least one phase to be added to a container")
426
427            let firstPhaseDetails = phases[0].getDetails()
428
429            emit DropAdded(
430                address: self.owner!.address,
431                id: drop.uuid,
432                name: details.display.name,
433                description: details.display.description,
434                imageUrl: details.display.thumbnail.uri(),
435                start: firstPhaseDetails.activeChecker.getStart(),
436                end: firstPhaseDetails.activeChecker.getEnd(),
437                nftType: details.nftType
438            )
439            destroy self.drops.insert(key: drop.uuid, <-drop)
440        }
441
442        access(Owner) fun removeDrop(id: UInt64): @Drop {
443            pre {
444                self.drops.containsKey(id): "drop was not found"
445            }
446
447            emit DropRemoved(address: self.owner!.address, id: id)
448            return <- self.drops.remove(key: id)!
449        }
450
451        access(Owner) fun borrowDrop(id: UInt64): auth(Owner) &Drop? {
452            return &self.drops[id]
453        }
454
455        access(all) fun borrowDropPublic(id: UInt64): &{DropPublic}? {
456            return &self.drops[id]
457        }
458
459        access(all) fun getIDs(): [UInt64] {
460            return self.drops.keys
461        }
462
463        init() {
464            self.drops <- {}
465
466            self.data = {}
467            self.resources <- {}
468        }
469    }
470
471    access(all) fun createPhase(details: PhaseDetails): @Phase {
472        return <- create Phase(details: details)
473    }
474
475    access(all) fun createDrop(details: DropDetails, minterCap: Capability<&{Minter}>, phases: @[Phase]): @Drop {
476        return <- create Drop(details: details, minterCap: minterCap, phases: <- phases)
477    }
478
479    access(all) fun createContainer(): @Container {
480        return <- create Container()
481    }
482
483    access(all) fun getMinterStoragePath(type: Type): StoragePath {
484        let segments = type.identifier.split(separator: ".")
485        let identifier = "FlowtyDrops_Minter_".concat(segments[1]).concat("_").concat(segments[2])
486        return StoragePath(identifier: identifier)!
487    }
488
489    init() {
490        let identifier = "FlowtyDrops_".concat(self.account.address.toString())
491        let containerIdentifier = identifier.concat("_Container")
492        let minterIdentifier = identifier.concat("_Minter")
493
494        self.ContainerStoragePath = StoragePath(identifier: containerIdentifier)!
495        self.ContainerPublicPath = PublicPath(identifier: containerIdentifier)!
496
497        self.TotalMinted = 0
498    }
499}