Smart Contract

D3SKOfferNFT

A.5ec90e3dcf0067c4.D3SKOfferNFT

Valid From

142,135,713

Deployed

2w ago
Feb 12, 2026, 12:23:12 AM UTC

Dependents

28 imports
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("---&gt; ").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