Smart Contract
StreamVest
A.5ec90e3dcf0067c4.StreamVest
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