Smart Contract
SemesterZeroV3
A.ce9dd43888d99574.SemesterZeroV3
1import FungibleToken from 0xf233dcee88fe0abe
2import NonFungibleToken from 0x1d7e57aa55817448
3import MetadataViews from 0x1d7e57aa55817448
4import ViewResolver from 0x1d7e57aa55817448
5
6/// SemesterZeroV3 - Production-ready Flunks: Semester Zero NFT Collection
7///
8/// FEATURES:
9/// ✅ Pin evolution: Base → Silver → Gold → Special
10/// ✅ Patch evolution: Base → Retro → Punk → Nerdy
11/// ✅ GUM cost tracking for each evolution tier (stored in Admin, updatable)
12/// ✅ Location-based NFTs (Paradise Motel, Crystal Springs, etc.)
13/// ✅ Custom metadata at mint with evolution/reveal capabilities
14/// ✅ Traits revealed and locked during first evolution
15/// ✅ Full MetadataViews support for marketplaces
16/// ✅ Royalties (10% to Flunks)
17/// ✅ Admin minting and evolution controls
18/// ✅ NFT burning capability
19/// ✅ Clean codebase - NO legacy baggage
20///
21/// V3.1: Fixed duplicate traits - November 2025
22access(all) contract SemesterZeroV3: NonFungibleToken, ViewResolver {
23
24 // ========================================
25 // PATHS
26 // ========================================
27
28 access(all) let CollectionStoragePath: StoragePath
29 access(all) let CollectionPublicPath: PublicPath
30 access(all) let AdminStoragePath: StoragePath
31
32 // ========================================
33 // EVENTS
34 // ========================================
35
36 access(all) event ContractInitialized()
37 access(all) event NFTMinted(nftID: UInt64, recipient: Address, nftType: String, location: String, serialNumber: UInt64, timestamp: UFix64)
38 access(all) event NFTEvolved(nftID: UInt64, owner: Address, oldTier: String, newTier: String, timestamp: UFix64)
39 access(all) event NFTBurned(nftID: UInt64, owner: Address, timestamp: UFix64)
40 access(all) event Withdraw(id: UInt64, from: Address?)
41 access(all) event Deposit(id: UInt64, to: Address?)
42 access(all) event EvolutionCostsUpdated(nftType: String, costs: {String: UFix64})
43
44 // ========================================
45 // STATE VARIABLES
46 // ========================================
47
48 access(all) var totalSupply: UInt64
49
50 // ========================================
51 // NFT RESOURCE
52 // ========================================
53
54 access(all) resource NFT: NonFungibleToken.NFT {
55 access(all) let id: UInt64
56 access(all) let nftType: String
57 access(all) let location: String
58 access(all) let recipient: Address
59 access(all) let mintedAt: UFix64
60 access(all) let serialNumber: UInt64
61 access(all) var metadata: {String: String}
62 access(all) var evolutionTier: String
63 access(all) var traitsLocked: Bool
64
65 init(
66 id: UInt64,
67 recipient: Address,
68 serialNumber: UInt64,
69 nftType: String,
70 location: String,
71 initialMetadata: {String: String}
72 ) {
73 self.id = id
74 self.nftType = nftType
75 self.location = location
76 self.recipient = recipient
77 self.mintedAt = getCurrentBlock().timestamp
78 self.serialNumber = serialNumber
79 self.metadata = initialMetadata
80 self.evolutionTier = "Base"
81 self.traitsLocked = false
82 }
83
84 access(contract) fun evolve(newMetadata: {String: String}, newTier: String) {
85 self.metadata = newMetadata
86 self.evolutionTier = newTier
87
88 if !self.traitsLocked {
89 self.traitsLocked = true
90 }
91 }
92
93 access(all) view fun getViews(): [Type] {
94 return [
95 Type<MetadataViews.Display>(),
96 Type<MetadataViews.NFTCollectionData>(),
97 Type<MetadataViews.NFTCollectionDisplay>(),
98 Type<MetadataViews.Royalties>(),
99 Type<MetadataViews.ExternalURL>(),
100 Type<MetadataViews.Serial>(),
101 Type<MetadataViews.Traits>()
102 ]
103 }
104
105 access(all) fun resolveView(_ view: Type): AnyStruct? {
106 switch view {
107 case Type<MetadataViews.Display>():
108 return MetadataViews.Display(
109 name: self.metadata["name"] ?? "Semester Zero NFT",
110 description: self.metadata["description"] ?? "A collectible from Flunks: Semester Zero",
111 thumbnail: MetadataViews.HTTPFile(url: self.metadata["image"] ?? "")
112 )
113
114 case Type<MetadataViews.NFTCollectionData>():
115 return SemesterZeroV3.resolveContractView(resourceType: nil, viewType: Type<MetadataViews.NFTCollectionData>())
116
117 case Type<MetadataViews.NFTCollectionDisplay>():
118 return SemesterZeroV3.resolveContractView(resourceType: nil, viewType: Type<MetadataViews.NFTCollectionDisplay>())
119
120 case Type<MetadataViews.Royalties>():
121 let royaltyCap = SemesterZeroV3.getRoyaltyReceiverCapability()
122 if !royaltyCap.check() {
123 return MetadataViews.Royalties([])
124 }
125 return MetadataViews.Royalties([
126 MetadataViews.Royalty(
127 receiver: royaltyCap,
128 cut: 0.10,
129 description: "Flunks: Semester Zero creator royalty"
130 )
131 ])
132
133 case Type<MetadataViews.ExternalURL>():
134 return MetadataViews.ExternalURL("https://flunks.net")
135
136 case Type<MetadataViews.Serial>():
137 return MetadataViews.Serial(self.serialNumber)
138
139 case Type<MetadataViews.Traits>():
140 let traits: [MetadataViews.Trait] = []
141
142 // Keys to exclude from metadata loop (we add them from resource fields)
143 let excludeKeys: [String] = ["name", "description", "image", "nftType", "location", "evolutionTier", "mintedAt"]
144
145 // Add metadata traits (excluding display fields and core traits)
146 for key in self.metadata.keys {
147 var skip = false
148 for excludeKey in excludeKeys {
149 if key == excludeKey {
150 skip = true
151 break
152 }
153 }
154 if !skip {
155 traits.append(MetadataViews.Trait(
156 name: key,
157 value: self.metadata[key]!,
158 displayType: "String",
159 rarity: nil
160 ))
161 }
162 }
163
164 // Add core traits from resource fields (single source of truth)
165 traits.append(MetadataViews.Trait(name: "nftType", value: self.nftType, displayType: "String", rarity: nil))
166 traits.append(MetadataViews.Trait(name: "location", value: self.location, displayType: "String", rarity: nil))
167 traits.append(MetadataViews.Trait(name: "evolutionTier", value: self.evolutionTier, displayType: "String", rarity: nil))
168 // traitsLocked hidden from display
169
170 return MetadataViews.Traits(traits)
171 }
172 return nil
173 }
174
175 access(all) fun createEmptyCollection(): @{NonFungibleToken.Collection} {
176 return <-SemesterZeroV3.createEmptyCollection(nftType: Type<@SemesterZeroV3.NFT>())
177 }
178 }
179
180 // ========================================
181 // NFT COLLECTION
182 // ========================================
183
184 access(all) resource Collection: NonFungibleToken.Collection, ViewResolver.ResolverCollection {
185 access(all) var ownedNFTs: @{UInt64: {NonFungibleToken.NFT}}
186
187 init() {
188 self.ownedNFTs <- {}
189 }
190
191 access(all) view fun getLength(): Int {
192 return self.ownedNFTs.length
193 }
194
195 access(all) view fun getIDs(): [UInt64] {
196 return self.ownedNFTs.keys
197 }
198
199 access(all) view fun borrowNFT(_ id: UInt64): &{NonFungibleToken.NFT}? {
200 return &self.ownedNFTs[id]
201 }
202
203 access(NonFungibleToken.Withdraw) fun withdraw(withdrawID: UInt64): @{NonFungibleToken.NFT} {
204 let token <- self.ownedNFTs.remove(key: withdrawID)
205 ?? panic("NFT not found in collection")
206 let nft <- token as! @SemesterZeroV3.NFT
207 emit Withdraw(id: nft.id, from: self.owner?.address)
208 return <-nft
209 }
210
211 access(all) fun deposit(token: @{NonFungibleToken.NFT}) {
212 let nft <- token as! @SemesterZeroV3.NFT
213 let id = nft.id
214 let oldToken <- self.ownedNFTs[id] <- nft
215 destroy oldToken
216 emit Deposit(id: id, to: self.owner?.address)
217 }
218
219 access(all) view fun getSupportedNFTTypes(): {Type: Bool} {
220 return {Type<@SemesterZeroV3.NFT>(): true}
221 }
222
223 access(all) view fun isSupportedNFTType(type: Type): Bool {
224 return type == Type<@SemesterZeroV3.NFT>()
225 }
226
227 access(all) fun createEmptyCollection(): @{NonFungibleToken.Collection} {
228 return <-SemesterZeroV3.createEmptyCollection(nftType: Type<@SemesterZeroV3.NFT>())
229 }
230
231 access(all) view fun borrowSemesterZeroNFT(id: UInt64): &NFT? {
232 if self.ownedNFTs[id] != nil {
233 let ref = &self.ownedNFTs[id] as &{NonFungibleToken.NFT}?
234 return ref as! &NFT?
235 }
236 return nil
237 }
238
239 access(all) view fun borrowViewResolver(id: UInt64): &{ViewResolver.Resolver}? {
240 if let nft = &self.ownedNFTs[id] as &{NonFungibleToken.NFT}? {
241 return nft as &{ViewResolver.Resolver}
242 }
243 return nil
244 }
245 }
246
247 // ========================================
248 // ADMIN RESOURCE
249 // ========================================
250
251 access(all) resource Admin {
252
253 access(all) var evolutionCosts: {String: UFix64}
254
255 init() {
256 self.evolutionCosts = {
257 "Pin_Silver": 250.0,
258 "Pin_Gold": 500.0,
259 "Pin_Special": 750.0,
260 "Patch_Retro": 500.0,
261 "Patch_Punk": 250.0,
262 "Patch_Nerdy": 250.0
263 }
264 }
265
266 access(all) fun mintNFT(
267 recipientAddress: Address,
268 nftType: String,
269 location: String,
270 metadata: {String: String}
271 ) {
272 pre {
273 nftType == "Pin" || nftType == "Patch": "NFT type must be 'Pin' or 'Patch'"
274 metadata["name"] != nil: "Metadata must include 'name'"
275 metadata["description"] != nil: "Metadata must include 'description'"
276 metadata["image"] != nil: "Metadata must include 'image'"
277 }
278
279 let recipientCap = getAccount(recipientAddress)
280 .capabilities.get<&SemesterZeroV3.Collection>(SemesterZeroV3.CollectionPublicPath)
281
282 assert(recipientCap.check(), message: "Recipient does not have SemesterZeroV3 collection set up")
283
284 let recipient = recipientCap.borrow()!
285
286 let nftID = SemesterZeroV3.totalSupply
287 let serialNumber = SemesterZeroV3.totalSupply + 1
288
289 var fullMetadata = metadata
290 fullMetadata["nftType"] = nftType
291 fullMetadata["location"] = location
292 fullMetadata["serialNumber"] = serialNumber.toString()
293 fullMetadata["evolutionTier"] = "Base"
294 fullMetadata["collection"] = "Flunks: Semester Zero"
295 fullMetadata["mintedAt"] = getCurrentBlock().timestamp.toString()
296
297 let nft <- create NFT(
298 id: nftID,
299 recipient: recipientAddress,
300 serialNumber: serialNumber,
301 nftType: nftType,
302 location: location,
303 initialMetadata: fullMetadata
304 )
305
306 SemesterZeroV3.totalSupply = SemesterZeroV3.totalSupply + 1
307
308 recipient.deposit(token: <-nft)
309
310 emit NFTMinted(
311 nftID: nftID,
312 recipient: recipientAddress,
313 nftType: nftType,
314 location: location,
315 serialNumber: serialNumber,
316 timestamp: getCurrentBlock().timestamp
317 )
318 }
319
320 access(all) fun evolveNFT(
321 userAddress: Address,
322 nftID: UInt64,
323 newTier: String,
324 newMetadata: {String: String}
325 ) {
326 pre {
327 newTier == "Silver" || newTier == "Gold" || newTier == "Special" ||
328 newTier == "Retro" || newTier == "Punk" || newTier == "Nerdy":
329 "Invalid evolution tier. Must be: Silver, Gold, Special, Retro, Punk, or Nerdy"
330 newMetadata["image"] != nil: "New metadata must include updated 'image'"
331 }
332
333 let collectionRef = getAccount(userAddress)
334 .capabilities.get<&SemesterZeroV3.Collection>(SemesterZeroV3.CollectionPublicPath)
335 .borrow()
336 ?? panic("User does not have SemesterZeroV3 collection")
337
338 let nftRef = collectionRef.borrowSemesterZeroNFT(id: nftID)
339 ?? panic("NFT not found in collection")
340
341 let oldTier = nftRef.evolutionTier
342
343 var fullMetadata = newMetadata
344 fullMetadata["evolutionTier"] = newTier
345 fullMetadata["evolvedAt"] = getCurrentBlock().timestamp.toString()
346
347 nftRef.evolve(newMetadata: fullMetadata, newTier: newTier)
348
349 emit NFTEvolved(
350 nftID: nftID,
351 owner: userAddress,
352 oldTier: oldTier,
353 newTier: newTier,
354 timestamp: getCurrentBlock().timestamp
355 )
356 }
357
358 access(all) fun updatePinCosts(silverCost: UFix64, goldCost: UFix64, specialCost: UFix64) {
359 self.evolutionCosts["Pin_Silver"] = silverCost
360 self.evolutionCosts["Pin_Gold"] = goldCost
361 self.evolutionCosts["Pin_Special"] = specialCost
362
363 emit EvolutionCostsUpdated(nftType: "Pin", costs: {
364 "Silver": silverCost,
365 "Gold": goldCost,
366 "Special": specialCost
367 })
368 }
369
370 access(all) fun updatePatchCosts(retroCost: UFix64, punkCost: UFix64, nerdyCost: UFix64) {
371 self.evolutionCosts["Patch_Retro"] = retroCost
372 self.evolutionCosts["Patch_Punk"] = punkCost
373 self.evolutionCosts["Patch_Nerdy"] = nerdyCost
374
375 emit EvolutionCostsUpdated(nftType: "Patch", costs: {
376 "Retro": retroCost,
377 "Punk": punkCost,
378 "Nerdy": nerdyCost
379 })
380 }
381
382 access(all) fun getEvolutionCosts(): {String: UFix64} {
383 return self.evolutionCosts
384 }
385
386 access(all) fun getEvolutionCost(nftType: String, tier: String): UFix64? {
387 let key = nftType.concat("_").concat(tier)
388 return self.evolutionCosts[key]
389 }
390
391 access(all) fun burnNFT(
392 collection: auth(NonFungibleToken.Withdraw) &Collection,
393 nftID: UInt64
394 ) {
395 assert(collection.ownedNFTs[nftID] != nil, message: "NFT does not exist in collection")
396
397 let nft <- collection.withdraw(withdrawID: nftID)
398 let ownerAddress = collection.owner!.address
399
400 emit NFTBurned(
401 nftID: nftID,
402 owner: ownerAddress,
403 timestamp: getCurrentBlock().timestamp
404 )
405
406 destroy nft
407 }
408 }
409
410 // ========================================
411 // PUBLIC CONTRACT FUNCTIONS
412 // ========================================
413
414 access(all) fun createEmptyCollection(nftType: Type): @{NonFungibleToken.Collection} {
415 return <- create Collection()
416 }
417
418 access(all) fun getRoyaltyReceiverCapability(): Capability<&{FungibleToken.Receiver}> {
419 return getAccount(0xbfffec679fff3a94)
420 .capabilities
421 .get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver)
422 }
423
424 access(all) fun getStats(): {String: AnyStruct} {
425 return {
426 "totalSupply": SemesterZeroV3.totalSupply,
427 "contractName": "SemesterZeroV3",
428 "version": "3.1.0",
429 "collectionPath": SemesterZeroV3.CollectionPublicPath.toString()
430 }
431 }
432
433 // ========================================
434 // VIEW RESOLVER (Marketplace Compatibility)
435 // ========================================
436
437 access(all) view fun getContractViews(resourceType: Type?): [Type] {
438 return [
439 Type<MetadataViews.NFTCollectionData>(),
440 Type<MetadataViews.NFTCollectionDisplay>()
441 ]
442 }
443
444 access(all) fun resolveContractView(resourceType: Type?, viewType: Type): AnyStruct? {
445 switch viewType {
446 case Type<MetadataViews.NFTCollectionData>():
447 return MetadataViews.NFTCollectionData(
448 storagePath: SemesterZeroV3.CollectionStoragePath,
449 publicPath: SemesterZeroV3.CollectionPublicPath,
450 publicCollection: Type<&SemesterZeroV3.Collection>(),
451 publicLinkedType: Type<&SemesterZeroV3.Collection>(),
452 createEmptyCollectionFunction: (fun(): @{NonFungibleToken.Collection} {
453 return <-SemesterZeroV3.createEmptyCollection(nftType: Type<@SemesterZeroV3.NFT>())
454 })
455 )
456
457 case Type<MetadataViews.NFTCollectionDisplay>():
458 let squareMedia = MetadataViews.Media(
459 file: MetadataViews.HTTPFile(
460 url: "https://storage.googleapis.com/flunks_public/images/semesterzero.png"
461 ),
462 mediaType: "image/png"
463 )
464 let bannerMedia = MetadataViews.Media(
465 file: MetadataViews.HTTPFile(
466 url: "https://storage.googleapis.com/flunks_public/images/banner.png"
467 ),
468 mediaType: "image/png"
469 )
470 return MetadataViews.NFTCollectionDisplay(
471 name: "Flunks: Semester Zero",
472 description: "Collectible Pins and Patches from Flunks: Semester Zero. Earn them by exploring locations, completing challenges, and participating in events. Evolve your NFTs with GUM to unlock new tiers and reveal hidden traits!",
473 externalURL: MetadataViews.ExternalURL("https://flunks.net"),
474 squareImage: squareMedia,
475 bannerImage: bannerMedia,
476 socials: {
477 "twitter": MetadataViews.ExternalURL("https://twitter.com/FlunksNFT"),
478 "discord": MetadataViews.ExternalURL("https://discord.gg/flunks"),
479 "website": MetadataViews.ExternalURL("https://flunks.net")
480 }
481 )
482 }
483 return nil
484 }
485
486 // ========================================
487 // INITIALIZATION
488 // ========================================
489
490 init() {
491 self.CollectionStoragePath = /storage/SemesterZeroV3Collection
492 self.CollectionPublicPath = /public/SemesterZeroV3Collection
493 self.AdminStoragePath = /storage/SemesterZeroV3Admin
494
495 self.totalSupply = 0
496
497 self.account.storage.save(<-create Admin(), to: self.AdminStoragePath)
498
499 emit ContractInitialized()
500 }
501}
502