Smart Contract

SemesterZeroV2

A.ce9dd43888d99574.SemesterZeroV2

Valid From

133,461,652

Deployed

6d ago
Feb 21, 2026, 07:07:25 PM UTC

Dependents

0 imports
1import FungibleToken from 0xf233dcee88fe0abe
2import NonFungibleToken from 0x1d7e57aa55817448
3import MetadataViews from 0x1d7e57aa55817448
4import ViewResolver from 0x1d7e57aa55817448
5
6/// SemesterZeroV2 - Clean implementation for Flunks: Semester Zero NFT Collection
7/// Features:
8/// - Pin evolution: Base → Silver → Gold → Special
9/// - Patch evolution: Base → Retro → Punk → Nerdy
10/// - GUM cost tracking for each evolution tier
11/// - Location-based NFTs (Paradise Motel, Crystal Springs, etc.)
12/// - Custom metadata at mint with evolution/reveal capabilities
13/// - Traits revealed and locked during first evolution
14///
15/// V2: Fresh start without legacy Chapter5/GumDrop baggage
16access(all) contract SemesterZeroV2: NonFungibleToken, ViewResolver {
17    
18    // ========================================
19    // PATHS
20    // ========================================
21    
22    access(all) let CollectionStoragePath: StoragePath
23    access(all) let CollectionPublicPath: PublicPath
24    access(all) let AdminStoragePath: StoragePath
25    
26    // ========================================
27    // EVENTS
28    // ========================================
29    
30    access(all) event ContractInitialized()
31    access(all) event NFTMinted(nftID: UInt64, recipient: Address, nftType: String, location: String, timestamp: UFix64)
32    access(all) event NFTEvolved(nftID: UInt64, owner: Address, oldTier: String, newTier: String, timestamp: UFix64)
33    access(all) event NFTBurned(nftID: UInt64, owner: Address, timestamp: UFix64)
34    access(all) event Withdraw(id: UInt64, from: Address?)
35    access(all) event Deposit(id: UInt64, to: Address?)
36    
37    // ========================================
38    // STATE VARIABLES
39    // ========================================
40    
41    access(all) var totalSupply: UInt64
42    
43    // ========================================
44    // NFT RESOURCE
45    // ========================================
46    
47    access(all) resource NFT: NonFungibleToken.NFT {
48        access(all) let id: UInt64
49        access(all) let nftType: String          // "Pin" or "Patch"
50        access(all) let location: String         // "Paradise Motel", "Crystal Springs", etc.
51        access(all) let recipient: Address
52        access(all) let mintedAt: UFix64
53        access(all) let serialNumber: UInt64
54        access(all) var metadata: {String: String}  // Mutable for evolution
55        access(all) var evolutionTier: String    // Pins: "Base", "Silver", "Gold", "Special" | Patches: "Base", "Retro", "Punk", "Nerdy"
56        access(all) var traitsLocked: Bool       // Traits get locked after first evolution
57        
58        init(id: UInt64, recipient: Address, serialNumber: UInt64, nftType: String, location: String, initialMetadata: {String: String}) {
59            self.id = id
60            self.nftType = nftType
61            self.location = location
62            self.recipient = recipient
63            self.mintedAt = getCurrentBlock().timestamp
64            self.serialNumber = serialNumber
65            self.metadata = initialMetadata
66            self.evolutionTier = "Base"
67            self.traitsLocked = false
68        }
69        
70        // Admin can evolve NFT - updates image, tier, and locks traits on first evolution
71        access(contract) fun evolve(newMetadata: {String: String}, newTier: String) {
72            self.metadata = newMetadata
73            self.evolutionTier = newTier
74            
75            // Lock traits after first evolution (traits revealed = traits locked)
76            if !self.traitsLocked {
77                self.traitsLocked = true
78            }
79        }
80        
81        access(all) view fun getViews(): [Type] {
82            return [
83                Type<MetadataViews.Display>(),
84                Type<MetadataViews.NFTCollectionData>(),
85                Type<MetadataViews.NFTCollectionDisplay>(),
86                Type<MetadataViews.Royalties>(),
87                Type<MetadataViews.ExternalURL>(),
88                Type<MetadataViews.Serial>()
89            ]
90        }
91        
92        access(all) fun resolveView(_ view: Type): AnyStruct? {
93            switch view {
94                case Type<MetadataViews.Display>():
95                    return MetadataViews.Display(
96                        name: self.metadata["name"]!,
97                        description: self.metadata["description"]!,
98                        thumbnail: MetadataViews.HTTPFile(url: self.metadata["image"]!)
99                    )
100                
101                case Type<MetadataViews.NFTCollectionData>():
102                    return SemesterZeroV2.resolveContractView(resourceType: nil, viewType: Type<MetadataViews.NFTCollectionData>())
103                
104                case Type<MetadataViews.NFTCollectionDisplay>():
105                    return SemesterZeroV2.resolveContractView(resourceType: nil, viewType: Type<MetadataViews.NFTCollectionDisplay>())
106                
107                case Type<MetadataViews.Royalties>():
108                    let royaltyCap = SemesterZeroV2.getRoyaltyReceiverCapability()
109                    if !royaltyCap.check() {
110                        return MetadataViews.Royalties([])
111                    }
112                    return MetadataViews.Royalties([
113                        MetadataViews.Royalty(
114                            receiver: royaltyCap,
115                            cut: 0.10,
116                            description: "Flunks: Semester Zero creator royalty"
117                        )
118                    ])
119
120                case Type<MetadataViews.ExternalURL>():
121                    return MetadataViews.ExternalURL("https://flunks.net")
122                
123                case Type<MetadataViews.Serial>():
124                    return MetadataViews.Serial(self.serialNumber)
125            }
126            return nil
127        }
128        
129        access(all) fun createEmptyCollection(): @{NonFungibleToken.Collection} {
130            return <-SemesterZeroV2.createEmptyCollection(nftType: Type<@SemesterZeroV2.NFT>())
131        }
132    }
133    
134    // ========================================
135    // NFT COLLECTION
136    // ========================================
137    
138    access(all) resource Collection: NonFungibleToken.Collection, ViewResolver.ResolverCollection {
139        access(all) var ownedNFTs: @{UInt64: {NonFungibleToken.NFT}}
140        
141        init() {
142            self.ownedNFTs <- {}
143        }
144        
145        access(all) view fun getLength(): Int {
146            return self.ownedNFTs.length
147        }
148        
149        access(all) view fun getIDs(): [UInt64] {
150            return self.ownedNFTs.keys
151        }
152        
153        access(all) view fun borrowNFT(_ id: UInt64): &{NonFungibleToken.NFT}? {
154            return &self.ownedNFTs[id]
155        }
156        
157        access(NonFungibleToken.Withdraw) fun withdraw(withdrawID: UInt64): @{NonFungibleToken.NFT} {
158            let token <- self.ownedNFTs.remove(key: withdrawID)
159                ?? panic("NFT not found in collection")
160            let nft <- token as! @SemesterZeroV2.NFT
161            emit Withdraw(id: nft.id, from: self.owner?.address)
162            return <-nft
163        }
164        
165        access(all) fun deposit(token: @{NonFungibleToken.NFT}) {
166            let nft <- token as! @SemesterZeroV2.NFT
167            let id = nft.id
168            let oldToken <- self.ownedNFTs[id] <- nft
169            destroy oldToken
170            emit Deposit(id: id, to: self.owner?.address)
171        }
172        
173        access(all) view fun getSupportedNFTTypes(): {Type: Bool} {
174            return {Type<@SemesterZeroV2.NFT>(): true}
175        }
176        
177        access(all) view fun isSupportedNFTType(type: Type): Bool {
178            return type == Type<@SemesterZeroV2.NFT>()
179        }
180        
181        access(all) fun createEmptyCollection(): @{NonFungibleToken.Collection} {
182            return <-SemesterZeroV2.createEmptyCollection(nftType: Type<@SemesterZeroV2.NFT>())
183        }
184        
185        // Borrow specific NFT with full access (needed for evolution function)
186        access(all) view fun borrowSemesterZeroNFT(id: UInt64): &NFT? {
187            if self.ownedNFTs[id] != nil {
188                let ref = &self.ownedNFTs[id] as &{NonFungibleToken.NFT}?
189                return ref as! &NFT?
190            }
191            return nil
192        }
193        
194        // MetadataViews.ResolverCollection - Required for Token List
195        access(all) view fun borrowViewResolver(id: UInt64): &{ViewResolver.Resolver}? {
196            if let nft = &self.ownedNFTs[id] as &{NonFungibleToken.NFT}? {
197                return nft as &{ViewResolver.Resolver}
198            }
199            return nil
200        }
201    }
202    
203    // ========================================
204    // ADMIN RESOURCE
205    // ========================================
206    
207    access(all) resource Admin {
208        
209        // GUM costs for evolution (stored in Admin resource)
210        access(all) var evolutionCosts: {String: UFix64}
211        
212        init() {
213            // Initialize with default costs
214            self.evolutionCosts = {
215                "Pin_Silver": 100.0,
216                "Pin_Gold": 250.0,
217                "Pin_Special": 500.0,
218                "Patch_Retro": 100.0,
219                "Patch_Punk": 250.0,
220                "Patch_Nerdy": 500.0
221            }
222        }
223        
224        /// Mint NFT with custom type and metadata
225        /// nftType: "Pin" or "Patch"
226        /// location: "Paradise Motel", "Crystal Springs", etc.
227        /// metadata: Must include "name", "description", "image", and any traits
228        access(all) fun mintNFT(
229            recipientAddress: Address,
230            nftType: String,
231            location: String,
232            metadata: {String: String}
233        ) {
234            pre {
235                nftType == "Pin" || nftType == "Patch": "NFT type must be Pin or Patch"
236                metadata["name"] != nil: "Metadata must include name"
237                metadata["description"] != nil: "Metadata must include description"
238                metadata["image"] != nil: "Metadata must include image"
239            }
240            
241            // Get recipient's collection capability
242            let recipientCap = getAccount(recipientAddress)
243                .capabilities.get<&SemesterZeroV2.Collection>(SemesterZeroV2.CollectionPublicPath)
244            
245            assert(recipientCap.check(), message: "Recipient does not have SemesterZeroV2 collection set up")
246            
247            let recipient = recipientCap.borrow()!
248            
249            // Mint NFT
250            let nftID = SemesterZeroV2.totalSupply
251            let serialNumber = SemesterZeroV2.totalSupply + 1
252            
253            // Ensure metadata has required fields
254            var fullMetadata = metadata
255            fullMetadata["nftType"] = nftType
256            fullMetadata["location"] = location
257            fullMetadata["serialNumber"] = serialNumber.toString()
258            fullMetadata["evolutionTier"] = "Base"
259            fullMetadata["collection"] = "Flunks: Semester Zero"
260            
261            let nft <- create NFT(
262                id: nftID,
263                recipient: recipientAddress,
264                serialNumber: serialNumber,
265                nftType: nftType,
266                location: location,
267                initialMetadata: fullMetadata
268            )
269            
270            SemesterZeroV2.totalSupply = SemesterZeroV2.totalSupply + 1
271            
272            // Deposit to recipient
273            recipient.deposit(token: <-nft)
274            
275            emit NFTMinted(
276                nftID: nftID,
277                recipient: recipientAddress,
278                nftType: nftType,
279                location: location,
280                timestamp: getCurrentBlock().timestamp
281            )
282        }
283        
284        /// Evolve an NFT
285        /// Pins: Base → Silver → Gold → Special
286        /// Patches: Base → Retro → Punk → Nerdy
287        /// newMetadata: Must include updated "image"
288        /// Traits get locked after first evolution
289        /// NOTE: GUM payment happens off-chain (Supabase) - this function just updates the NFT
290        access(all) fun evolveNFT(
291            userAddress: Address,
292            nftID: UInt64,
293            newTier: String,
294            newMetadata: {String: String}
295        ) {
296            pre {
297                newTier == "Silver" || newTier == "Gold" || newTier == "Special" || newTier == "Retro" || newTier == "Punk" || newTier == "Nerdy": "Invalid evolution tier"
298                newMetadata["image"] != nil: "New metadata must include image"
299            }
300            
301            // Get user's collection
302            let collectionRef = getAccount(userAddress)
303                .capabilities.get<&SemesterZeroV2.Collection>(SemesterZeroV2.CollectionPublicPath)
304                .borrow()
305                ?? panic("User does not have SemesterZeroV2 collection")
306            
307            // Borrow the NFT
308            let nftRef = collectionRef.borrowSemesterZeroNFT(id: nftID)
309                ?? panic("Could not borrow NFT reference")
310            
311            let oldTier = nftRef.evolutionTier
312            
313            // Update metadata to include evolution tier
314            var fullMetadata = newMetadata
315            fullMetadata["evolutionTier"] = newTier
316            
317            // Evolve the NFT
318            nftRef.evolve(newMetadata: fullMetadata, newTier: newTier)
319            
320            emit NFTEvolved(
321                nftID: nftID,
322                owner: userAddress,
323                oldTier: oldTier,
324                newTier: newTier,
325                timestamp: getCurrentBlock().timestamp
326            )
327        }
328        
329        /// Update GUM costs for evolution (admin only)
330        access(all) fun updatePinCosts(silverCost: UFix64, goldCost: UFix64, specialCost: UFix64) {
331            self.evolutionCosts["Pin_Silver"] = silverCost
332            self.evolutionCosts["Pin_Gold"] = goldCost
333            self.evolutionCosts["Pin_Special"] = specialCost
334        }
335        
336        access(all) fun updatePatchCosts(retroCost: UFix64, punkCost: UFix64, nerdyCost: UFix64) {
337            self.evolutionCosts["Patch_Retro"] = retroCost
338            self.evolutionCosts["Patch_Punk"] = punkCost
339            self.evolutionCosts["Patch_Nerdy"] = nerdyCost
340        }
341        
342        /// Get current evolution costs
343        access(all) fun getEvolutionCosts(): {String: UFix64} {
344            return self.evolutionCosts
345        }
346        
347        /// Burn (permanently destroy) an NFT from a collection
348        access(all) fun burnNFTFromCollection(collection: auth(NonFungibleToken.Withdraw) &Collection, nftID: UInt64) {
349            assert(collection.ownedNFTs[nftID] != nil, message: "NFT does not exist in collection")
350            
351            let nft <- collection.withdraw(withdrawID: nftID)
352            let ownerAddress = collection.owner!.address
353            
354            emit NFTBurned(nftID: nftID, owner: ownerAddress, timestamp: getCurrentBlock().timestamp)
355            destroy nft
356        }
357    }
358    
359    // ========================================
360    // CONTRACT FUNCTIONS
361    // ========================================
362    
363    access(all) fun createEmptyCollection(nftType: Type): @{NonFungibleToken.Collection} {
364        return <- create Collection()
365    }
366    
367    access(all) fun getRoyaltyReceiverCapability(): Capability<&{FungibleToken.Receiver}> {
368        return getAccount(0xbfffec679fff3a94)
369            .capabilities
370            .get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver)
371    }
372    
373    // Contract-level view resolver for marketplace compatibility
374    access(all) view fun getContractViews(resourceType: Type?): [Type] {
375        return [
376            Type<MetadataViews.NFTCollectionData>(),
377            Type<MetadataViews.NFTCollectionDisplay>()
378        ]
379    }
380    
381    access(all) fun resolveContractView(resourceType: Type?, viewType: Type): AnyStruct? {
382        switch viewType {
383            case Type<MetadataViews.NFTCollectionData>():
384                return MetadataViews.NFTCollectionData(
385                    storagePath: SemesterZeroV2.CollectionStoragePath,
386                    publicPath: SemesterZeroV2.CollectionPublicPath,
387                    publicCollection: Type<&SemesterZeroV2.Collection>(),
388                    publicLinkedType: Type<&SemesterZeroV2.Collection>(),
389                    createEmptyCollectionFunction: (fun(): @{NonFungibleToken.Collection} {
390                        return <-SemesterZeroV2.createEmptyCollection(nftType: Type<@SemesterZeroV2.NFT>())
391                    })
392                )
393            
394            case Type<MetadataViews.NFTCollectionDisplay>():
395                let squareMedia = MetadataViews.Media(
396                    file: MetadataViews.HTTPFile(url: "https://storage.googleapis.com/flunks_public/branding/flunks-logo-square.png"),
397                    mediaType: "image/png"
398                )
399                let bannerMedia = MetadataViews.Media(
400                    file: MetadataViews.HTTPFile(url: "https://storage.googleapis.com/flunks_public/branding/flunks-banner.png"),
401                    mediaType: "image/png"
402                )
403                return MetadataViews.NFTCollectionDisplay(
404                    name: "Flunks: Semester Zero",
405                    description: "Collectible Pins and Patches from your journey through Flunks: Semester Zero. Evolve your NFTs as you progress!",
406                    externalURL: MetadataViews.ExternalURL("https://flunks.net"),
407                    squareImage: squareMedia,
408                    bannerImage: bannerMedia,
409                    socials: {
410                        "twitter": MetadataViews.ExternalURL("https://twitter.com/FlunksNFT"),
411                        "discord": MetadataViews.ExternalURL("https://discord.gg/flunks")
412                    }
413                )
414        }
415        return nil
416    }
417    
418    // ========================================
419    // INIT
420    // ========================================
421    
422    init() {
423        // Set paths
424        self.CollectionStoragePath = /storage/SemesterZeroV2Collection
425        self.CollectionPublicPath = /public/SemesterZeroV2Collection
426        self.AdminStoragePath = /storage/SemesterZeroV2Admin
427        
428        // Initialize state
429        self.totalSupply = 0
430        
431        // Create and store Admin resource
432        self.account.storage.save(<-create Admin(), to: self.AdminStoragePath)
433        
434        emit ContractInitialized()
435    }
436}
437