Smart Contract

Redeemables

A.e81193c424cfd3fb.Redeemables

Deployed

2d ago
Feb 26, 2026, 01:49:14 PM UTC

Dependents

0 imports
1import FungibleToken from 0xf233dcee88fe0abe
2import NonFungibleToken from 0x1d7e57aa55817448
3import MetadataViews from 0x1d7e57aa55817448
4import ViewResolver from 0x1d7e57aa55817448
5
6access(all) contract Redeemables: NonFungibleToken {
7	access(all) event ContractInitialized()
8	access(all) event Minted(id: UInt64, address: Address, setId: UInt64, templateId: UInt64, name: String)
9	access(all) event Redeemed(id: UInt64, address: Address, setId: UInt64, templateId: UInt64, name: String)
10	access(all) event Burned(id: UInt64, address: Address, setId: UInt64, templateId: UInt64, name: String)
11    access(all) event SetCreated(id: UInt64, name: String)
12	access(all) event TemplateCreated(id: UInt64, setId: UInt64, name: String)
13
14	access(all) let CollectionStoragePath: StoragePath
15	access(all) let CollectionPublicPath: PublicPath
16	access(all) let AdminStoragePath: StoragePath
17
18	access(all) var totalSupply: UInt64
19	access(all) let sets: {UInt64 : Set}
20	access(all) let templates: {UInt64 : Template}
21	access(all) let templateNextSerialNumber: {UInt64 : UInt64}
22
23	access(self) let extra: {String: AnyStruct}
24
25	access(all) struct Set {
26		access(all) let id: UInt64
27		access(all) var name: String
28		access(all) var canRedeem: Bool
29        access(all) var redeemLimitTimestamp : UFix64
30		access(all) var active: Bool
31	    access(contract) let ownersRecord: [Address]
32
33		access(self) let extra: {String: AnyStruct}
34
35		init(name: String, canRedeem: Bool, redeemLimitTimestamp: UFix64, active: Bool) {
36			self.id = UInt64(Redeemables.sets.keys.length) + 1
37			self.name = name
38			self.canRedeem = canRedeem
39			self.redeemLimitTimestamp = redeemLimitTimestamp
40			self.active = active
41			self.ownersRecord = []
42			self.extra = {}
43		}
44
45		access(all) fun getName() : String {
46			return self.name
47		}
48
49		access(all) fun isRedeemLimitExceeded() : Bool {
50			return self.redeemLimitTimestamp < getCurrentBlock().timestamp
51		}
52
53		access(contract) fun setActive(_ active: Bool) {
54			self.active = active
55		}
56
57		access(contract) fun setCanRedeem(_ canRedeem: Bool) {
58			self.canRedeem = canRedeem
59		}
60
61		access(contract) fun setRedeemLimitTimestamp(_ redeemLimitTimestamp: UFix64) {
62			self.redeemLimitTimestamp = redeemLimitTimestamp
63		}
64
65		access(contract) fun addOwnerRecord(_ address: Address): Bool {
66			if self.ownersRecord.contains(address) {
67				return false
68			}
69			self.ownersRecord.append(address)
70			return true
71		}
72	}
73
74	access(all) struct Template {
75		access(all) let id: UInt64
76		access(all) let setId: UInt64
77		access(all) var name: String
78		access(all) var description: String
79		access(all) var brand: String
80		access(all) var royalties: [MetadataViews.Royalty]
81		access(all) var type: String
82		access(all) var thumbnail: MetadataViews.Media
83		access(all) var image: MetadataViews.Media
84		access(all) var active: Bool
85
86		access(self) let extra: {String : AnyStruct}
87
88		init(setId: UInt64, name: String, description: String, brand: String, royalties: [MetadataViews.Royalty], type: String, thumbnail: MetadataViews.Media, image: MetadataViews.Media, active: Bool) {
89			pre {
90				Redeemables.sets.containsKey(setId) : "Set does not exist. Id: ".concat(setId.toString())
91			}
92			self.id = UInt64(Redeemables.templates.keys.length) + 1
93			self.setId = setId
94			self.name = name
95			self.description = description
96			self.brand = brand
97			self.royalties = royalties
98			self.type = type
99			self.thumbnail = thumbnail
100			self.image = image
101			self.active = active
102			self.extra = {}
103		}
104
105		access(all) fun getSet() : Redeemables.Set {
106			return Redeemables.sets[self.setId]!
107		}
108
109		access(contract) fun setActive(_ active: Bool) {
110			self.active = active
111		}
112	}
113
114	access(all) resource NFT: NonFungibleToken.NFT, ViewResolver.Resolver {
115		access(all) let id:UInt64
116		access(all) let templateId: UInt64
117		access(all) let serialNumber: UInt64
118
119		access(self) let extra: {String : AnyStruct}
120
121		init(templateId: UInt64) {
122			self.id = self.uuid
123			self.templateId = templateId
124			self.serialNumber = Redeemables.templateNextSerialNumber[templateId] ?? 1
125			self.extra = {}
126
127			Redeemables.templateNextSerialNumber[templateId] = self.serialNumber + 1
128			Redeemables.totalSupply = Redeemables.totalSupply + 1
129		}
130
131		access(all) view fun getViews(): [Type] {
132			return  [
133				Type<MetadataViews.Display>(),
134				Type<MetadataViews.Royalties>(),
135				Type<MetadataViews.ExternalURL>(),
136				Type<MetadataViews.NFTCollectionData>(),
137				Type<MetadataViews.NFTCollectionDisplay>(),
138				Type<MetadataViews.Traits>(),
139				Type<MetadataViews.Editions>()
140			]
141		}
142
143		access(all) fun getTemplate() : Template {
144			return Redeemables.templates[self.templateId]!
145		}
146
147		access(all) fun getSet() : Set {
148			return self.getTemplate().getSet()
149		}
150
151		access(all) fun createEmptyCollection(): @{NonFungibleToken.Collection} {
152            return <-Redeemables.createEmptyCollection(nftType: Type<@Redeemables.NFT>())
153        }
154
155		access(all) fun resolveView(_ view: Type): AnyStruct? {
156			switch view {
157				case Type<MetadataViews.Display>():
158					let template = self.getTemplate()
159					return MetadataViews.Display(
160						name: template.name,
161						description: template.description,
162						thumbnail: template.thumbnail.file,
163					)
164
165				case Type<MetadataViews.ExternalURL>():
166					return MetadataViews.ExternalURL("https://doodles.app")
167
168				case Type<MetadataViews.Traits>():
169					let template=self.getTemplate()
170					let traits : [MetadataViews.Trait]= []
171
172					traits.append(MetadataViews.Trait(
173						name: "Name",
174						value: template.name,
175							displayType: "string",
176						rarity: nil
177					))
178
179					traits.append(MetadataViews.Trait(
180						name: "Brand",
181						value: template.brand,
182						displayType: "string",
183						rarity: nil
184					))
185
186					traits.append(MetadataViews.Trait(
187						name: "Set",
188						value: template.getSet().name,
189						displayType: "string",
190						rarity: nil
191					))
192
193					traits.append(MetadataViews.Trait(
194						name: "Type",
195						value: template.type,
196						displayType: "string",
197						rarity: nil
198					))
199
200					traits.append(MetadataViews.Trait(
201						name: "Redeem Limit Date",
202						value: template.getSet().redeemLimitTimestamp,
203						displayType: "Date",
204						rarity: nil
205					))
206					return MetadataViews.Traits(traits)
207
208				case Type<MetadataViews.Royalties>():
209					let royalties = self.getTemplate().royalties
210					let royalty=royalties[0]
211
212					let doodlesMerchantAccountMainnet="0x014e9ddc4aaaf557"
213					//royalties if we sell on something else then DapperWallet cannot go to the address stored in the contract, and Dapper will not allow us to setup forwarders for Flow/USDC
214					if royalty.receiver.address.toString() == doodlesMerchantAccountMainnet {
215
216						//this is an account that have setup a forwarder for DUC/FUT to the merchant account of Doodles.
217						let royaltyAccountWithDapperForwarder = getAccount(0x12be92985b852cb8)
218						let cap = royaltyAccountWithDapperForwarder.capabilities.get<&{FungibleToken.Receiver}>(/public/fungibleTokenSwitchboardPublic)!
219						return MetadataViews.Royalties([MetadataViews.Royalty(receiver:cap, cut: royalty.cut, description:royalty.description)])
220					}
221
222					let doodlesMerchanAccountTestnet="0xd5b1a1553d0ed52e"
223					if royalty.receiver.address.toString() == doodlesMerchanAccountTestnet {
224						//on testnet we just send this to the main vault, it is not important
225						let cap = Redeemables.account.capabilities.get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver)!
226						return MetadataViews.Royalties([MetadataViews.Royalty(receiver:cap, cut: royalty.cut, description:royalty.description)])
227					}
228
229					return royalties
230			}
231
232			return Redeemables.resolveContractView(resourceType: Type<@Redeemables.NFT>(), viewType: view)
233		}
234	}
235
236	access(all) resource interface RedeemablesCollectionPublic {
237        access(all) fun deposit(token: @{NonFungibleToken.NFT})
238        access(all) fun getIDs(): [UInt64]
239        access(all) view fun borrowNFT(_ id: UInt64): &{NonFungibleToken.NFT}?
240        access(all) fun borrowRedeemable(id: UInt64): &Redeemables.NFT? {
241            post {
242                (result == nil) || (result?.id == id):
243                    "Cannot borrow RedeemNFT reference: the ID of the returned reference is incorrect"
244            }
245        }
246        access(NonFungibleToken.Withdraw) fun redeem(id: UInt64)
247        access(all) fun burnUnredeemedSet(set: Redeemables.Set)
248    }
249
250	access(all) resource Collection: RedeemablesCollectionPublic, NonFungibleToken.Collection {
251		access(all) var ownedNFTs: @{UInt64: {NonFungibleToken.NFT}}
252
253		init () {
254			self.ownedNFTs <- {}
255		}
256
257		access(NonFungibleToken.Withdraw) fun withdraw(withdrawID: UInt64): @{NonFungibleToken.NFT} {
258			let token <- (self.ownedNFTs.remove(key: withdrawID) ?? panic("missing NFT")) as! @NFT
259
260			assert(!token.getSet().isRedeemLimitExceeded(), message: "Set redeem limit timestamp reached")
261
262			return <-token
263		}
264
265		access(all) fun deposit(token: @{NonFungibleToken.NFT}) {
266			let token <- token as! @NFT
267
268			assert(!token.getSet().isRedeemLimitExceeded(), message: "Set redeem limit timestamp reached")
269
270			let set = token.getSet()
271			set.addOwnerRecord(self.owner!.address)
272			Redeemables.sets[set.id] = set
273
274			let id: UInt64 = token.id
275
276			let oldToken <- self.ownedNFTs[id] <- token
277
278			destroy oldToken
279		}
280
281		access(all) view fun getIDs(): [UInt64] {
282			return self.ownedNFTs.keys
283		}
284
285	  	access(all) view fun borrowNFT(_ id: UInt64): &{NonFungibleToken.NFT}? {
286            return &self.ownedNFTs[id]
287        }
288
289		access(all) fun borrowRedeemable(id: UInt64) : &Redeemables.NFT? {
290			if self.ownedNFTs[id] != nil {
291                return &self.ownedNFTs[id] as &{NonFungibleToken.NFT}? as! &Redeemables.NFT? 
292            }
293            return nil
294		}
295
296        access(all) view fun getLength(): Int {
297            return self.ownedNFTs.keys.length
298        }
299
300        access(all) view fun getSupportedNFTTypes(): {Type: Bool} {
301            let supportedTypes: {Type: Bool} = {}
302            supportedTypes[Type<@Redeemables.NFT>()] = true
303            return supportedTypes
304        }
305
306        access(all) view fun isSupportedNFTType(type: Type): Bool {
307           if type == Type<@Redeemables.NFT>() {
308            return true
309           } else {
310            return false
311           }
312        }
313
314		access(all) view fun borrowViewResolver(id: UInt64): &{ViewResolver.Resolver}? {
315			if let nft = &self.ownedNFTs[id] as &{NonFungibleToken.NFT}? as! &Redeemables.NFT? {
316                return nft as &{ViewResolver.Resolver}
317            }
318            return nil
319		}
320
321		access(NonFungibleToken.Withdraw) fun redeem(id: UInt64) {
322			let nft <- (self.ownedNFTs.remove(key: id) ?? panic("missing NFT")) as! @NFT
323			let template = nft.getTemplate()
324			let set = template.getSet()
325
326			assert(set.canRedeem, message: "Set not available to redeem: ".concat(set.name))
327			assert(!set.isRedeemLimitExceeded(), message: "Set redeem limit timestamp reached: ".concat(set.name))
328
329			emit Redeemed(id: id, address: self.owner!.address, setId: set.id, templateId: template.id, name: template.name)
330			emit Burned(id: id, address: self.owner!.address, setId: set.id, templateId: template.id, name: template.name)
331
332			destroy nft
333		}
334
335		access(all) fun burnUnredeemedSet(set: Redeemables.Set) {
336			assert(set.isRedeemLimitExceeded(), message: "Set redeem limit timestamp not reached: ".concat(set.name))
337
338			let ids = self.ownedNFTs.keys
339			for id in ids {
340				let nft = self.borrowRedeemable(id: id)!
341				let template = nft.getTemplate()
342				if template.getSet().id == set.id {
343					emit Burned(id: id, address: self.owner!.address, setId: set.id, templateId: template.id, name: template.name)
344					destroy <- self.ownedNFTs.remove(key: id)!
345				}
346			}
347		}
348
349		access(all) fun createEmptyCollection(): @{NonFungibleToken.Collection} {
350            return <-Redeemables.createEmptyCollection(nftType: Type<@Redeemables.NFT>())
351        }
352	}
353
354	access(all) fun createEmptyCollection(nftType: Type): @{NonFungibleToken.Collection} {
355		return <- create Collection()
356	}
357
358	access(all) resource Admin {}
359
360	access(account) fun createSet(name: String, canRedeem: Bool, redeemLimitTimestamp: UFix64, active: Bool) {
361		let set = Set(name: name, canRedeem: canRedeem, redeemLimitTimestamp: redeemLimitTimestamp, active: active)
362		emit SetCreated(id: set.id, name: set.name)
363		Redeemables.sets[set.id] = set
364	}
365
366	access(account) fun updateSetActive(setId: UInt64, active: Bool) {
367		pre {
368			Redeemables.sets.containsKey(setId) : "Set does not exist. Id: ".concat(setId.toString())
369		}
370		let set = Redeemables.sets[setId]!
371		set.setActive(active)
372		Redeemables.sets[setId] = set
373	}
374
375	access(account) fun updateSetCanRedeem(setId: UInt64, canRedeem: Bool) {
376		pre {
377			Redeemables.sets.containsKey(setId) : "Set does not exist. Id: ".concat(setId.toString())
378		}
379		let set = Redeemables.sets[setId]!
380		set.setCanRedeem(canRedeem)
381		Redeemables.sets[setId] = set
382	}
383
384	access(account) fun updateSetRedeemLimitTimestamp(setId: UInt64, redeemLimitTimestamp: UFix64) {
385		pre {
386			Redeemables.sets.containsKey(setId) : "Set does not exist. Id: ".concat(setId.toString())
387		}
388		let set = Redeemables.sets[setId]!
389		set.setRedeemLimitTimestamp(redeemLimitTimestamp)
390		Redeemables.sets[setId] = set
391	}
392
393	access(account) fun createTemplate(setId: UInt64, name: String, description: String, brand: String, royalties: [MetadataViews.Royalty], type: String, thumbnail: MetadataViews.Media, image: MetadataViews.Media, active: Bool) {
394		pre {
395			Redeemables.sets.containsKey(setId) : "Set does not exist. Id: ".concat(setId.toString())
396		}
397		let template = Template(setId: setId, name: name, description: description, brand: brand, royalties: royalties, type: type, thumbnail: thumbnail, image: image, active: active)
398		emit TemplateCreated(id: template.id, setId: setId, name: name)
399		Redeemables.templates[template.id] = template
400	}
401
402	access(account) fun updateTemplateActive(templateId: UInt64, active: Bool) {
403		pre {
404			Redeemables.templates.containsKey(templateId) : "Template does not exist. Id: ".concat(templateId.toString())
405		}
406		let template = Redeemables.templates[templateId]!
407		template.setActive(active)
408		Redeemables.templates[templateId] = template
409	}
410	
411	access(account) fun mintNFT(recipient: &{NonFungibleToken.Receiver}, templateId: UInt64){
412		pre {
413			recipient.owner != nil : "Recipients NFT collection is not owned"
414			Redeemables.templates.containsKey(templateId) : "Template does not exist. Id: ".concat(templateId.toString())
415		}
416
417		let template = Redeemables.templates[templateId] ?? panic("Template does not exist. Id: ".concat(templateId.toString()))
418		let set = Redeemables.sets[template.setId] ?? panic("Set does not exist. Id: ".concat(template.setId.toString()))
419
420		assert(!set.isRedeemLimitExceeded(), message: "Set redeem limit timestamp reached: ".concat(set.name))
421		assert(set.active, message: "Set not active: ".concat(set.name))
422		assert(template.active, message: "Template not active: ".concat(template.name))
423
424		var newNFT <- create NFT(templateId: templateId)
425		
426		emit Minted(id: newNFT.id, address:recipient.owner!.address, setId: set.id, templateId: template.id, name: template.name)
427
428		recipient.deposit(token: <-newNFT)
429	}
430
431	access(account) fun burnUnredeemedSet(setId: UInt64) {
432		let set = Redeemables.sets[setId] ?? panic("Set does not exist. Id: ".concat(setId.toString()))
433
434		assert(set.isRedeemLimitExceeded(), message: "Set redeem limit timestamp not reached: ".concat(set.name))
435
436		let addresses = set.ownersRecord
437
438		for address in addresses {
439			let collection =
440				getAccount(address).capabilities.get<&{Redeemables.RedeemablesCollectionPublic}>(Redeemables.CollectionPublicPath)!.borrow()
441			if collection != nil {
442				collection!.burnUnredeemedSet(set: set)
443			}
444		}
445	}
446
447	access(all) view fun getContractViews(resourceType: Type?): [Type] {
448        return [
449            Type<MetadataViews.NFTCollectionDisplay>(),
450            Type<MetadataViews.NFTCollectionData>()
451        ]
452    }
453
454    access(all) fun resolveContractView(resourceType: Type?, viewType: Type): AnyStruct? {
455        switch viewType {
456            case Type<MetadataViews.NFTCollectionDisplay>():
457				return MetadataViews.NFTCollectionDisplay(
458					name: "Redeemables",
459					description: "Doodles 2 lets anyone create a uniquely personalized and endlessly customizable character in a one-of-a-kind style. Wearables and other collectibles can easily be bought, traded, or sold. Doodles 2 will also incorporate collaborative releases with top brands in fashion, music, sports, gaming, and more. Redeemables are a part of the Doodles ecosystem that will allow you to turn in this NFT within a particular period of time to receive a physical collectible.",
460					externalURL: MetadataViews.ExternalURL("https://doodles.app"),
461					squareImage: MetadataViews.Media(file: MetadataViews.IPFSFile(cid: "QmVpAiutpnzp3zR4q2cUedMxsZd8h5HDeyxs9x3HibsnJb", path: nil), mediaType:"image/png"),
462					bannerImage: MetadataViews.Media(file: MetadataViews.IPFSFile(cid: "QmVoTikzygffMaPcacyTjF8mQ71Eg3zsMF4p4fbsAtGQmQ", path: nil), mediaType:"image/png"),
463					socials: {
464						"instagram": MetadataViews.ExternalURL("https://www.instagram.com/thedoodles"),
465						"discord": MetadataViews.ExternalURL("https://discord.gg/doodles"),
466						"twitter": MetadataViews.ExternalURL("https://twitter.com/doodles")
467					}
468				)
469			case Type<MetadataViews.NFTCollectionData>():
470				return MetadataViews.NFTCollectionData(
471					storagePath: Redeemables.CollectionStoragePath,
472					publicPath: Redeemables.CollectionPublicPath,
473					publicCollection: Type<&Redeemables.Collection>(),
474					publicLinkedType: Type<&Redeemables.Collection>(),
475					createEmptyCollectionFunction: (fun(): @{NonFungibleToken.Collection} {
476						return <- Redeemables.createEmptyCollection(nftType: Type<@Redeemables.NFT>())
477					})
478				)
479        }
480        return nil
481    }
482
483	init() {
484		self.totalSupply = 0
485
486		self.sets = {}
487		self.templates = {}
488		self.templateNextSerialNumber = {}
489		self.extra = {}
490
491		self.CollectionStoragePath = /storage/redeemables
492		self.CollectionPublicPath = /public/redeemabless
493
494		self.AdminStoragePath = /storage/redeemablesAdmin
495
496		self.account.storage.save<@{NonFungibleToken.Collection}>(<- Redeemables.createEmptyCollection(nftType: Type<@Redeemables.NFT>()), to: Redeemables.CollectionStoragePath)
497		let cap = self.account.capabilities.storage.issue<&Redeemables.Collection>(Redeemables.CollectionStoragePath)
498		self.account.capabilities.publish(cap, at: Redeemables.CollectionPublicPath)
499
500        self.account.storage.save(<-create Admin(), to: self.AdminStoragePath)
501
502		emit ContractInitialized()
503	}
504}
505