Smart Contract
D3SKOfferNFT
A.5ec90e3dcf0067c4.D3SKOfferNFT
1// D3SKOfferNFT.cdc — NFT-based zero-custody offer positions
2// Each offer is minted as a transferable NFT with on-chain SVG certificate.
3// Settlement is atomic. Payout goes to the current NFT holder, not the original maker.
4// Supports optional expiration via Flow's native Scheduled Transactions.
5// Protocol fee on fills — configurable by admin, destined for DAO governance.
6
7import FungibleToken from 0xf233dcee88fe0abe
8import NonFungibleToken from 0x1d7e57aa55817448
9import MetadataViews from 0x1d7e57aa55817448
10import ViewResolver from 0x1d7e57aa55817448
11import FlowTransactionScheduler from 0xe467b9dd11fa00df
12import D3SKRegistry from 0x5ec90e3dcf0067c4
13
14access(all) contract D3SKOfferNFT: NonFungibleToken {
15
16 // ── Entitlements ──────────────────────────────────────────────
17 access(all) entitlement Fill
18 access(all) entitlement Cancel
19 access(all) entitlement Admin
20
21 // ── Events ────────────────────────────────────────────────────
22 access(all) event OfferMinted(
23 id: UInt64,
24 serialNumber: UInt64,
25 maker: Address,
26 sellType: String,
27 sellAmount: UFix64,
28 askType: String,
29 askAmount: UFix64,
30 expiresAt: UFix64?
31 )
32 access(all) event OfferFilled(
33 id: UInt64,
34 holder: Address,
35 taker: Address,
36 sellType: String,
37 sellAmount: UFix64,
38 askType: String,
39 askAmount: UFix64,
40 feeAmount: UFix64
41 )
42 access(all) event OfferCancelled(
43 id: UInt64,
44 holder: Address,
45 sellType: String,
46 sellAmount: UFix64
47 )
48 access(all) event OfferExpired(
49 id: UInt64,
50 holder: Address,
51 sellType: String,
52 sellAmount: UFix64
53 )
54 access(all) event FeeCollected(
55 offerId: UInt64,
56 tokenType: String,
57 amount: UFix64
58 )
59 access(all) event FeeRateUpdated(oldRate: UFix64, newRate: UFix64)
60 access(all) event TreasuryAddressUpdated(oldAddress: Address, newAddress: Address)
61
62 // ── State ─────────────────────────────────────────────────────
63 access(all) var totalSupply: UInt64
64 access(all) var feeRate: UFix64
65 access(all) var treasuryAddress: Address
66
67 // ── Storage Paths ─────────────────────────────────────────────
68 access(all) let CollectionStoragePath: StoragePath
69 access(all) let CollectionPublicPath: PublicPath
70 access(all) let AdminStoragePath: StoragePath
71
72 // ── Custom Metadata: Certificate SVG ──────────────────────────
73 access(all) struct CertificateSVG {
74 access(all) let svg: String
75 init(svg: String) {
76 self.svg = svg
77 }
78 }
79
80 // ── Offer Details Struct ──────────────────────────────────────
81 access(all) struct OfferDetails {
82 access(all) let id: UInt64
83 access(all) let serialNumber: UInt64
84 access(all) let maker: Address
85 access(all) let sellType: String
86 access(all) let sellAmount: UFix64
87 access(all) let askType: String
88 access(all) let askAmount: UFix64
89 access(all) let createdAt: UFix64
90 access(all) let expiresAt: UFix64?
91 access(all) let status: UInt8
92 access(all) let isActive: Bool
93 access(all) let isExpired: Bool
94
95 init(
96 id: UInt64, serialNumber: UInt64, maker: Address,
97 sellType: String, sellAmount: UFix64,
98 askType: String, askAmount: UFix64,
99 createdAt: UFix64, expiresAt: UFix64?,
100 status: UInt8, isActive: Bool, isExpired: Bool
101 ) {
102 self.id = id
103 self.serialNumber = serialNumber
104 self.maker = maker
105 self.sellType = sellType
106 self.sellAmount = sellAmount
107 self.askType = askType
108 self.askAmount = askAmount
109 self.createdAt = createdAt
110 self.expiresAt = expiresAt
111 self.status = status
112 self.isActive = isActive
113 self.isExpired = isExpired
114 }
115 }
116
117 // ── Helper: Extract token name from type identifier ───────────
118 // e.g. "A.1654653399040a61.FlowToken.Vault" → "FlowToken"
119 access(all) view fun extractTokenName(typeId: String): String {
120 let parts = typeId.split(separator: ".")
121 // Type identifiers: A.{addr}.{ContractName}.Vault → parts[2] is the contract name
122 if parts.length >= 3 {
123 return parts[2]
124 }
125 return typeId
126 }
127
128 // ── Numeric → String helpers (view-safe, from StreamVest patterns) ──
129
130 /// Convert a UFix64 to an integer string (truncates decimals).
131 access(all) view fun ufix64ToIntString(_ value: UFix64): String {
132 let str = value.toString()
133 let parts = str.split(separator: ".")
134 if parts.length > 0 {
135 return parts[0]
136 }
137 return "0"
138 }
139
140 /// Convert a UFix64 to a decimal string with up to `places` decimals.
141 access(all) view fun ufix64ToDecimalString(_ value: UFix64, _ places: Int): String {
142 let str = value.toString()
143 let parts = str.split(separator: ".")
144 var intPart = "0"
145 var decPart = ""
146
147 if parts.length > 0 {
148 intPart = parts[0]
149 }
150 if parts.length > 1 {
151 decPart = parts[1]
152 }
153
154 if decPart.length > places {
155 decPart = decPart.slice(from: 0, upTo: places)
156 } else {
157 while decPart.length < places {
158 decPart = decPart.concat("0")
159 }
160 }
161
162 if places == 0 {
163 return intPart
164 }
165 return intPart.concat(".").concat(decPart)
166 }
167
168 // ══════════════════════════════════════════════════════════════
169 // NFT RESOURCE
170 // ══════════════════════════════════════════════════════════════
171 access(all) resource NFT: NonFungibleToken.NFT {
172 access(all) let id: UInt64
173 access(all) let serialNumber: UInt64
174 access(all) let maker: Address
175 access(all) let sellTokenType: Type
176 access(all) let sellTokenName: String
177 access(all) let askTokenType: Type
178 access(all) let askTokenName: String
179 access(all) let askAmount: UFix64
180 access(all) let createdAt: UFix64
181 access(all) let expiresAt: UFix64?
182
183 // Mutable — updated by Collection methods (same contract)
184 access(contract) var status: UInt8 // 0=active, 1=filled, 2=cancelled, 3=expired
185 access(contract) var sellVault: @{FungibleToken.Vault}?
186
187 init(
188 id: UInt64,
189 serialNumber: UInt64,
190 maker: Address,
191 sellVault: @{FungibleToken.Vault},
192 askTokenType: Type,
193 askAmount: UFix64,
194 expiresAt: UFix64?
195 ) {
196 self.id = id
197 self.serialNumber = serialNumber
198 self.maker = maker
199 self.sellTokenType = sellVault.getType()
200 self.sellTokenName = D3SKOfferNFT.extractTokenName(typeId: sellVault.getType().identifier)
201 self.askTokenType = askTokenType
202 self.askTokenName = D3SKOfferNFT.extractTokenName(typeId: askTokenType.identifier)
203 self.askAmount = askAmount
204 self.createdAt = getCurrentBlock().timestamp
205 self.expiresAt = expiresAt
206 self.status = 0
207 self.sellVault <- sellVault
208 }
209
210 // ── Fill (contract-internal, called by Collection) ────────
211 access(contract) fun fill(
212 payment: @{FungibleToken.Vault},
213 holderAddress: Address,
214 takerAddress: Address,
215 askReceiverPath: PublicPath,
216 askStoragePath: StoragePath
217 ): @{FungibleToken.Vault} {
218 pre {
219 self.status == 0: "Offer is not active"
220 self.sellVault != nil: "Offer already consumed"
221 payment.getType() == self.askTokenType: "Payment token type mismatch"
222 self.expiresAt == nil || getCurrentBlock().timestamp <= self.expiresAt!: "Offer has expired"
223 }
224
225 let feeAmount = self.askAmount * D3SKOfferNFT.feeRate
226 let totalRequired = self.askAmount + feeAmount
227 assert(
228 payment.balance >= totalRequired,
229 message: "Payment must cover ask + fee (".concat(totalRequired.toString()).concat(" required)")
230 )
231
232 let sellAmount = self.sellVault?.balance ?? 0.0
233 let sellTokens <- self.sellVault <- nil
234
235 // ── Pay the HOLDER (not maker) ──
236 let holderPayment <- payment.withdraw(amount: self.askAmount)
237 let holderAccount = getAccount(holderAddress)
238 let receiverRef = holderAccount.capabilities.borrow<&{FungibleToken.Receiver}>(
239 askReceiverPath
240 ) ?? panic("Could not borrow holder's receiver for ask token")
241 receiverRef.deposit(from: <-holderPayment)
242
243 // ── Fee to treasury (auto-initializes vault if needed) ──
244 let actualFee = payment.balance
245 if actualFee > 0.0 {
246 // Auto-initialize treasury vault for this token if it doesn't exist yet.
247 // This works because the contract lives on the treasury account and has
248 // self.account access to its own storage.
249 if D3SKOfferNFT.account.storage.borrow<&{FungibleToken.Receiver}>(from: askStoragePath) == nil {
250 let emptyVault <- payment.createEmptyVault()
251 D3SKOfferNFT.account.storage.save(<-emptyVault, to: askStoragePath)
252 let cap = D3SKOfferNFT.account.capabilities.storage.issue<&{FungibleToken.Receiver}>(askStoragePath)
253 D3SKOfferNFT.account.capabilities.publish(cap, at: askReceiverPath)
254 }
255
256 let treasuryReceiver = getAccount(D3SKOfferNFT.treasuryAddress)
257 .capabilities.borrow<&{FungibleToken.Receiver}>(askReceiverPath)
258 ?? panic("Could not borrow treasury receiver")
259
260 emit FeeCollected(offerId: self.id, tokenType: self.askTokenType.identifier, amount: actualFee)
261 treasuryReceiver.deposit(from: <-payment)
262 } else {
263 destroy payment
264 }
265
266 self.status = 1 // filled
267
268 emit OfferFilled(
269 id: self.id, holder: holderAddress, taker: takerAddress,
270 sellType: self.sellTokenType.identifier, sellAmount: sellAmount,
271 askType: self.askTokenType.identifier, askAmount: self.askAmount,
272 feeAmount: actualFee
273 )
274 D3SKRegistry.removeOffer(id: self.id, reason: "filled")
275
276 return <-sellTokens!
277 }
278
279 // ── Cancel (contract-internal, called by Collection) ──────
280 access(contract) fun cancel(): @{FungibleToken.Vault} {
281 pre {
282 self.status == 0: "Offer is not active"
283 self.sellVault != nil: "Offer already consumed"
284 }
285 let sellAmount = self.sellVault?.balance!
286 let vault <- self.sellVault <- nil
287 self.status = 2 // cancelled
288
289 emit OfferCancelled(
290 id: self.id, holder: self.maker,
291 sellType: self.sellTokenType.identifier, sellAmount: sellAmount
292 )
293 D3SKRegistry.removeOffer(id: self.id, reason: "cancelled")
294 return <-vault!
295 }
296
297 // ── Expire (contract-internal, called by Collection) ──────
298 access(contract) fun expire(): @{FungibleToken.Vault} {
299 pre {
300 self.status == 0: "Offer is not active"
301 self.sellVault != nil: "Offer already consumed"
302 self.expiresAt != nil: "Offer has no expiration"
303 getCurrentBlock().timestamp >= self.expiresAt!: "Not expired yet"
304 }
305 let sellAmount = self.sellVault?.balance!
306 let vault <- self.sellVault <- nil
307 self.status = 3 // expired
308
309 emit OfferExpired(
310 id: self.id, holder: self.maker,
311 sellType: self.sellTokenType.identifier, sellAmount: sellAmount
312 )
313 D3SKRegistry.removeOffer(id: self.id, reason: "expired")
314 return <-vault!
315 }
316
317 // ── Getters ───────────────────────────────────────────────
318 access(all) view fun getSellAmount(): UFix64 {
319 return self.sellVault?.balance ?? 0.0
320 }
321
322 access(all) view fun isActive(): Bool {
323 return self.status == 0 && self.sellVault != nil
324 }
325
326 access(all) view fun isExpiredCheck(): Bool {
327 if self.expiresAt == nil { return false }
328 return getCurrentBlock().timestamp >= self.expiresAt!
329 }
330
331 access(all) fun getDetails(): OfferDetails {
332 return OfferDetails(
333 id: self.id, serialNumber: self.serialNumber, maker: self.maker,
334 sellType: self.sellTokenType.identifier,
335 sellAmount: self.getSellAmount(),
336 askType: self.askTokenType.identifier,
337 askAmount: self.askAmount,
338 createdAt: self.createdAt, expiresAt: self.expiresAt,
339 status: self.status,
340 isActive: self.isActive(),
341 isExpired: self.isExpiredCheck()
342 )
343 }
344
345 // ── MetadataViews ─────────────────────────────────────────
346 access(all) view fun getViews(): [Type] {
347 return [
348 Type<MetadataViews.Display>(),
349 Type<MetadataViews.Serial>(),
350 Type<MetadataViews.Traits>(),
351 Type<MetadataViews.ExternalURL>(),
352 Type<MetadataViews.NFTCollectionData>(),
353 Type<MetadataViews.NFTCollectionDisplay>(),
354 Type<D3SKOfferNFT.CertificateSVG>()
355 ]
356 }
357
358 access(all) fun resolveView(_ view: Type): AnyStruct? {
359 switch view {
360 case Type<MetadataViews.Display>():
361 // Generate SVG and embed as data URI (proven pattern from StreamVest)
362 let svg = self.generateCertificateSVG()
363 let dataURI = "data:image/svg+xml;utf8,".concat(svg)
364 var statusText = "ACTIVE"
365 if self.status == 1 { statusText = "FILLED" }
366 else if self.status == 2 { statusText = "CANCELLED" }
367 else if self.status == 3 { statusText = "EXPIRED" }
368 return MetadataViews.Display(
369 name: "D3SK Offer #".concat(self.serialNumber.toString()),
370 description: self.sellTokenName.concat(" -> ").concat(self.askTokenName)
371 .concat(" | ").concat(D3SKOfferNFT.ufix64ToDecimalString(self.askAmount, 4))
372 .concat(" ").concat(self.askTokenName)
373 .concat(" | ").concat(statusText),
374 thumbnail: MetadataViews.HTTPFile(url: dataURI)
375 )
376 case Type<MetadataViews.Serial>():
377 return MetadataViews.Serial(self.serialNumber)
378 case Type<MetadataViews.Traits>():
379 let traits: [MetadataViews.Trait] = [
380 MetadataViews.Trait(name: "sellToken", value: self.sellTokenName, displayType: nil, rarity: nil),
381 MetadataViews.Trait(name: "askToken", value: self.askTokenName, displayType: nil, rarity: nil),
382 MetadataViews.Trait(name: "sellAmount", value: self.getSellAmount(), displayType: nil, rarity: nil),
383 MetadataViews.Trait(name: "askAmount", value: self.askAmount, displayType: nil, rarity: nil),
384 MetadataViews.Trait(name: "status", value: self.status, displayType: nil, rarity: nil),
385 MetadataViews.Trait(name: "maker", value: self.maker, displayType: nil, rarity: nil)
386 ]
387 return MetadataViews.Traits(traits)
388 case Type<MetadataViews.ExternalURL>():
389 return MetadataViews.ExternalURL("https://d3sk.exchange")
390 case Type<MetadataViews.NFTCollectionData>():
391 return D3SKOfferNFT.resolveContractView(
392 resourceType: Type<@D3SKOfferNFT.NFT>(),
393 viewType: Type<MetadataViews.NFTCollectionData>()
394 )
395 case Type<MetadataViews.NFTCollectionDisplay>():
396 return D3SKOfferNFT.resolveContractView(
397 resourceType: Type<@D3SKOfferNFT.NFT>(),
398 viewType: Type<MetadataViews.NFTCollectionDisplay>()
399 )
400 case Type<D3SKOfferNFT.CertificateSVG>():
401 return D3SKOfferNFT.CertificateSVG(svg: self.generateCertificateSVG())
402 }
403 return nil
404 }
405
406 // ── On-Chain SVG Certificate Generation ───────────────────
407 // Generates a fully on-chain 8-bit stock certificate SVG.
408 // Uses rgb() colors (no # chars) for safe data URI embedding.
409 // Number formatting via contract-level helpers (StreamVest pattern).
410 access(all) fun createEmptyCollection(): @{NonFungibleToken.Collection} {
411 return <- D3SKOfferNFT.createEmptyCollection(nftType: Type<@D3SKOfferNFT.NFT>())
412 }
413
414 access(all) view fun generateCertificateSVG(): String {
415 let sellAmount = self.getSellAmount()
416
417 // Status display
418 var statusText = "ACTIVE"
419 var statusFill = "rgb(0,40,0)"
420 var statusStroke = "rgb(0,255,65)"
421 if self.status == 1 {
422 statusText = "FILLED"
423 statusFill = "rgb(40,35,0)"
424 statusStroke = "rgb(255,215,0)"
425 } else if self.status == 2 {
426 statusText = "CANCELLED"
427 statusFill = "rgb(40,0,0)"
428 statusStroke = "rgb(255,0,85)"
429 } else if self.status == 3 {
430 statusText = "EXPIRED"
431 statusFill = "rgb(30,30,30)"
432 statusStroke = "rgb(128,128,128)"
433 }
434
435 // Price ratio (formatted with 4 decimals)
436 var priceDisplay = "N/A"
437 if sellAmount > 0.0 {
438 priceDisplay = D3SKOfferNFT.ufix64ToDecimalString(self.askAmount / sellAmount, 4)
439 }
440
441 // Formatted amounts (clean, no trailing zeros spam)
442 let sellAmountStr = D3SKOfferNFT.ufix64ToDecimalString(sellAmount, 4)
443 let askAmountStr = D3SKOfferNFT.ufix64ToDecimalString(self.askAmount, 4)
444
445 // Truncated maker address
446 let addr = self.maker.toString()
447 var addrShort = addr
448 if addr.length > 10 {
449 addrShort = addr.slice(from: 0, upTo: 6).concat("..").concat(
450 addr.slice(from: addr.length - 4, upTo: addr.length)
451 )
452 }
453
454 // Formatted timestamps
455 let createdStr = D3SKOfferNFT.ufix64ToIntString(self.createdAt)
456
457 // ── Build SVG ──
458 var s = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 560' shape-rendering='crispEdges'>"
459
460 // Background
461 s = s.concat("<rect width='400' height='560' fill='rgb(15,14,23)'/>")
462
463 // Outer border (gold)
464 s = s.concat("<rect x='8' y='8' width='384' height='544' fill='none' stroke='rgb(255,215,0)' stroke-width='4'/>")
465
466 // Inner border (muted)
467 s = s.concat("<rect x='16' y='16' width='368' height='528' fill='none' stroke='rgb(61,61,92)' stroke-width='2'/>")
468
469 // Corner decorations
470 s = s.concat("<rect x='10' y='10' width='8' height='8' fill='rgb(255,215,0)'/>")
471 s = s.concat("<rect x='382' y='10' width='8' height='8' fill='rgb(255,215,0)'/>")
472 s = s.concat("<rect x='10' y='542' width='8' height='8' fill='rgb(255,215,0)'/>")
473 s = s.concat("<rect x='382' y='542' width='8' height='8' fill='rgb(255,215,0)'/>")
474
475 // Header: D3SK EXCHANGE
476 s = s.concat("<text x='200' y='56' font-family='monospace' font-size='20' font-weight='bold' fill='rgb(255,215,0)' text-anchor='middle'>D3SK EXCHANGE</text>")
477
478 // Decorative lines
479 s = s.concat("<rect x='40' y='68' width='320' height='2' fill='rgb(255,215,0)'/>")
480 s = s.concat("<rect x='40' y='72' width='320' height='1' fill='rgb(61,61,92)'/>")
481
482 // OFFER CERTIFICATE #serial
483 s = s.concat("<text x='200' y='100' font-family='monospace' font-size='12' fill='rgb(0,229,255)' text-anchor='middle'>OFFER CERTIFICATE</text>")
484 s = s.concat("<text x='200' y='124' font-family='monospace' font-size='22' font-weight='bold' fill='rgb(255,215,0)' text-anchor='middle'>")
485 s = s.concat("#").concat(self.serialNumber.toString())
486 s = s.concat("</text>")
487
488 // Divider
489 s = s.concat("<rect x='60' y='140' width='280' height='1' fill='rgb(61,61,92)'/>")
490
491 // Token pair: SELL -> ASK
492 s = s.concat("<text x='200' y='174' font-family='monospace' font-size='18' font-weight='bold' fill='rgb(0,255,65)' text-anchor='middle'>")
493 s = s.concat(self.sellTokenName)
494 s = s.concat("</text>")
495 s = s.concat("<text x='200' y='198' font-family='monospace' font-size='14' fill='rgb(0,229,255)' text-anchor='middle'>")
496 s = s.concat("---> ").concat(self.askTokenName)
497 s = s.concat("</text>")
498
499 // Selling amount (formatted)
500 s = s.concat("<text x='40' y='240' font-family='monospace' font-size='11' fill='rgb(128,128,128)'>SELLING</text>")
501 s = s.concat("<text x='40' y='260' font-family='monospace' font-size='16' fill='rgb(0,255,65)'>")
502 s = s.concat(sellAmountStr).concat(" ").concat(self.sellTokenName)
503 s = s.concat("</text>")
504
505 // Asking amount (formatted)
506 s = s.concat("<text x='40' y='296' font-family='monospace' font-size='11' fill='rgb(128,128,128)'>ASKING</text>")
507 s = s.concat("<text x='40' y='316' font-family='monospace' font-size='16' fill='rgb(0,229,255)'>")
508 s = s.concat(askAmountStr).concat(" ").concat(self.askTokenName)
509 s = s.concat("</text>")
510
511 // Price ratio (formatted)
512 s = s.concat("<text x='40' y='352' font-family='monospace' font-size='11' fill='rgb(128,128,128)'>PRICE</text>")
513 s = s.concat("<text x='40' y='372' font-family='monospace' font-size='14' fill='rgb(255,215,0)'>")
514 s = s.concat(priceDisplay).concat(" ").concat(self.askTokenName).concat("/").concat(self.sellTokenName)
515 s = s.concat("</text>")
516
517 // Divider
518 s = s.concat("<rect x='60' y='390' width='280' height='1' fill='rgb(61,61,92)'/>")
519
520 // Status badge
521 s = s.concat("<rect x='140' y='406' width='120' height='32' rx='2' fill='").concat(statusFill).concat("' stroke='").concat(statusStroke).concat("' stroke-width='2'/>")
522 s = s.concat("<text x='200' y='427' font-family='monospace' font-size='14' font-weight='bold' fill='").concat(statusStroke).concat("' text-anchor='middle'>")
523 s = s.concat(statusText)
524 s = s.concat("</text>")
525
526 // Dates (integer timestamps, no decimal noise)
527 s = s.concat("<text x='40' y='468' font-family='monospace' font-size='10' fill='rgb(128,128,128)'>CREATED: ").concat(createdStr).concat("</text>")
528 if self.expiresAt != nil {
529 s = s.concat("<text x='40' y='484' font-family='monospace' font-size='10' fill='rgb(128,128,128)'>EXPIRES: ").concat(D3SKOfferNFT.ufix64ToIntString(self.expiresAt!)).concat("</text>")
530 } else {
531 s = s.concat("<text x='40' y='484' font-family='monospace' font-size='10' fill='rgb(128,128,128)'>EXPIRES: NEVER</text>")
532 }
533
534 // Maker address
535 s = s.concat("<text x='40' y='508' font-family='monospace' font-size='10' fill='rgb(128,128,128)'>MAKER: ").concat(addrShort).concat("</text>")
536
537 // Decorative seal (bottom-right pixel rosette)
538 s = s.concat("<rect x='320' y='480' width='40' height='40' rx='20' fill='none' stroke='rgb(255,215,0)' stroke-width='2'/>")
539 s = s.concat("<text x='340' y='505' font-family='monospace' font-size='10' font-weight='bold' fill='rgb(255,215,0)' text-anchor='middle'>D3SK</text>")
540
541 // Pixel noise decoration (top corners)
542 s = s.concat("<rect x='28' y='28' width='4' height='4' fill='rgb(0,255,65)' opacity='0.3'/>")
543 s = s.concat("<rect x='36' y='32' width='4' height='4' fill='rgb(0,229,255)' opacity='0.2'/>")
544 s = s.concat("<rect x='364' y='28' width='4' height='4' fill='rgb(255,0,85)' opacity='0.3'/>")
545 s = s.concat("<rect x='356' y='32' width='4' height='4' fill='rgb(255,215,0)' opacity='0.2'/>")
546
547 s = s.concat("</svg>")
548 return s
549 }
550 }
551
552 // ══════════════════════════════════════════════════════════════
553 // OFFER COLLECTION PUBLIC INTERFACE
554 // ══════════════════════════════════════════════════════════════
555 access(all) resource interface OfferCollectionPublic {
556 access(all) fun getOfferIDs(): [UInt64]
557 access(all) fun getOfferDetails(id: UInt64): OfferDetails?
558 access(Fill) fun fillOffer(
559 id: UInt64,
560 payment: @{FungibleToken.Vault},
561 holderAddress: Address,
562 takerAddress: Address,
563 askReceiverPath: PublicPath,
564 askStoragePath: StoragePath
565 ): @{FungibleToken.Vault}
566 }
567
568 // ══════════════════════════════════════════════════════════════
569 // COLLECTION RESOURCE
570 // ══════════════════════════════════════════════════════════════
571 access(all) resource Collection: NonFungibleToken.Collection, OfferCollectionPublic {
572 access(all) var ownedNFTs: @{UInt64: {NonFungibleToken.NFT}}
573
574 init() {
575 self.ownedNFTs <- {}
576 }
577
578 // ── Standard NFT Collection Methods ───────────────────────
579
580 access(all) view fun getSupportedNFTTypes(): {Type: Bool} {
581 return { Type<@D3SKOfferNFT.NFT>(): true }
582 }
583
584 access(all) view fun isSupportedNFTType(type: Type): Bool {
585 return type == Type<@D3SKOfferNFT.NFT>()
586 }
587
588 access(NonFungibleToken.Withdraw) fun withdraw(withdrawID: UInt64): @{NonFungibleToken.NFT} {
589 let nft <- self.ownedNFTs.remove(key: withdrawID)
590 ?? panic("NFT not found in collection")
591 return <-nft
592 }
593
594 access(all) fun deposit(token: @{NonFungibleToken.NFT}) {
595 let nft <- token as! @D3SKOfferNFT.NFT
596 let id = nft.id
597 let oldNFT <- self.ownedNFTs[id] <- nft
598 destroy oldNFT
599 }
600
601 access(all) view fun getIDs(): [UInt64] {
602 return self.ownedNFTs.keys
603 }
604
605 access(all) view fun getLength(): Int {
606 return self.ownedNFTs.length
607 }
608
609 access(all) view fun borrowNFT(_ id: UInt64): &{NonFungibleToken.NFT}? {
610 return &self.ownedNFTs[id]
611 }
612
613 access(all) fun createEmptyCollection(): @{NonFungibleToken.Collection} {
614 return <- D3SKOfferNFT.createEmptyCollection(nftType: Type<@D3SKOfferNFT.NFT>())
615 }
616
617 // ── Custom: Borrow typed reference ────────────────────────
618 access(all) fun borrowD3SKOfferNFT(id: UInt64): &D3SKOfferNFT.NFT? {
619 if self.ownedNFTs[id] != nil {
620 let ref = (&self.ownedNFTs[id] as &{NonFungibleToken.NFT}?)!
621 return ref as! &D3SKOfferNFT.NFT
622 }
623 return nil
624 }
625
626 // ── Offer-specific: Get IDs (alias for getIDs) ───────────
627 access(all) fun getOfferIDs(): [UInt64] {
628 return self.ownedNFTs.keys
629 }
630
631 // ── Offer-specific: Get details ───────────────────────────
632 access(all) fun getOfferDetails(id: UInt64): OfferDetails? {
633 if let nftRef = self.borrowD3SKOfferNFT(id: id) {
634 return nftRef.getDetails()
635 }
636 return nil
637 }
638
639 // ── Fill an offer (requires Fill entitlement) ──
640 // Payment always routes to offer maker (prevents redirect attacks)
641 access(Fill) fun fillOffer(
642 id: UInt64,
643 payment: @{FungibleToken.Vault},
644 holderAddress: Address,
645 takerAddress: Address,
646 askReceiverPath: PublicPath,
647 askStoragePath: StoragePath
648 ): @{FungibleToken.Vault} {
649 // Remove NFT to get ownership (needed to call contract-internal methods)
650 let nft <- self.ownedNFTs.remove(key: id)
651 ?? panic("Offer not found")
652 let offerNFT <- nft as! @D3SKOfferNFT.NFT
653
654 // V1 security: enforce payment goes to the offer maker, not arbitrary address
655 assert(holderAddress == offerNFT.maker, message: "Payment must route to offer maker")
656
657 // Execute fill — pays the holder, returns sell tokens to taker
658 let sellTokens <- offerNFT.fill(
659 payment: <-payment,
660 holderAddress: offerNFT.maker,
661 takerAddress: takerAddress,
662 askReceiverPath: askReceiverPath,
663 askStoragePath: askStoragePath
664 )
665
666 // Put NFT back (now status=filled, sellVault=nil, acts as receipt)
667 let old <- self.ownedNFTs[offerNFT.id] <- offerNFT
668 destroy old
669
670 return <-sellTokens
671 }
672
673 // ── Public fill wrapper (no entitlement needed) ──────────────
674 // Callable via non-auth &Collection capability.
675 // Delegates to access(Fill) fillOffer via self (which has all entitlements).
676 access(all) fun fillOfferPublic(
677 id: UInt64,
678 payment: @{FungibleToken.Vault},
679 holderAddress: Address,
680 takerAddress: Address,
681 askReceiverPath: PublicPath,
682 askStoragePath: StoragePath
683 ): @{FungibleToken.Vault} {
684 return <-self.fillOffer(
685 id: id,
686 payment: <-payment,
687 holderAddress: holderAddress,
688 takerAddress: takerAddress,
689 askReceiverPath: askReceiverPath,
690 askStoragePath: askStoragePath
691 )
692 }
693
694 // ── Cancel an offer (holder only via Cancel entitlement) ──
695 access(Cancel) fun cancelOffer(id: UInt64): @{FungibleToken.Vault} {
696 let nft <- self.ownedNFTs.remove(key: id)
697 ?? panic("Offer not found")
698 let offerNFT <- nft as! @D3SKOfferNFT.NFT
699
700 let vault <- offerNFT.cancel()
701
702 // Put NFT back as cancelled receipt
703 let old <- self.ownedNFTs[offerNFT.id] <- offerNFT
704 destroy old
705
706 return <-vault
707 }
708
709 // ── Expire an offer (for scheduled handler) ───────────────
710 access(Cancel) fun expireOffer(id: UInt64): @{FungibleToken.Vault} {
711 let nft <- self.ownedNFTs.remove(key: id)
712 ?? panic("Offer not found")
713 let offerNFT <- nft as! @D3SKOfferNFT.NFT
714
715 let vault <- offerNFT.expire()
716
717 // Put NFT back as expired receipt
718 let old <- self.ownedNFTs[offerNFT.id] <- offerNFT
719 destroy old
720
721 return <-vault
722 }
723 }
724
725 // ══════════════════════════════════════════════════════════════
726 // ADMINISTRATOR
727 // ══════════════════════════════════════════════════════════════
728 access(all) resource Administrator {
729 access(Admin) fun setFeeRate(newRate: UFix64) {
730 pre { newRate <= 0.05: "Fee rate cannot exceed 5%" }
731 let oldRate = D3SKOfferNFT.feeRate
732 D3SKOfferNFT.feeRate = newRate
733 emit FeeRateUpdated(oldRate: oldRate, newRate: newRate)
734 }
735
736 access(Admin) fun setTreasuryAddress(newAddress: Address) {
737 let oldAddress = D3SKOfferNFT.treasuryAddress
738 D3SKOfferNFT.treasuryAddress = newAddress
739 emit TreasuryAddressUpdated(oldAddress: oldAddress, newAddress: newAddress)
740 }
741 }
742
743 // ══════════════════════════════════════════════════════════════
744 // EXPIRATION HANDLER
745 // ══════════════════════════════════════════════════════════════
746 access(all) resource OfferExpirationHandler: FlowTransactionScheduler.TransactionHandler {
747 access(all) let offerID: UInt64
748 access(all) let holderAddress: Address
749 access(self) let collectionCapability: Capability<auth(D3SKOfferNFT.Cancel) &D3SKOfferNFT.Collection>
750 access(self) let receiverCapability: Capability<&{FungibleToken.Receiver}>
751
752 init(
753 offerID: UInt64,
754 holderAddress: Address,
755 collectionCapability: Capability<auth(D3SKOfferNFT.Cancel) &D3SKOfferNFT.Collection>,
756 receiverCapability: Capability<&{FungibleToken.Receiver}>
757 ) {
758 self.offerID = offerID
759 self.holderAddress = holderAddress
760 self.collectionCapability = collectionCapability
761 self.receiverCapability = receiverCapability
762 }
763
764 access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) {
765 let collectionRef = self.collectionCapability.borrow()
766 if collectionRef == nil { return }
767
768 let details = collectionRef!.getOfferDetails(id: self.offerID)
769 if details == nil { return }
770 if !details!.isActive { return }
771 if !details!.isExpired { return }
772
773 let returnedVault <- collectionRef!.expireOffer(id: self.offerID)
774
775 let receiverRef = self.receiverCapability.borrow()
776 ?? panic("Cannot borrow receiver to return expired tokens")
777 receiverRef.deposit(from: <-returnedVault)
778 }
779 }
780
781 // ══════════════════════════════════════════════════════════════
782 // CONTRACT-LEVEL FUNCTIONS
783 // ══════════════════════════════════════════════════════════════
784
785 // Mint a new offer NFT
786 access(all) fun mintOffer(
787 sellVault: @{FungibleToken.Vault},
788 askTokenType: Type,
789 askAmount: UFix64,
790 makerAddress: Address,
791 expiresAt: UFix64?
792 ): @NFT {
793 pre {
794 sellVault.balance > 0.0: "Sell amount must be positive"
795 askAmount > 0.0: "Ask amount must be positive"
796 expiresAt == nil || expiresAt! > getCurrentBlock().timestamp: "Expiration must be in the future"
797 }
798
799 let id = self.totalSupply
800 self.totalSupply = self.totalSupply + 1
801
802 let sellType = sellVault.getType().identifier
803 let sellAmount = sellVault.balance
804
805 let nft <- create NFT(
806 id: id,
807 serialNumber: id,
808 maker: makerAddress,
809 sellVault: <-sellVault,
810 askTokenType: askTokenType,
811 askAmount: askAmount,
812 expiresAt: expiresAt
813 )
814
815 // Register in the discovery index
816 D3SKRegistry.registerOffer(
817 id: id,
818 maker: makerAddress,
819 sellType: sellType,
820 sellAmount: sellAmount,
821 askType: askTokenType.identifier,
822 askAmount: askAmount,
823 expiresAt: expiresAt
824 )
825
826 emit OfferMinted(
827 id: id, serialNumber: id, maker: makerAddress,
828 sellType: sellType, sellAmount: sellAmount,
829 askType: askTokenType.identifier, askAmount: askAmount,
830 expiresAt: expiresAt
831 )
832
833 return <-nft
834 }
835
836 // Create empty collection (required by NonFungibleToken)
837 access(all) fun createEmptyCollection(nftType: Type): @{NonFungibleToken.Collection} {
838 return <- create Collection()
839 }
840
841 // Create expiration handler for scheduling
842 access(all) fun createExpirationHandler(
843 offerID: UInt64,
844 holderAddress: Address,
845 collectionCapability: Capability<auth(D3SKOfferNFT.Cancel) &D3SKOfferNFT.Collection>,
846 receiverCapability: Capability<&{FungibleToken.Receiver}>
847 ): @OfferExpirationHandler {
848 return <-create OfferExpirationHandler(
849 offerID: offerID,
850 holderAddress: holderAddress,
851 collectionCapability: collectionCapability,
852 receiverCapability: receiverCapability
853 )
854 }
855
856 // Fee helpers
857 access(all) fun getRequiredPayment(askAmount: UFix64): UFix64 {
858 return askAmount + (askAmount * self.feeRate)
859 }
860
861 access(all) fun getFeeAmount(askAmount: UFix64): UFix64 {
862 return askAmount * self.feeRate
863 }
864
865 // Contract-level views (required by NonFungibleToken / ViewResolver)
866 access(all) view fun getContractViews(resourceType: Type?): [Type] {
867 return [
868 Type<MetadataViews.NFTCollectionData>(),
869 Type<MetadataViews.NFTCollectionDisplay>()
870 ]
871 }
872
873 access(all) fun resolveContractView(resourceType: Type?, viewType: Type): AnyStruct? {
874 switch viewType {
875 case Type<MetadataViews.NFTCollectionData>():
876 return MetadataViews.NFTCollectionData(
877 storagePath: self.CollectionStoragePath,
878 publicPath: self.CollectionPublicPath,
879 publicCollection: Type<&D3SKOfferNFT.Collection>(),
880 publicLinkedType: Type<&D3SKOfferNFT.Collection>(),
881 createEmptyCollectionFunction: fun(): @{NonFungibleToken.Collection} {
882 return <- D3SKOfferNFT.createEmptyCollection(nftType: Type<@D3SKOfferNFT.NFT>())
883 }
884 )
885 case Type<MetadataViews.NFTCollectionDisplay>():
886 return MetadataViews.NFTCollectionDisplay(
887 name: "D3SK Offer Positions",
888 description: "Zero-custody P2P limit order NFTs with fully on-chain SVG certificates. Trade any token pair on Flow.",
889 externalURL: MetadataViews.ExternalURL("https://d3sk.exchange"),
890 squareImage: MetadataViews.Media(
891 file: MetadataViews.HTTPFile(url: "https://d3sk.exchange/d3sk-square.png"),
892 mediaType: "image/png"
893 ),
894 bannerImage: MetadataViews.Media(
895 file: MetadataViews.HTTPFile(url: "https://d3sk.exchange/d3sk-banner.png"),
896 mediaType: "image/png"
897 ),
898 socials: {}
899 )
900 }
901 return nil
902 }
903
904 // ── Contract Init ─────────────────────────────────────────────
905 init() {
906 self.totalSupply = 0
907 self.feeRate = 0.003 // 0.3% protocol fee
908 self.treasuryAddress = self.account.address
909
910 self.CollectionStoragePath = /storage/D3SKOfferCollection
911 self.CollectionPublicPath = /public/D3SKOfferCollection
912 self.AdminStoragePath = /storage/D3SKAdmin
913
914 let admin <- create Administrator()
915 self.account.storage.save(<-admin, to: self.AdminStoragePath)
916 }
917}
918