Smart Contract
SemesterZeroV2
A.ce9dd43888d99574.SemesterZeroV2
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