Smart Contract

StreamVest

A.5ec90e3dcf0067c4.StreamVest

Valid From

141,781,817

Deployed

2w ago
Feb 10, 2026, 05:29:08 PM UTC

Dependents

6 imports
1// StreamVest.cdc
2// A streaming vesting NFT primitive on Flow.
3//
4// Each StreamVest NFT literally contains a vault of fungible tokens and
5// autonomously streams them to a destination address over time using
6// Flow's scheduled-transaction infrastructure — zero off-chain deps.
7
8import NonFungibleToken from 0x1d7e57aa55817448
9import ViewResolver from 0x1d7e57aa55817448
10import MetadataViews from 0x1d7e57aa55817448
11import FungibleToken from 0xf233dcee88fe0abe
12import FlowToken from 0x1654653399040a61
13
14// Uncomment when running on an emulator/network that supports scheduled txs:
15// import "FlowTransactionScheduler"
16// import "FlowTransactionSchedulerUtils"
17
18access(all) contract StreamVest: NonFungibleToken, ViewResolver {
19
20    // ───────── Paths ─────────
21    access(all) let CollectionStoragePath: StoragePath
22    access(all) let CollectionPublicPath:  PublicPath
23    access(all) let MinterStoragePath:     StoragePath
24
25    // ───────── State ─────────
26    access(all) var totalSupply: UInt64
27
28    // ───────── Events ─────────
29    access(all) event ContractInitialized()
30    access(all) event Withdraw(id: UInt64, from: Address?)
31    access(all) event Deposit(id: UInt64, to: Address?)
32    access(all) event StreamMinted(
33        id: UInt64,
34        amount: UFix64,
35        destination: Address,
36        startTime: UFix64,
37        endTime: UFix64,
38        streamRate: UFix64
39    )
40    access(all) event TokensStreamed(id: UInt64, amount: UFix64, totalStreamed: UFix64)
41    access(all) event StreamCompleted(id: UInt64, totalStreamed: UFix64)
42    access(all) event StreamCancelled(id: UInt64, returnedAmount: UFix64)
43    access(all) event DestinationUpdated(id: UInt64, newDestination: Address)
44
45    // ───────── Entitlements ─────────
46    access(all) entitlement StreamOwner    // owner-facing mutations
47    access(all) entitlement StreamExecute  // handler / trigger streaming
48
49    // ═══════════════════════════════════════════════════════════════════
50    //  NFT Resource
51    // ═══════════════════════════════════════════════════════════════════
52    access(all) resource NFT: NonFungibleToken.NFT {
53
54        access(all) let id: UInt64
55
56        // ── Locked vault (the tokens being vested) ──
57        access(self) var lockedVault: @FlowToken.Vault
58
59        // ── Vesting parameters ──
60        access(all) let totalAmount:  UFix64
61        access(all) let startTime:    UFix64
62        access(all) let endTime:      UFix64
63        access(all) let streamRate:   UFix64   // tokens per second
64
65        // ── Mutable state ──
66        access(all) var destinationAddress: Address
67        access(all) var totalStreamed:       UFix64
68        access(all) var lastStreamTime:     UFix64
69        access(all) var isActive:           Bool
70
71        // ── Optional scheduler reference ──
72        access(all) var schedulerID: UInt64?
73
74        // ────────────────────── init ──────────────────────
75        init(
76            id: UInt64,
77            vault: @FlowToken.Vault,
78            destination: Address,
79            startTime: UFix64,
80            endTime: UFix64
81        ) {
82            pre {
83                endTime > startTime: "endTime must be after startTime"
84                vault.balance > 0.0:  "vault must contain tokens"
85            }
86            self.id                = id
87            self.totalAmount       = vault.balance
88            self.startTime         = startTime
89            self.endTime           = endTime
90            self.destinationAddress = destination
91            self.totalStreamed      = 0.0
92            self.lastStreamTime    = startTime
93            self.isActive          = true
94            self.schedulerID       = nil
95
96            let duration = endTime - startTime
97            self.streamRate = self.totalAmount / duration
98
99            self.lockedVault <- vault
100        }
101
102        // ────────────────────── View helpers ──────────────────────
103
104        /// Total amount vested up to `now` (linearly).
105        access(all) view fun calculateVestedAmount(): UFix64 {
106            let now = getCurrentBlock().timestamp
107            if now <= self.startTime { return 0.0 }
108            if now >= self.endTime   { return self.totalAmount }
109            let elapsed = now - self.startTime
110            var vested  = self.streamRate * elapsed
111            if vested > self.totalAmount { vested = self.totalAmount }
112            return vested
113        }
114
115        /// Tokens that have vested but have not yet been streamed out.
116        access(all) view fun calculateClaimable(): UFix64 {
117            let vested = self.calculateVestedAmount()
118            if vested <= self.totalStreamed { return 0.0 }
119            return vested - self.totalStreamed
120        }
121
122        /// How many tokens are still inside the vault.
123        access(all) view fun getRemainingBalance(): UFix64 {
124            return self.lockedVault.balance
125        }
126
127        /// Vesting progress as a percentage (0–100).
128        access(all) view fun getProgressPercent(): UFix64 {
129            if self.totalAmount == 0.0 { return 100.0 }
130            return (self.totalStreamed / self.totalAmount) * 100.0
131        }
132
133        /// Human-readable status string.
134        access(all) view fun getStatus(): String {
135            if !self.isActive { return "COMPLETED" }
136            let now = getCurrentBlock().timestamp
137            if now < self.startTime { return "PENDING" }
138            return "STREAMING"
139        }
140
141        // ────────────────────── Streaming logic ──────────────────────
142
143        /// Withdraw tokens that have vested since the last stream.
144        /// Callable by the Collection on behalf of the scheduler / trigger.
145        access(StreamExecute) fun withdrawVestedTokens(): @{FungibleToken.Vault}? {
146            if !self.isActive { return nil }
147
148            let now = getCurrentBlock().timestamp
149            if now <= self.startTime { return nil }
150
151            let effectiveTime = now >= self.endTime ? self.endTime : now
152            let elapsed       = effectiveTime - self.startTime
153            var vested        = self.streamRate * elapsed
154            if vested > self.totalAmount { vested = self.totalAmount }
155
156            let claimable = vested - self.totalStreamed
157            if claimable <= 0.0 { return nil }
158
159            // Guard against rounding: never withdraw more than the vault holds.
160            var withdrawAmount = claimable
161            if withdrawAmount > self.lockedVault.balance {
162                withdrawAmount = self.lockedVault.balance
163            }
164            if withdrawAmount <= 0.0 { return nil }
165
166            let tokens <- self.lockedVault.withdraw(amount: withdrawAmount)
167            self.totalStreamed  = self.totalStreamed + withdrawAmount
168            self.lastStreamTime = now
169
170            if self.totalStreamed >= self.totalAmount || self.lockedVault.balance == 0.0 {
171                self.isActive = false
172                emit StreamCompleted(id: self.id, totalStreamed: self.totalStreamed)
173            }
174
175            emit TokensStreamed(
176                id: self.id,
177                amount: withdrawAmount,
178                totalStreamed: self.totalStreamed
179            )
180            return <- tokens
181        }
182
183        // ────────────────────── Owner mutations ──────────────────────
184
185        /// Change the address that receives the streamed tokens.
186        access(StreamOwner) fun updateDestination(newAddress: Address) {
187            self.destinationAddress = newAddress
188            emit DestinationUpdated(id: self.id, newDestination: newAddress)
189        }
190
191        /// Cancel the stream and return all remaining tokens.
192        access(StreamOwner) fun cancelStream(): @FlowToken.Vault {
193            pre  { self.isActive: "Stream is not active" }
194            self.isActive = false
195            let remaining <- self.lockedVault.withdraw(amount: self.lockedVault.balance)
196            emit StreamCancelled(id: self.id, returnedAmount: remaining.balance)
197            return <- (remaining as! @FlowToken.Vault)
198        }
199
200        // ────────────────────── SVG Generation ──────────────────────
201
202        /// Produce a dynamic SVG reflecting current vesting state.
203        access(all) view fun generateSVG(): String {
204            let pct        = self.getProgressPercent()
205            let remaining  = self.getRemainingBalance()
206            let status     = self.getStatus()
207            let claimable  = self.calculateClaimable()
208
209            // Tank dimensions
210            let tankTop:    UFix64 = 70.0
211            let tankHeight: UFix64 = 180.0
212            let fillPct = (100.0 - pct) / 100.0   // inverse: full → empty
213
214            var fillHeight = tankHeight * fillPct
215            if fillHeight > tankHeight { fillHeight = tankHeight }
216            let fillY = tankTop + (tankHeight - fillHeight)
217
218            // Status colour
219            var statusColour = "#00d4ff"
220            if status == "COMPLETED" { statusColour = "#00ff88" }
221            if status == "PENDING"   { statusColour = "#ffaa00" }
222
223            // Build SVG
224            var svg = "<svg xmlns='http://www.w3.org/2000/svg' width='400' height='500' viewBox='0 0 400 500'>"
225
226            // ── defs (gradient) ──
227            svg = svg.concat("<defs>")
228            svg = svg.concat("<linearGradient id='fill' x1='0' y1='0' x2='0' y2='1'>")
229            svg = svg.concat("<stop offset='0%' stop-color='#00d4ff' stop-opacity='0.9'/>")
230            svg = svg.concat("<stop offset='100%' stop-color='#0066cc' stop-opacity='0.9'/>")
231            svg = svg.concat("</linearGradient>")
232            svg = svg.concat("<linearGradient id='bg' x1='0' y1='0' x2='0' y2='1'>")
233            svg = svg.concat("<stop offset='0%' stop-color='#0a0e27'/>")
234            svg = svg.concat("<stop offset='100%' stop-color='#121835'/>")
235            svg = svg.concat("</linearGradient>")
236            svg = svg.concat("</defs>")
237
238            // ── Background ──
239            svg = svg.concat("<rect width='400' height='500' rx='20' fill='url(#bg)'/>")
240
241            // ── Border glow ──
242            svg = svg.concat("<rect x='2' y='2' width='396' height='496' rx='19' fill='none' stroke='#1a3a5c' stroke-width='1.5'/>")
243
244            // ── Title ──
245            svg = svg.concat("<text x='200' y='38' text-anchor='middle' fill='#00d4ff' font-family='monospace' font-size='22' font-weight='bold'>STREAMVEST</text>")
246            svg = svg.concat("<text x='200' y='58' text-anchor='middle' fill='#556688' font-family='monospace' font-size='13'>#")
247            svg = svg.concat(self.id.toString())
248            svg = svg.concat("</text>")
249
250            // ── Tank outline ──
251            svg = svg.concat("<rect x='100' y='")
252            svg = svg.concat(StreamVest.ufix64ToIntString(tankTop))
253            svg = svg.concat("' width='200' height='")
254            svg = svg.concat(StreamVest.ufix64ToIntString(tankHeight))
255            svg = svg.concat("' rx='10' fill='#0d1230' stroke='#1a3a5c' stroke-width='2'/>")
256
257            // ── Fill level ──
258            if fillHeight > 0.0 {
259                svg = svg.concat("<rect x='103' y='")
260                svg = svg.concat(StreamVest.ufix64ToIntString(fillY))
261                svg = svg.concat("' width='194' height='")
262                svg = svg.concat(StreamVest.ufix64ToIntString(fillHeight))
263                svg = svg.concat("' rx='8' fill='url(#fill)' opacity='0.85'/>")
264            }
265
266            // ── Tank label (percentage remaining) ──
267            let remainPctStr = StreamVest.ufix64ToDecimalString(100.0 - pct, 1)
268            svg = svg.concat("<text x='200' y='")
269            let labelY = tankTop + tankHeight / 2.0 + 6.0
270            svg = svg.concat(StreamVest.ufix64ToIntString(labelY))
271            svg = svg.concat("' text-anchor='middle' fill='white' font-family='monospace' font-size='28' font-weight='bold' opacity='0.9'>")
272            svg = svg.concat(remainPctStr)
273            svg = svg.concat("%</text>")
274
275            // ── Stream droplets (decorative) ──
276            let dropY = tankTop + tankHeight + 10.0
277            if status == "STREAMING" {
278                svg = svg.concat("<circle cx='190' cy='")
279                svg = svg.concat(StreamVest.ufix64ToIntString(dropY))
280                svg = svg.concat("' r='3' fill='#00d4ff' opacity='0.7'/>")
281                svg = svg.concat("<circle cx='200' cy='")
282                svg = svg.concat(StreamVest.ufix64ToIntString(dropY + 8.0))
283                svg = svg.concat("' r='2.5' fill='#00d4ff' opacity='0.5'/>")
284                svg = svg.concat("<circle cx='210' cy='")
285                svg = svg.concat(StreamVest.ufix64ToIntString(dropY + 4.0))
286                svg = svg.concat("' r='2' fill='#00d4ff' opacity='0.6'/>")
287            }
288
289            // ── Stats block ──
290            let statsY = tankTop + tankHeight + 45.0
291
292            svg = svg.concat("<text x='200' y='")
293            svg = svg.concat(StreamVest.ufix64ToIntString(statsY))
294            svg = svg.concat("' text-anchor='middle' fill='white' font-family='monospace' font-size='14'>Remaining: ")
295            svg = svg.concat(StreamVest.ufix64ToDecimalString(remaining, 4))
296            svg = svg.concat(" FLOW</text>")
297
298            svg = svg.concat("<text x='200' y='")
299            svg = svg.concat(StreamVest.ufix64ToIntString(statsY + 22.0))
300            svg = svg.concat("' text-anchor='middle' fill='#8899aa' font-family='monospace' font-size='12'>Streamed: ")
301            svg = svg.concat(StreamVest.ufix64ToDecimalString(self.totalStreamed, 4))
302            svg = svg.concat(" / ")
303            svg = svg.concat(StreamVest.ufix64ToDecimalString(self.totalAmount, 4))
304            svg = svg.concat("</text>")
305
306            svg = svg.concat("<text x='200' y='")
307            svg = svg.concat(StreamVest.ufix64ToIntString(statsY + 42.0))
308            svg = svg.concat("' text-anchor='middle' fill='#00d4ff' font-family='monospace' font-size='12'>Rate: ")
309            svg = svg.concat(StreamVest.ufix64ToDecimalString(self.streamRate, 8))
310            svg = svg.concat(" FLOW/sec</text>")
311
312            // ── Progress bar ──
313            let barY = statsY + 60.0
314            svg = svg.concat("<rect x='60' y='")
315            svg = svg.concat(StreamVest.ufix64ToIntString(barY))
316            svg = svg.concat("' width='280' height='10' rx='5' fill='#0d1230' stroke='#1a3a5c' stroke-width='1'/>")
317
318            let barWidth = 280.0 * pct / 100.0
319            if barWidth > 0.0 {
320                svg = svg.concat("<rect x='60' y='")
321                svg = svg.concat(StreamVest.ufix64ToIntString(barY))
322                svg = svg.concat("' width='")
323                svg = svg.concat(StreamVest.ufix64ToIntString(barWidth))
324                svg = svg.concat("' height='10' rx='5' fill='")
325                svg = svg.concat(statusColour)
326                svg = svg.concat("'/>")
327            }
328
329            // ── Status badge ──
330            let badgeY = barY + 32.0
331            svg = svg.concat("<text x='200' y='")
332            svg = svg.concat(StreamVest.ufix64ToIntString(badgeY))
333            svg = svg.concat("' text-anchor='middle' fill='")
334            svg = svg.concat(statusColour)
335            svg = svg.concat("' font-family='monospace' font-size='14' font-weight='bold'>")
336            svg = svg.concat(status)
337            svg = svg.concat("</text>")
338
339            svg = svg.concat("</svg>")
340            return svg
341        }
342
343        // ────────────────────── MetadataViews ──────────────────────
344
345        access(all) view fun getViews(): [Type] {
346            return [
347                Type<MetadataViews.Display>(),
348                Type<MetadataViews.Traits>(),
349                Type<MetadataViews.ExternalURL>(),
350                Type<MetadataViews.NFTCollectionData>(),
351                Type<MetadataViews.NFTCollectionDisplay>()
352            ]
353        }
354
355        access(all) fun resolveView(_ view: Type): AnyStruct? {
356            switch view {
357                case Type<MetadataViews.Display>():
358                    let svg = self.generateSVG()
359                    let dataURI = "data:image/svg+xml;utf8,".concat(svg)
360                    return MetadataViews.Display(
361                        name: "StreamVest #".concat(self.id.toString()),
362                        description: "Streaming vesting NFT — "
363                            .concat(StreamVest.ufix64ToDecimalString(self.totalAmount, 4))
364                            .concat(" FLOW over ")
365                            .concat(StreamVest.ufix64ToIntString(self.endTime - self.startTime))
366                            .concat(" seconds"),
367                        thumbnail: MetadataViews.HTTPFile(url: dataURI)
368                    )
369
370                case Type<MetadataViews.Traits>():
371                    let traits: [MetadataViews.Trait] = [
372                        MetadataViews.Trait(name: "totalAmount",   value: self.totalAmount,   displayType: nil, rarity: nil),
373                        MetadataViews.Trait(name: "totalStreamed",  value: self.totalStreamed,  displayType: nil, rarity: nil),
374                        MetadataViews.Trait(name: "streamRate",    value: self.streamRate,     displayType: nil, rarity: nil),
375                        MetadataViews.Trait(name: "status",        value: self.getStatus(),    displayType: nil, rarity: nil),
376                        MetadataViews.Trait(name: "remaining",     value: self.getRemainingBalance(), displayType: nil, rarity: nil)
377                    ]
378                    return MetadataViews.Traits(traits)
379
380                case Type<MetadataViews.NFTCollectionData>():
381                    return StreamVest.resolveContractView(
382                        resourceType: Type<@StreamVest.NFT>(),
383                        viewType: Type<MetadataViews.NFTCollectionData>()
384                    )
385
386                case Type<MetadataViews.NFTCollectionDisplay>():
387                    return StreamVest.resolveContractView(
388                        resourceType: Type<@StreamVest.NFT>(),
389                        viewType: Type<MetadataViews.NFTCollectionDisplay>()
390                    )
391
392                case Type<MetadataViews.ExternalURL>():
393                    return MetadataViews.ExternalURL("https://flowfoundation.org")
394            }
395            return nil
396        }
397
398        access(all) fun createEmptyCollection(): @{NonFungibleToken.Collection} {
399            return <- StreamVest.createEmptyCollection(nftType: Type<@StreamVest.NFT>())
400        }
401
402        // ────────────────────── Destroy ──────────────────────
403        // When destroyed, any remaining tokens in the vault are also destroyed.
404        // Only destroy completed / cancelled streams!
405    }
406
407    // ═══════════════════════════════════════════════════════════════════
408    //  Collection
409    // ═══════════════════════════════════════════════════════════════════
410    access(all) resource Collection: NonFungibleToken.Collection {
411
412        access(all) var ownedNFTs: @{UInt64: {NonFungibleToken.NFT}}
413
414        init() {
415            self.ownedNFTs <- {}
416        }
417
418        // ── Standard NFT ops ──
419
420        access(all) view fun getIDs(): [UInt64] {
421            return self.ownedNFTs.keys
422        }
423
424        access(all) view fun getLength(): Int {
425            return self.ownedNFTs.length
426        }
427
428        access(all) view fun getSupportedNFTTypes(): {Type: Bool} {
429            return { Type<@StreamVest.NFT>(): true }
430        }
431
432        access(all) view fun isSupportedNFTType(type: Type): Bool {
433            return type == Type<@StreamVest.NFT>()
434        }
435
436        access(all) fun deposit(token: @{NonFungibleToken.NFT}) {
437            let nft <- token as! @StreamVest.NFT
438            let id  = nft.id
439            let old <- self.ownedNFTs[id] <- nft
440            destroy old
441            emit Deposit(id: id, to: self.owner?.address)
442        }
443
444        access(NonFungibleToken.Withdraw) fun withdraw(withdrawID: UInt64): @{NonFungibleToken.NFT} {
445            let nft <- self.ownedNFTs.remove(key: withdrawID)
446                ?? panic("StreamVest.Collection.withdraw: NFT not found")
447            emit Withdraw(id: withdrawID, from: self.owner?.address)
448            return <- nft
449        }
450
451        access(all) view fun borrowNFT(_ id: UInt64): &{NonFungibleToken.NFT}? {
452            return &self.ownedNFTs[id]
453        }
454
455        access(all) fun createEmptyCollection(): @{NonFungibleToken.Collection} {
456            return <- StreamVest.createEmptyCollection(nftType: Type<@StreamVest.NFT>())
457        }
458
459        // ── StreamVest-specific borrows ──
460
461        /// Public borrow — view functions only.
462        access(all) view fun borrowStreamVestNFT(id: UInt64): &StreamVest.NFT? {
463            if self.ownedNFTs[id] != nil {
464                let ref = &self.ownedNFTs[id] as &{NonFungibleToken.NFT}?
465                return ref as! &StreamVest.NFT
466            }
467            return nil
468        }
469
470        // ── Trigger streaming (public — anyone can trigger) ──
471
472        /// Calculates owed tokens for `nftID` and deposits them to the
473        /// NFT's configured destination.  Safe for anyone to call: it can
474        /// only send tokens to the destination stored inside the NFT.
475        access(all) fun triggerStream(nftID: UInt64) {
476            let nftRef = (&self.ownedNFTs[nftID] as auth(StreamExecute) &{NonFungibleToken.NFT}?)
477                ?? panic("StreamVest: NFT not found")
478            let streamNFT = nftRef as! auth(StreamExecute) &StreamVest.NFT
479
480            let tokens <- streamNFT.withdrawVestedTokens()
481            if tokens == nil {
482                destroy tokens
483                return
484            }
485
486            let vault <- tokens!
487
488            let receiver = getAccount(streamNFT.destinationAddress)
489                .capabilities.get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver)
490                .borrow()
491                ?? panic("StreamVest: destination account has no FLOW receiver capability")
492
493            receiver.deposit(from: <- vault)
494        }
495
496        // ── Owner mutations (require entitlement) ──
497
498        access(StreamOwner) fun updateDestination(nftID: UInt64, newAddress: Address) {
499            let nftRef = (&self.ownedNFTs[nftID] as auth(StreamOwner) &{NonFungibleToken.NFT}?)
500                ?? panic("StreamVest: NFT not found")
501            let streamNFT = nftRef as! auth(StreamOwner) &StreamVest.NFT
502            streamNFT.updateDestination(newAddress: newAddress)
503        }
504
505        access(StreamOwner) fun cancelStream(nftID: UInt64): @FlowToken.Vault {
506            let nftRef = (&self.ownedNFTs[nftID] as auth(StreamOwner) &{NonFungibleToken.NFT}?)
507                ?? panic("StreamVest: NFT not found")
508            let streamNFT = nftRef as! auth(StreamOwner) &StreamVest.NFT
509            return <- streamNFT.cancelStream()
510        }
511    }
512
513    // ═══════════════════════════════════════════════════════════════════
514    //  StreamHandler — Scheduled Transaction Integration
515    // ═══════════════════════════════════════════════════════════════════
516    //
517    // This resource conforms to FlowTransactionScheduler.TransactionHandler.
518    // When Flow's scheduler invokes execute(), the handler borrows the
519    // owner's Collection via a stored Capability and triggers streaming
520    // for the associated NFT.
521    //
522    // NOTE: FlowTransactionScheduler is currently behind the
523    // --scheduled-transactions emulator flag.  On networks where it is
524    // unavailable, use the public `triggerStream` function on the
525    // Collection instead (manually or via a lightweight cron).
526    // ─────────────────────────────────────────────────────────────────
527
528    // Placeholder interface when the scheduler import is not available.
529    // Replace with the real FlowTransactionScheduler.TransactionHandler
530    // import when deploying with scheduled-tx support.
531    access(all) resource interface ITransactionHandler {
532        access(all) fun run()
533    }
534
535    access(all) resource StreamHandler: ITransactionHandler {
536        access(self) let collectionCap: Capability<auth(StreamExecute) &Collection>
537        access(self) let nftID: UInt64
538        access(self) let intervalSeconds: UFix64
539        access(all)  var isComplete: Bool
540
541        init(
542            collectionCap: Capability<auth(StreamExecute) &Collection>,
543            nftID: UInt64,
544            intervalSeconds: UFix64
545        ) {
546            self.collectionCap    = collectionCap
547            self.nftID            = nftID
548            self.intervalSeconds  = intervalSeconds
549            self.isComplete       = false
550        }
551
552        /// Called by the scheduler at each interval.
553        access(all) fun run() {
554            if self.isComplete { return }
555
556            let collection = self.collectionCap.borrow()
557                ?? panic("StreamHandler: cannot borrow collection capability")
558
559            // Trigger the stream — deposits owed tokens to destination.
560            collection.triggerStream(nftID: self.nftID)
561
562            // Check if the stream is now finished.
563            if let nft = collection.borrowStreamVestNFT(id: self.nftID) {
564                if !nft.isActive {
565                    self.isComplete = true
566                    // When using the real scheduler, do NOT reschedule here.
567                    return
568                }
569            } else {
570                // NFT no longer in collection (transferred out?).
571                self.isComplete = true
572                return
573            }
574
575            // When using FlowTransactionSchedulerUtils, reschedule:
576            // FlowTransactionSchedulerUtils.Manager.scheduleTransaction(
577            //     handler: selfCapability,
578            //     executeAfter: getCurrentBlock().timestamp + self.intervalSeconds
579            // )
580        }
581    }
582
583    // ═══════════════════════════════════════════════════════════════════
584    //  Minter
585    // ═══════════════════════════════════════════════════════════════════
586    access(all) resource Minter {
587
588        /// Mint a new StreamVest NFT.
589        ///
590        /// - vault:       FLOW tokens to lock for vesting
591        /// - destination: address that will receive streamed tokens
592        /// - duration:    vesting duration in seconds
593        access(all) fun mintNFT(
594            vault: @FlowToken.Vault,
595            destination: Address,
596            duration: UFix64
597        ): @StreamVest.NFT {
598            pre {
599                duration > 0.0:       "Duration must be positive"
600                vault.balance > 0.0:  "Must deposit tokens"
601            }
602
603            let now   = getCurrentBlock().timestamp
604            let start = now
605            let end   = now + duration
606            let id    = StreamVest.totalSupply
607
608            let nft <- create NFT(
609                id: id,
610                vault: <- vault,
611                destination: destination,
612                startTime: start,
613                endTime: end
614            )
615
616            emit StreamMinted(
617                id: id,
618                amount: nft.totalAmount,
619                destination: destination,
620                startTime: start,
621                endTime: end,
622                streamRate: nft.streamRate
623            )
624
625            StreamVest.totalSupply = StreamVest.totalSupply + 1
626            return <- nft
627        }
628    }
629
630    // ═══════════════════════════════════════════════════════════════════
631    //  Public Stream Creation
632    // ═══════════════════════════════════════════════════════════════════
633
634    /// Create a new StreamVest NFT.  Anyone can call this — you lock
635    /// your own tokens and choose the destination and duration.
636    ///
637    /// Returns the NFT; the caller is responsible for depositing it
638    /// into their (or someone else's) Collection.
639    access(all) fun createStream(
640        vault: @FlowToken.Vault,
641        destination: Address,
642        duration: UFix64
643    ): @StreamVest.NFT {
644        pre {
645            duration > 0.0:       "Duration must be positive"
646            vault.balance > 0.0:  "Must deposit tokens"
647        }
648
649        let now   = getCurrentBlock().timestamp
650        let start = now
651        let end   = now + duration
652        let id    = self.totalSupply
653
654        let nft <- create NFT(
655            id: id,
656            vault: <- vault,
657            destination: destination,
658            startTime: start,
659            endTime: end
660        )
661
662        emit StreamMinted(
663            id: id,
664            amount: nft.totalAmount,
665            destination: destination,
666            startTime: start,
667            endTime: end,
668            streamRate: nft.streamRate
669        )
670
671        self.totalSupply = self.totalSupply + 1
672        return <- nft
673    }
674
675    // ═══════════════════════════════════════════════════════════════════
676    //  Contract-level helpers
677    // ═══════════════════════════════════════════════════════════════════
678
679    /// Create a new StreamHandler resource.  The caller must store it and
680    /// issue a Capability for the scheduler.
681    access(all) fun createStreamHandler(
682        collectionCap: Capability<auth(StreamExecute) &Collection>,
683        nftID: UInt64,
684        intervalSeconds: UFix64
685    ): @StreamHandler {
686        return <- create StreamHandler(
687            collectionCap: collectionCap,
688            nftID: nftID,
689            intervalSeconds: intervalSeconds
690        )
691    }
692
693    // ── Numeric → String helpers (view-safe) ──
694
695    /// Convert a UFix64 to an integer string (truncates decimals).
696    access(all) view fun ufix64ToIntString(_ value: UFix64): String {
697        let str = value.toString()
698        // UFix64.toString() returns "123.45600000" — take chars before '.'
699        var result = ""
700        var i = 0
701        let chars = str.utf8
702        while i < chars.length {
703            let c = chars[i]
704            if c == 46 { break }  // '.'
705            result = result.concat(String.fromUTF8([c]) ?? "")
706            i = i + 1
707        }
708        if result == "" { return "0" }
709        return result
710    }
711
712    /// Convert a UFix64 to a decimal string with up to `places` decimals.
713    access(all) view fun ufix64ToDecimalString(_ value: UFix64, _ places: Int): String {
714        let str = value.toString()
715        let chars = str.utf8
716        var intPart  = ""
717        var decPart  = ""
718        var pastDot  = false
719        var i = 0
720        while i < chars.length {
721            let c = chars[i]
722            if c == 46 { // '.'
723                pastDot = true
724                i = i + 1
725                continue
726            }
727            if pastDot {
728                decPart = decPart.concat(String.fromUTF8([c]) ?? "")
729            } else {
730                intPart = intPart.concat(String.fromUTF8([c]) ?? "")
731            }
732            i = i + 1
733        }
734        if intPart == "" { intPart = "0" }
735
736        // Trim or pad the decimal part.
737        var trimmed = ""
738        i = 0
739        while i < places && i < decPart.utf8.length {
740            let dc = decPart.utf8[i]
741            trimmed = trimmed.concat(String.fromUTF8([dc]) ?? "")
742            i = i + 1
743        }
744        while i < places {
745            trimmed = trimmed.concat("0")
746            i = i + 1
747        }
748        if places == 0 { return intPart }
749        return intPart.concat(".").concat(trimmed)
750    }
751
752    /// Estimate scheduled-tx fees for a given duration and interval.
753    /// This is a rough estimate: (duration / interval) * feePerExecution.
754    access(all) view fun estimateFees(
755        durationSeconds: UFix64,
756        intervalSeconds: UFix64,
757        feePerExecution: UFix64
758    ): UFix64 {
759        if intervalSeconds == 0.0 { return 0.0 }
760        let executions = durationSeconds / intervalSeconds
761        return executions * feePerExecution
762    }
763
764    // ═══════════════════════════════════════════════════════════════════
765    //  ViewResolver conformance
766    // ═══════════════════════════════════════════════════════════════════
767
768    access(all) view fun getContractViews(resourceType: Type?): [Type] {
769        return [
770            Type<MetadataViews.NFTCollectionData>(),
771            Type<MetadataViews.NFTCollectionDisplay>()
772        ]
773    }
774
775    access(all) fun resolveContractView(resourceType: Type?, viewType: Type): AnyStruct? {
776        switch viewType {
777            case Type<MetadataViews.NFTCollectionData>():
778                return MetadataViews.NFTCollectionData(
779                    storagePath: self.CollectionStoragePath,
780                    publicPath: self.CollectionPublicPath,
781                    publicCollection: Type<&StreamVest.Collection>(),
782                    publicLinkedType: Type<&StreamVest.Collection>(),
783                    createEmptyCollectionFunction: fun(): @{NonFungibleToken.Collection} {
784                        return <- StreamVest.createEmptyCollection(nftType: Type<@StreamVest.NFT>())
785                    }
786                )
787
788            case Type<MetadataViews.NFTCollectionDisplay>():
789                return MetadataViews.NFTCollectionDisplay(
790                    name: "StreamVest",
791                    description: "Streaming vesting NFTs — fully on-chain token vesting with dynamic SVG visuals.",
792                    externalURL: MetadataViews.ExternalURL("https://flowfoundation.org"),
793                    squareImage: MetadataViews.Media(
794                        file: MetadataViews.HTTPFile(url: "https://flowfoundation.org/streamvest-square.png"),
795                        mediaType: "image/png"
796                    ),
797                    bannerImage: MetadataViews.Media(
798                        file: MetadataViews.HTTPFile(url: "https://flowfoundation.org/streamvest-banner.png"),
799                        mediaType: "image/png"
800                    ),
801                    socials: {}
802                )
803        }
804        return nil
805    }
806
807    // ═══════════════════════════════════════════════════════════════════
808    //  NonFungibleToken conformance
809    // ═══════════════════════════════════════════════════════════════════
810
811    access(all) fun createEmptyCollection(nftType: Type): @{NonFungibleToken.Collection} {
812        return <- create Collection()
813    }
814
815    // ═══════════════════════════════════════════════════════════════════
816    //  Initializer
817    // ═══════════════════════════════════════════════════════════════════
818
819    init() {
820        self.totalSupply = 0
821
822        self.CollectionStoragePath = /storage/StreamVestCollection
823        self.CollectionPublicPath  = /public/StreamVestCollection
824        self.MinterStoragePath     = /storage/StreamVestMinter
825
826        // Save a Minter to the deployer's account.
827        let minter <- create Minter()
828        self.account.storage.save(<- minter, to: self.MinterStoragePath)
829
830        // Save an empty Collection for the deployer.
831        let collection <- create Collection()
832        self.account.storage.save(<- collection, to: self.CollectionStoragePath)
833
834        // Publish a public capability for the collection.
835        let cap = self.account.capabilities.storage.issue<&StreamVest.Collection>(
836            self.CollectionStoragePath
837        )
838        self.account.capabilities.publish(cap, at: self.CollectionPublicPath)
839
840        emit ContractInitialized()
841    }
842}
843