Smart Contract
DCAVaultActions
A.17ae3b1b0b0d50db.DCAVaultActions
1import FungibleToken from 0xf233dcee88fe0abe
2import DeFiActions from 0x17ae3b1b0b0d50db
3
4/// DCAVaultActions: FlowActions-enabled DCA vault with composable DEX integration
5///
6/// This contract leverages FlowActions to provide efficient, composable DCA execution
7/// using standardized Swapper, Source, and Sink interfaces for protocol-agnostic trades.
8///
9/// Protocol Fees: 0.1% of each purchase amount
10///
11access(all) contract DCAVaultActions {
12
13 /// Events
14 access(all) event DCAVaultCreated(vaultId: UInt64, owner: Address)
15 access(all) event DCAVaultDeposited(vaultId: UInt64, tokenType: String, amount: UFix64)
16 access(all) event DCAVaultWithdrawn(
17 vaultId: UInt64,
18 owner: Address,
19 tokenType: String,
20 amount: UFix64,
21 planId: UInt64?,
22 isSourceToken: Bool,
23 timestamp: UFix64
24 )
25 access(all) event ProtocolFeeCollected(
26 vaultId: UInt64,
27 planId: UInt64,
28 tokenType: String,
29 feeAmount: UFix64,
30 purchaseAmount: UFix64
31 )
32 access(all) event DCAPlanCreated(
33 vaultId: UInt64,
34 planId: UInt64,
35 sourceToken: String,
36 targetToken: String,
37 swapperType: String,
38 interval: UFix64,
39 purchaseAmount: UFix64
40 )
41 access(all) event DCAPlanActivated(vaultId: UInt64, planId: UInt64)
42 access(all) event DCAPlanPaused(vaultId: UInt64, planId: UInt64)
43 access(all) event DCAPlanCompleted(vaultId: UInt64, planId: UInt64)
44 access(all) event DCAPlanUpdated(vaultId: UInt64, planId: UInt64, field: String)
45 access(all) event DCAPlanCancelled(vaultId: UInt64, planId: UInt64, reason: String)
46 access(all) event PriceTriggerHit(vaultId: UInt64, planId: UInt64, currentPrice: UFix64, triggerPrice: UFix64)
47 access(all) event OCOPlanCancelled(vaultId: UInt64, planId: UInt64, triggeringPlanId: UInt64)
48 access(all) event DCAPurchaseExecuted(
49 vaultId: UInt64,
50 planId: UInt64,
51 owner: Address,
52 sourceAmount: UFix64,
53 targetAmount: UFix64,
54 sourceToken: String,
55 targetToken: String,
56 swapperUsed: String,
57 protocolFee: UFix64
58 )
59
60 /// Admin events
61 access(all) event ProtocolFeePercentUpdated(oldFee: UFix64, newFee: UFix64, updatedBy: Address)
62 access(all) event ProtocolTreasuryUpdated(oldTreasury: Address, newTreasury: Address, updatedBy: Address)
63 access(all) event ProtocolPaused(pausedBy: Address, reason: String)
64 access(all) event ProtocolUnpaused(unpausedBy: Address)
65 access(all) event AdminTransferred(oldAdmin: Address, newAdmin: Address, transferredBy: Address)
66 access(all) event TokenWhitelistUpdated(tokenType: String, approved: Bool, updatedBy: Address)
67 access(all) event MinimumDepositUpdated(oldAmount: UFix64, newAmount: UFix64, updatedBy: Address)
68
69 /// Emergency events
70 access(all) event EmergencyWithdrawal(
71 vaultId: UInt64,
72 owner: Address,
73 tokenType: String,
74 amount: UFix64,
75 timestamp: UFix64
76 )
77
78 /// Storage paths
79 access(all) let VaultStoragePath: StoragePath
80 access(all) let VaultPublicPath: PublicPath
81 access(all) let AdminStoragePath: StoragePath
82
83 /// Protocol Fee Configuration
84 /// Fee charged on each DCA purchase
85 access(all) var protocolFeePercent: UFix64
86 access(all) var protocolTreasuryAddress: Address
87
88 /// Emergency Pause State
89 /// Global circuit breaker to halt all DCA executions
90 /// Can only be modified by Admin resource holder
91 access(all) var isPaused: Bool
92
93 /// Plan Status
94 access(all) enum PlanStatus: UInt8 {
95 access(all) case pending
96 access(all) case active
97 access(all) case paused
98 access(all) case completed
99 access(all) case cancelled
100 }
101
102 /// Trigger Direction for conditional execution
103 /// - none: Standard DCA, no price condition
104 /// - buyAbove: Execute when price rises above trigger (Take Profit for shorts)
105 /// - sellBelow: Execute when price falls below trigger (Stop Loss for longs)
106 access(all) enum TriggerDirection: UInt8 {
107 access(all) case none
108 access(all) case buyAbove
109 access(all) case sellBelow
110 }
111
112 /// Trigger Type - what metric to check
113 access(all) enum TriggerType: UInt8 {
114 access(all) case none
115 access(all) case price // Trigger by USD price
116 access(all) case marketCap // Trigger by market cap (future)
117 }
118
119 /// PlanExecution entitlement
120 /// Required for functions that modify plan execution state
121 access(all) entitlement PlanExecution
122
123 /// Token pair configuration
124 access(all) struct TokenPair {
125 access(all) let sourceToken: String
126 access(all) let targetToken: String
127 access(contract) let sourceTokenType: Type
128 access(contract) let targetTokenType: Type
129
130 init(
131 sourceToken: String,
132 targetToken: String,
133 sourceTokenType: Type,
134 targetTokenType: Type
135 ) {
136 self.sourceToken = sourceToken
137 self.targetToken = targetToken
138 self.sourceTokenType = sourceTokenType
139 self.targetTokenType = targetTokenType
140 }
141
142 access(all) fun getSourceTokenType(): Type {
143 return self.sourceTokenType
144 }
145
146 access(all) fun getTargetTokenType(): Type {
147 return self.targetTokenType
148 }
149 }
150
151 /// DCA Plan with FlowActions integration + Advanced Features
152 access(all) struct DCAPlan {
153 access(all) let id: UInt64
154 access(contract) let tokenPair: TokenPair
155 access(all) let swapperType: String // Type of swapper connector to use
156 access(all) var purchaseAmount: UFix64 // Mutable for editing
157 access(all) var intervalSeconds: UFix64 // Mutable for editing
158 access(all) var totalPurchases: UInt64 // Mutable for editing
159 access(all) var purchasesExecuted: UInt64
160 access(contract) var status: PlanStatus
161 access(all) var lastPurchaseTime: UFix64
162 access(all) let createdAt: UFix64
163 access(all) var nextScheduledTime: UFix64
164 access(all) var slippageTolerance: UFix64 // Mutable for editing
165 access(all) var minReceiveAmount: UFix64 // Mutable for editing
166
167 // Advanced Features
168 access(contract) var triggerType: TriggerType // Price or MarketCap trigger
169 access(contract) var triggerDirection: TriggerDirection // Buy Above or Sell Below
170 access(all) var triggerPrice: UFix64? // USD price trigger (nil = no trigger)
171 access(all) var linkedPlanId: UInt64? // For OCO: linked plan ID
172 access(all) var isOCOPrimary: Bool // Is this the primary in OCO pair?
173
174 init(
175 id: UInt64,
176 tokenPair: TokenPair,
177 swapperType: String,
178 purchaseAmount: UFix64,
179 intervalSeconds: UFix64,
180 totalPurchases: UInt64,
181 slippageTolerance: UFix64,
182 minReceiveAmount: UFix64,
183 triggerType: TriggerType,
184 triggerDirection: TriggerDirection,
185 triggerPrice: UFix64?,
186 linkedPlanId: UInt64?,
187 isOCOPrimary: Bool
188 ) {
189 self.id = id
190 self.tokenPair = tokenPair
191 self.swapperType = swapperType
192 self.purchaseAmount = purchaseAmount
193 self.intervalSeconds = intervalSeconds
194 self.totalPurchases = totalPurchases
195 self.purchasesExecuted = 0
196 self.status = PlanStatus.pending
197 self.lastPurchaseTime = 0.0
198 self.createdAt = getCurrentBlock().timestamp
199 self.nextScheduledTime = 0.0
200 self.slippageTolerance = slippageTolerance
201 self.minReceiveAmount = minReceiveAmount
202 self.triggerType = triggerType
203 self.triggerDirection = triggerDirection
204 self.triggerPrice = triggerPrice
205 self.linkedPlanId = linkedPlanId
206 self.isOCOPrimary = isOCOPrimary
207 }
208
209 access(contract) fun activate() {
210 self.status = PlanStatus.active
211 self.nextScheduledTime = getCurrentBlock().timestamp + self.intervalSeconds
212 }
213
214 access(contract) fun pause() {
215 self.status = PlanStatus.paused
216 }
217
218 access(contract) fun recordPurchase() {
219 self.purchasesExecuted = self.purchasesExecuted + 1
220 self.lastPurchaseTime = getCurrentBlock().timestamp
221
222 // Check if plan is complete BEFORE setting next schedule time
223 if self.purchasesExecuted >= self.totalPurchases {
224 self.status = PlanStatus.completed
225 } else {
226 // Only schedule next purchase if plan is not complete
227 self.nextScheduledTime = getCurrentBlock().timestamp + self.intervalSeconds
228 }
229 }
230
231 access(all) fun isActive(): Bool {
232 return self.status == PlanStatus.active
233 }
234
235 access(all) fun isReadyForPurchase(): Bool {
236 return self.isActive() && getCurrentBlock().timestamp >= self.nextScheduledTime
237 }
238
239 /// Check if price trigger condition is met
240 /// Returns true if no trigger set, or if current price meets trigger condition
241 access(all) fun checkPriceTrigger(currentPrice: UFix64): Bool {
242 // No trigger set - always ready
243 if self.triggerType == TriggerType.none || self.triggerPrice == nil {
244 return true
245 }
246
247 let trigger = self.triggerPrice!
248
249 switch self.triggerDirection {
250 case TriggerDirection.buyAbove:
251 // Execute when price goes above trigger (Take Profit)
252 return currentPrice >= trigger
253 case TriggerDirection.sellBelow:
254 // Execute when price goes below trigger (Stop Loss)
255 return currentPrice <= trigger
256 default:
257 return true
258 }
259 }
260
261 /// Check if this is a Take Profit order
262 access(all) fun isTakeProfit(): Bool {
263 return self.triggerDirection == TriggerDirection.buyAbove && self.triggerPrice != nil
264 }
265
266 /// Check if this is a Stop Loss order
267 access(all) fun isStopLoss(): Bool {
268 return self.triggerDirection == TriggerDirection.sellBelow && self.triggerPrice != nil
269 }
270
271 /// Check if this plan is part of an OCO pair
272 access(all) fun isOCO(): Bool {
273 return self.linkedPlanId != nil
274 }
275
276 /// Cancel this plan (for OCO)
277 access(contract) fun cancel() {
278 self.status = PlanStatus.cancelled
279 }
280
281 /// Setter functions for plan updates
282 access(contract) fun setPurchaseAmount(_ amount: UFix64) {
283 self.purchaseAmount = amount
284 }
285
286 access(contract) fun setIntervalSeconds(_ seconds: UFix64) {
287 self.intervalSeconds = seconds
288 }
289
290 access(contract) fun setTotalPurchases(_ total: UInt64) {
291 self.totalPurchases = total
292 }
293
294 access(contract) fun setSlippageTolerance(_ tolerance: UFix64) {
295 self.slippageTolerance = tolerance
296 }
297
298 access(contract) fun setMinReceiveAmount(_ amount: UFix64) {
299 self.minReceiveAmount = amount
300 }
301
302 access(contract) fun setTriggerPrice(_ price: UFix64?) {
303 self.triggerPrice = price
304 }
305
306 access(all) fun getTokenPair(): TokenPair {
307 return self.tokenPair
308 }
309
310 access(contract) fun getStatus(): PlanStatus {
311 return self.status
312 }
313
314 access(all) fun getTriggerType(): TriggerType {
315 return self.triggerType
316 }
317
318 access(contract) fun getTriggerDirection(): TriggerDirection {
319 return self.triggerDirection
320 }
321 }
322
323 /// Purchase record with FlowActions metadata
324 access(all) struct PurchaseRecord {
325 access(all) let planId: UInt64
326 access(all) let timestamp: UFix64
327 access(all) let sourceAmount: UFix64 // Total amount (including fee)
328 access(all) let targetAmount: UFix64
329 access(all) let sourceToken: String
330 access(all) let targetToken: String
331 access(all) let swapperUsed: String
332 access(all) let price: UFix64
333 access(all) let protocolFee: UFix64 // Fee charged (0.1%)
334 access(all) let amountSwapped: UFix64 // Amount actually swapped (after fee)
335
336 init(
337 planId: UInt64,
338 sourceAmount: UFix64,
339 targetAmount: UFix64,
340 sourceToken: String,
341 targetToken: String,
342 swapperUsed: String,
343 protocolFee: UFix64
344 ) {
345 self.planId = planId
346 self.timestamp = getCurrentBlock().timestamp
347 self.sourceAmount = sourceAmount
348 self.targetAmount = targetAmount
349 self.sourceToken = sourceToken
350 self.targetToken = targetToken
351 self.swapperUsed = swapperUsed
352 self.protocolFee = protocolFee
353 self.amountSwapped = sourceAmount - protocolFee
354 self.price = self.amountSwapped / targetAmount
355 }
356 }
357
358 /// Admin resource for protocol configuration
359 /// Only the contract deployer receives this capability
360 access(all) resource Admin {
361 // Store the owner address for event emission
362 access(all) let ownerAddress: Address
363
364 init(ownerAddress: Address) {
365 self.ownerAddress = ownerAddress
366 }
367
368 /// Update protocol fee percentage
369 /// Can only be called by Admin capability holder
370 access(all) fun setProtocolFeePercent(_ newFee: UFix64) {
371 pre {
372 newFee >= 0.0 && newFee <= 0.05: "Fee must be between 0% and 5%"
373 }
374
375 let oldFee = DCAVaultActions.protocolFeePercent
376 DCAVaultActions.protocolFeePercent = newFee
377
378 emit ProtocolFeePercentUpdated(
379 oldFee: oldFee,
380 newFee: newFee,
381 updatedBy: self.ownerAddress
382 )
383 }
384
385 /// Update protocol treasury address
386 /// Can only be called by Admin capability holder
387 access(all) fun setProtocolTreasuryAddress(_ newAddress: Address) {
388 pre {
389 newAddress != Address(0x0): "Treasury address cannot be zero address"
390 }
391
392 let oldTreasury = DCAVaultActions.protocolTreasuryAddress
393 DCAVaultActions.protocolTreasuryAddress = newAddress
394
395 emit ProtocolTreasuryUpdated(
396 oldTreasury: oldTreasury,
397 newTreasury: newAddress,
398 updatedBy: self.ownerAddress
399 )
400 }
401
402 /// Emergency pause - halts all DCA plan executions
403 /// Use this in case of security issues, oracle failures, or market anomalies
404 access(all) fun pauseProtocol(_ reason: String) {
405 DCAVaultActions.isPaused = true
406 emit ProtocolPaused(pausedBy: self.ownerAddress, reason: reason)
407 }
408
409 /// Unpause protocol - resumes DCA plan executions
410 /// Only call after security issue is resolved
411 access(all) fun unpauseProtocol() {
412 DCAVaultActions.isPaused = false
413 emit ProtocolUnpaused(unpausedBy: self.ownerAddress)
414 }
415
416 /// Update token whitelist (DEPRECATED - No longer enforced)
417 /// As of contract upgrade, ALL FungibleTokens are accepted
418 /// This function now only emits events for tracking purposes
419 access(all) fun setTokenApproval(tokenType: String, approved: Bool) {
420 pre {
421 tokenType.length > 0: "Token type cannot be empty"
422 }
423
424 // NOTE: Token whitelist has been removed from the contract
425 // All FungibleToken vaults are now universally accepted
426 // This function remains for backward compatibility and event emission
427 emit TokenWhitelistUpdated(
428 tokenType: tokenType,
429 approved: approved,
430 updatedBy: self.ownerAddress
431 )
432 }
433
434 /// Update minimum deposit amount
435 /// Prevents dust deposits that could cause issues
436 access(all) fun setMinimumDepositAmount(_ newAmount: UFix64) {
437 pre {
438 newAmount >= 0.0: "Minimum deposit must be non-negative"
439 }
440
441 emit MinimumDepositUpdated(
442 oldAmount: 0.0, // Will be set per-vault in future
443 newAmount: newAmount,
444 updatedBy: self.ownerAddress
445 )
446 }
447 }
448
449 /// Wrapper resource to store any FungibleToken vault
450 access(all) resource TokenVaultWrapper {
451 access(all) let vault: @{FungibleToken.Vault}
452
453 init(vault: @{FungibleToken.Vault}) {
454 self.vault <- vault
455 }
456 }
457
458 /// FlowActions-enabled vault
459 access(all) resource Vault: VaultPublic {
460 access(all) let id: UInt64
461 access(all) let ownerAddress: Address
462 // Store token vaults wrapped in TokenVaultWrapper
463 access(self) var tokenBalances: @{String: TokenVaultWrapper}
464 access(self) var plans: {UInt64: DCAPlan}
465 access(self) var purchaseHistory: [PurchaseRecord]
466 access(self) var nextPlanId: UInt64
467 access(self) var isExecuting: Bool
468 access(self) var minimumDepositAmount: UFix64
469
470 init(owner: Address) {
471 self.id = self.uuid
472 self.ownerAddress = owner
473 self.tokenBalances <- {}
474 self.plans = {}
475 self.purchaseHistory = []
476 self.nextPlanId = 1
477 self.isExecuting = false
478 self.minimumDepositAmount = 0.000001
479 }
480
481 /// Deposit tokens using Source interface
482 access(all) fun deposit(tokenType: String, from: @{FungibleToken.Vault}) {
483 pre {
484 from.balance >= self.minimumDepositAmount: "Deposit amount below minimum"
485 tokenType.length > 0: "Token type cannot be empty"
486 }
487
488 let amount = from.balance
489
490 // Check if we already have a vault for this token type
491 if let existingWrapper <- self.tokenBalances.remove(key: tokenType) {
492 // Deposit into existing vault
493 let vaultRef = &existingWrapper.vault as &{FungibleToken.Vault}
494 vaultRef.deposit(from: <-from)
495 // Put the wrapper back
496 self.tokenBalances[tokenType] <-! existingWrapper
497 } else {
498 // Create new wrapper for this token type
499 let wrapper <- create TokenVaultWrapper(vault: <-from)
500 self.tokenBalances[tokenType] <-! wrapper
501 }
502
503 emit DCAVaultDeposited(vaultId: self.id, tokenType: tokenType, amount: amount)
504 }
505
506 /// Withdraw tokens with tracking for plan ID and token type
507 /// This is the ONLY withdrawal method - ensures all withdrawals are properly tracked
508 access(FungibleToken.Withdraw) fun withdrawWithTracking(
509 tokenType: String,
510 amount: UFix64,
511 planId: UInt64?,
512 isSourceToken: Bool
513 ): @{FungibleToken.Vault} {
514 pre {
515 amount > 0.0: "Withdrawal amount must be positive"
516 self.tokenBalances[tokenType] != nil: "Token type not found"
517 }
518
519 let balance = self.getTokenBalance(tokenType: tokenType)
520 assert(balance >= amount, message: "Insufficient balance")
521
522 // Remove wrapper from dictionary to access the vault
523 let wrapper <- self.tokenBalances.remove(key: tokenType)!
524 let vaultRef = &wrapper.vault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}
525 let withdrawn <- vaultRef.withdraw(amount: amount)
526
527 // Put wrapper back if vault still has balance, otherwise destroy it
528 if vaultRef.balance > 0.0 {
529 self.tokenBalances[tokenType] <-! wrapper
530 } else {
531 destroy wrapper
532 }
533
534 // Emit enhanced withdrawal event with full traceability
535 // Backend can index these events to build withdrawal history
536 emit DCAVaultWithdrawn(
537 vaultId: self.id,
538 owner: self.ownerAddress,
539 tokenType: tokenType,
540 amount: amount,
541 planId: planId,
542 isSourceToken: isSourceToken,
543 timestamp: getCurrentBlock().timestamp
544 )
545 return <-withdrawn
546 }
547
548 /// Create DCA plan with FlowActions Swapper + Advanced Features
549 /// Supports price triggers, TP/SL, and OCO orders
550 access(PlanExecution) fun createPlan(
551 tokenPair: TokenPair,
552 swapperType: String,
553 purchaseAmount: UFix64,
554 intervalSeconds: UFix64,
555 totalPurchases: UInt64,
556 slippageTolerance: UFix64,
557 minReceiveAmount: UFix64,
558 triggerType: TriggerType,
559 triggerDirection: TriggerDirection,
560 triggerPrice: UFix64?,
561 linkedPlanId: UInt64?,
562 isOCOPrimary: Bool
563 ): UInt64 {
564 pre {
565 purchaseAmount > 0.0: "Purchase amount must be positive"
566 intervalSeconds > 0.0: "Interval must be positive"
567 intervalSeconds >= 1.0: "Interval must be at least 1 second"
568 totalPurchases > 0: "Total purchases must be positive"
569 slippageTolerance >= 0.0 && slippageTolerance <= 1.0: "Slippage tolerance must be between 0% and 100%"
570 minReceiveAmount >= 0.0: "Minimum receive amount must be non-negative"
571 swapperType.length > 0: "Swapper type cannot be empty"
572 triggerPrice == nil || triggerPrice! > 0.0: "Trigger price must be positive if set"
573 }
574
575 if linkedPlanId != nil {
576 assert(
577 self.plans[linkedPlanId!] != nil,
578 message: "Linked plan does not exist"
579 )
580 assert(
581 self.plans[linkedPlanId!]!.linkedPlanId == nil ||
582 self.plans[linkedPlanId!]!.linkedPlanId! != self.nextPlanId,
583 message: "Cannot create circular OCO reference"
584 )
585 }
586
587 let plan = DCAPlan(
588 id: self.nextPlanId,
589 tokenPair: tokenPair,
590 swapperType: swapperType,
591 purchaseAmount: purchaseAmount,
592 intervalSeconds: intervalSeconds,
593 totalPurchases: totalPurchases,
594 slippageTolerance: slippageTolerance,
595 minReceiveAmount: minReceiveAmount,
596 triggerType: triggerType,
597 triggerDirection: triggerDirection,
598 triggerPrice: triggerPrice,
599 linkedPlanId: linkedPlanId,
600 isOCOPrimary: isOCOPrimary
601 )
602
603 self.plans[self.nextPlanId] = plan
604 emit DCAPlanCreated(
605 vaultId: self.id,
606 planId: self.nextPlanId,
607 sourceToken: tokenPair.sourceToken,
608 targetToken: tokenPair.targetToken,
609 swapperType: swapperType,
610 interval: intervalSeconds,
611 purchaseAmount: purchaseAmount
612 )
613
614 let planId = self.nextPlanId
615 self.nextPlanId = self.nextPlanId + 1
616 return planId
617 }
618
619 /// Activate plan
620 access(PlanExecution) fun activatePlan(planId: UInt64) {
621 pre {
622 self.plans[planId] != nil: "Plan does not exist"
623 (&self.plans[planId]! as &DCAPlan).status == PlanStatus.pending: "Plan must be pending"
624 }
625
626 let plan = &self.plans[planId]! as &DCAPlan
627 let totalRequired = plan.purchaseAmount * UFix64(plan.totalPurchases)
628
629 // Check balance - temporarily remove wrapper to access vault
630 let wrapper <- self.tokenBalances.remove(key: plan.tokenPair.sourceToken)!
631 let vaultRef = &wrapper.vault as &{FungibleToken.Vault}
632 assert(
633 vaultRef.balance >= totalRequired,
634 message: "Insufficient balance"
635 )
636 // Put wrapper back
637 self.tokenBalances[plan.tokenPair.sourceToken] <-! wrapper
638
639 plan.activate()
640 emit DCAPlanActivated(vaultId: self.id, planId: planId)
641 }
642
643 /// Pause plan
644 access(PlanExecution) fun pausePlan(planId: UInt64) {
645 pre {
646 self.plans[planId] != nil: "Plan does not exist"
647 }
648
649 // Check if plan is active (moved out of pre block to avoid view context issues)
650 let plan = &self.plans[planId]! as &DCAPlan
651 assert(plan.isActive(), message: "Plan must be active")
652 plan.pause()
653 emit DCAPlanPaused(vaultId: self.id, planId: planId)
654 }
655
656 /// Resume plan (unpause)
657 access(PlanExecution) fun resumePlan(planId: UInt64) {
658 pre {
659 self.plans[planId] != nil: "Plan does not exist"
660 }
661
662 let plan = &self.plans[planId]! as &DCAPlan
663 assert(plan.status == PlanStatus.paused, message: "Plan must be paused")
664 plan.activate()
665 emit DCAPlanActivated(vaultId: self.id, planId: planId)
666 }
667
668 /// Update plan parameters
669 /// Allows modifying existing plans without canceling and recreating
670 access(PlanExecution) fun updatePlan(
671 planId: UInt64,
672 purchaseAmount: UFix64?,
673 intervalSeconds: UFix64?,
674 totalPurchases: UInt64?,
675 slippageTolerance: UFix64?,
676 minReceiveAmount: UFix64?,
677 triggerPrice: UFix64?
678 ) {
679 pre {
680 self.plans[planId] != nil: "Plan does not exist"
681 purchaseAmount == nil || purchaseAmount! > 0.0: "Purchase amount must be positive if provided"
682 intervalSeconds == nil || intervalSeconds! >= 1.0: "Interval must be at least 1 second if provided"
683 totalPurchases == nil || totalPurchases! > 0: "Total purchases must be positive if provided"
684 slippageTolerance == nil || (slippageTolerance! >= 0.0 && slippageTolerance! <= 1.0): "Slippage tolerance must be between 0% and 100% if provided"
685 minReceiveAmount == nil || minReceiveAmount! >= 0.0: "Minimum receive amount must be non-negative if provided"
686 triggerPrice == nil || triggerPrice! > 0.0: "Trigger price must be positive if provided"
687 }
688
689 let plan = &self.plans[planId]! as &DCAPlan
690
691 if purchaseAmount != nil {
692 plan.setPurchaseAmount(purchaseAmount!)
693 emit DCAPlanUpdated(vaultId: self.id, planId: planId, field: "purchaseAmount")
694 }
695
696 if intervalSeconds != nil {
697 plan.setIntervalSeconds(intervalSeconds!)
698 emit DCAPlanUpdated(vaultId: self.id, planId: planId, field: "intervalSeconds")
699 }
700
701 if totalPurchases != nil {
702 plan.setTotalPurchases(totalPurchases!)
703 emit DCAPlanUpdated(vaultId: self.id, planId: planId, field: "totalPurchases")
704 }
705
706 if slippageTolerance != nil {
707 plan.setSlippageTolerance(slippageTolerance!)
708 emit DCAPlanUpdated(vaultId: self.id, planId: planId, field: "slippageTolerance")
709 }
710
711 if minReceiveAmount != nil {
712 plan.setMinReceiveAmount(minReceiveAmount!)
713 emit DCAPlanUpdated(vaultId: self.id, planId: planId, field: "minReceiveAmount")
714 }
715
716 if triggerPrice != nil {
717 plan.setTriggerPrice(triggerPrice)
718 emit DCAPlanUpdated(vaultId: self.id, planId: planId, field: "triggerPrice")
719 }
720 }
721
722 /// Cancel plan manually
723 access(PlanExecution) fun cancelPlan(planId: UInt64, reason: String) {
724 pre {
725 self.plans[planId] != nil: "Plan does not exist"
726 }
727
728 let plan = &self.plans[planId]! as &DCAPlan
729 plan.cancel()
730 emit DCAPlanCancelled(vaultId: self.id, planId: planId, reason: reason)
731 }
732
733 /// Cancel linked plan when OCO partner executes
734 /// This implements One-Cancels-Other functionality
735 access(self) fun cancelLinkedPlan(triggeringPlanId: UInt64) {
736 pre {
737 self.plans[triggeringPlanId] != nil: "Triggering plan does not exist"
738 }
739
740 let triggeringPlan = self.plans[triggeringPlanId]!
741
742 if let linkedId = triggeringPlan.linkedPlanId {
743 if self.plans[linkedId] != nil {
744 let linkedPlan = &self.plans[linkedId]! as &DCAPlan
745 linkedPlan.cancel()
746
747 emit OCOPlanCancelled(
748 vaultId: self.id,
749 planId: linkedId,
750 triggeringPlanId: triggeringPlanId
751 )
752 }
753 }
754 }
755
756 /// Execute purchase using FlowActions Swapper
757 /// Enhanced with price triggers and OCO support
758 ///
759 /// SLIPPAGE PROTECTION: This function enforces multi-layer slippage protection:
760 /// 1. PRE-EXECUTION: Caller must calculate quote.minAmount based on plan.slippageTolerance
761 /// 2. DURING EXECUTION: SwapRouter enforces amountOutMin (quote.minAmount) - reverts if not met
762 /// 3. POST-EXECUTION: Validates actualSlippage ≤ plan.slippageTolerance
763 ///
764 /// Example: If plan.slippageTolerance = 0.01 (1%), caller must set:
765 /// quote.minAmount = quote.expectedAmount * 0.99
766 ///
767 access(account) fun executePurchaseWithSwapper(
768 planId: UInt64,
769 swapper: @{DeFiActions.Swapper},
770 quote: DeFiActions.Quote,
771 currentPrice: UFix64?
772 ): UInt64 {
773 pre {
774 !DCAVaultActions.isPaused: "Protocol is paused"
775 self.plans[planId] != nil: "Plan does not exist"
776 !self.isExecuting: "Reentrancy detected"
777 }
778
779 self.isExecuting = true
780
781 // Check if plan is ready (moved out of pre block to avoid view context issues)
782 let plan = &self.plans[planId]! as &DCAPlan
783 assert(plan.isReadyForPurchase(), message: "Plan not ready")
784
785 // Check price trigger if set
786 if currentPrice != nil {
787 assert(
788 plan.checkPriceTrigger(currentPrice: currentPrice!),
789 message: "Price trigger condition not met"
790 )
791
792 // Emit event when price trigger is hit
793 if plan.triggerPrice != nil {
794 emit PriceTriggerHit(
795 vaultId: self.id,
796 planId: planId,
797 currentPrice: currentPrice!,
798 triggerPrice: plan.triggerPrice!
799 )
800 }
801 }
802
803 // Get source vault - remove from dictionary to access
804 let sourceWrapper <- self.tokenBalances.remove(key: plan.tokenPair.sourceToken)!
805 let sourceVaultRef = &sourceWrapper.vault as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}
806 assert(
807 sourceVaultRef.balance >= plan.purchaseAmount,
808 message: "Insufficient balance"
809 )
810
811 // For EVM swaps, truncate amount to 6 decimal places to avoid FlowEVMBridge
812 // rounding errors when bridging EVM-native tokens (like USDF, cbBTC).
813 // Cadence uses 8 decimals, EVM uses 18 - truncating to 6 ensures clean conversion.
814 let isEVMSwap = plan.swapperType == "UniswapV2" ||
815 plan.swapperType == "uniswap-v2" ||
816 plan.swapperType == "UniswapV3" ||
817 plan.swapperType == "uniswap-v3" ||
818 plan.swapperType == "punchswap-v2" ||
819 plan.swapperType == "punchswap-v3" ||
820 plan.swapperType == "flowswap-v3" ||
821 plan.swapperType == "evm-auto" ||
822 plan.swapperType == "evm"
823
824 // Truncate to 6 decimal places: multiply by 1,000,000, floor, divide by 1,000,000
825 let rawAmount = plan.purchaseAmount
826 let effectivePurchaseAmount = isEVMSwap
827 ? UFix64(UInt64(rawAmount * 1000000.0)) / 1000000.0
828 : rawAmount
829
830 // Calculate and deduct protocol fee (0.1% of purchase amount)
831 let feeAmount = effectivePurchaseAmount * DCAVaultActions.protocolFeePercent
832 let amountAfterFee = effectivePurchaseAmount - feeAmount
833
834 assert(feeAmount > 0.0, message: "Fee amount must be positive")
835 assert(amountAfterFee > 0.0, message: "Amount after fee must be positive")
836 assert(DCAVaultActions.protocolTreasuryAddress != Address(0x0), message: "Protocol treasury address must be set")
837
838 // Withdraw tokens (using effective amount which is truncated for EVM swaps)
839 let withdrawn <- sourceVaultRef.withdraw(amount: effectivePurchaseAmount)
840
841 // Put wrapper back (vault may still have balance after withdrawal)
842 self.tokenBalances[plan.tokenPair.sourceToken] <-! sourceWrapper
843
844 // Split out protocol fee
845 let protocolFee <- withdrawn.withdraw(amount: feeAmount)
846
847 assert(protocolFee.balance == feeAmount, message: "Fee withdrawal amount mismatch")
848
849 // Only deposit fee to treasury if it meets minimum deposit requirement
850 // For very small transactions, fee stays with user (effectively waived)
851 if feeAmount >= self.minimumDepositAmount {
852 // Deposit fee to treasury's DCA vault
853 // This automatically works for all tokens without needing receiver paths
854 let treasuryVault = getAccount(DCAVaultActions.protocolTreasuryAddress)
855 .capabilities.get<&{VaultPublic}>(DCAVaultActions.VaultPublicPath)
856 .borrow()
857 ?? panic("Treasury must have DCA vault. Run setup transaction on treasury account.")
858
859 treasuryVault.deposit(tokenType: plan.tokenPair.sourceToken, from: <-protocolFee)
860
861 emit ProtocolFeeCollected(
862 vaultId: self.id,
863 planId: planId,
864 tokenType: plan.tokenPair.sourceToken,
865 feeAmount: feeAmount,
866 purchaseAmount: effectivePurchaseAmount
867 )
868 } else {
869 // Fee is too small - return it to withdrawn amount (fee waived for micro-transactions)
870 withdrawn.deposit(from: <-protocolFee)
871 }
872
873 // Execute swap with amount after fee
874 // Note: swapper is a resource parameter that must be consumed
875 // The swap() method doesn't consume the swapper itself, so we destroy it after use
876 let swapResult <- swapper.swap(
877 inVault: <-withdrawn,
878 quote: quote
879 )
880 // Destroy swapper resource after use (swappers are typically single-use per transaction)
881 destroy swapper
882
883 let targetAmount = swapResult.balance
884
885 assert(targetAmount > 0.0, message: "Swap result amount must be positive")
886
887 // Validate minimum receive amount
888 assert(
889 targetAmount >= plan.minReceiveAmount,
890 message: "Received amount below minimum"
891 )
892
893 // This is the THIRD layer of slippage protection:
894 // 1. Connector returned raw market data
895 // 2. Caller calculated minAmount = expectedAmount * (1 - slippageTolerance)
896 // 3. SwapRouter enforced minAmount during swap (would have reverted if violated)
897 // 4. NOW: We validate the actual slippage is within user's tolerance
898 if quote.expectedAmount > 0.0 {
899 // Calculate slippage (can be positive if we got more than expected)
900 // Only check negative slippage (getting less than expected)
901 if targetAmount < quote.expectedAmount {
902 let actualSlippage = (quote.expectedAmount - targetAmount) / quote.expectedAmount
903 assert(
904 actualSlippage <= plan.slippageTolerance,
905 message: "Slippage exceeds tolerance"
906 )
907 }
908 // If targetAmount >= expectedAmount, we got bonus tokens (positive slippage) - always good!
909 }
910
911 // Deposit swapped tokens
912 if let existingTargetWrapper <- self.tokenBalances.remove(key: plan.tokenPair.targetToken) {
913 // Check if vault types match before depositing
914 let existingVaultType = existingTargetWrapper.vault.getType()
915 let swapResultType = swapResult.getType()
916
917 if existingVaultType == swapResultType {
918 // Types match - deposit into existing vault
919 let targetVaultRef = &existingTargetWrapper.vault as &{FungibleToken.Vault}
920 targetVaultRef.deposit(from: <-swapResult)
921 // Put wrapper back
922 self.tokenBalances[plan.tokenPair.targetToken] <-! existingTargetWrapper
923 } else {
924 // Types don't match - store as separate key with type identifier
925 // Put back the existing wrapper under its original key
926 self.tokenBalances[plan.tokenPair.targetToken] <-! existingTargetWrapper
927
928 // Store new vault under the actual type identifier
929 let newKey = swapResultType.identifier
930
931 // Check if there's already a vault at the new key
932 if let existingNewWrapper <- self.tokenBalances.remove(key: newKey) {
933 // Deposit into existing vault of matching type
934 let vaultRef = &existingNewWrapper.vault as &{FungibleToken.Vault}
935 vaultRef.deposit(from: <-swapResult)
936 self.tokenBalances[newKey] <-! existingNewWrapper
937 } else {
938 // Create new wrapper
939 let targetWrapper <- create TokenVaultWrapper(vault: <-swapResult)
940 self.tokenBalances[newKey] <-! targetWrapper
941 }
942 }
943 } else {
944 // Create new wrapper for target token
945 let targetWrapper <- create TokenVaultWrapper(vault: <-swapResult)
946 self.tokenBalances[plan.tokenPair.targetToken] <-! targetWrapper
947 }
948
949 // NOTE: Purchase history is no longer stored on-chain to avoid computation limit errors.
950 // Use DCAPurchaseExecuted events for history indexing instead.
951 // The PurchaseRecord struct and getPurchaseHistory() are kept for backward compatibility.
952 // Existing on-chain history remains accessible, but new purchases only emit events.
953
954 // Update plan
955 plan.recordPurchase()
956
957 emit DCAPurchaseExecuted(
958 vaultId: self.id,
959 planId: planId,
960 owner: self.ownerAddress,
961 sourceAmount: plan.purchaseAmount,
962 targetAmount: targetAmount,
963 sourceToken: plan.tokenPair.sourceToken,
964 targetToken: plan.tokenPair.targetToken,
965 swapperUsed: plan.swapperType,
966 protocolFee: feeAmount
967 )
968
969 // OCO: Cancel linked plan if this execution succeeded
970 if plan.isOCO() {
971 self.cancelLinkedPlan(triggeringPlanId: planId)
972 }
973
974 if plan.status == PlanStatus.completed {
975 emit DCAPlanCompleted(vaultId: self.id, planId: planId)
976 }
977
978 self.isExecuting = false
979
980 // Return 0 as placeholder - gas tracking removed to fix interface compatibility
981 return 0
982 }
983
984 /// Get token balance
985 /// Note: This is a view function, so we can't move resources
986 /// We need to check if the key exists and borrow the reference
987 access(all) fun getTokenBalance(tokenType: String): UFix64 {
988 // Check if key exists first (non-destructive check)
989 if self.tokenBalances.containsKey(tokenType) {
990 // Temporarily remove to access, then put back
991 let wrapper <- self.tokenBalances.remove(key: tokenType)!
992 let vaultRef = &wrapper.vault as &{FungibleToken.Vault}
993 let balance = vaultRef.balance
994 // Put wrapper back
995 self.tokenBalances[tokenType] <-! wrapper
996 return balance
997 }
998 return 0.0
999 }
1000
1001 /// Get all balances
1002 access(all) fun getAllBalances(): {String: UFix64} {
1003 let balances: {String: UFix64} = {}
1004 for tokenType in self.tokenBalances.keys {
1005 balances[tokenType] = self.getTokenBalance(tokenType: tokenType)
1006 }
1007 return balances
1008 }
1009
1010 /// Get plan
1011 access(all) fun getPlan(planId: UInt64): DCAPlan? {
1012 return self.plans[planId]
1013 }
1014
1015 /// Get all plans
1016 access(all) fun getAllPlans(): {UInt64: DCAPlan} {
1017 return self.plans
1018 }
1019
1020 /// Get purchase history
1021 access(all) fun getPurchaseHistory(): [PurchaseRecord] {
1022 return self.purchaseHistory
1023 }
1024
1025 /// Get active plans
1026 access(all) fun getActivePlans(): [UInt64] {
1027 let activePlanIds: [UInt64] = []
1028 for planId in self.plans.keys {
1029 if (&self.plans[planId]! as &DCAPlan).isActive() {
1030 activePlanIds.append(planId)
1031 }
1032 }
1033 return activePlanIds
1034 }
1035
1036 /// Emergency withdraw all funds for a specific token type
1037 /// This function allows the vault owner to bypass all DCA logic and withdraw all funds immediately
1038 /// Use cases:
1039 /// - Critical bug discovered in the contract
1040 /// - User needs immediate access to funds
1041 /// - Migrating to a new contract version
1042 ///
1043 /// Security:
1044 /// - Only callable by the vault owner (resource owner)
1045 /// - No admin access - admin cannot call this for any user
1046 /// - Cancels all active plans for the specified token
1047 /// - Cannot be called while protocol is paused (emergency pause trumps emergency withdraw)
1048 ///
1049 /// @param tokenType: The type identifier of the token to withdraw (e.g., Type<@FlowToken.Vault>())
1050 /// @param receiver: The FungibleToken receiver capability to deposit withdrawn funds
1051 ///
1052 access(FungibleToken.Withdraw) fun emergencyWithdrawAll(
1053 tokenType: Type,
1054 receiver: &{FungibleToken.Receiver}
1055 ) {
1056 pre {
1057 !DCAVaultActions.isPaused: "Protocol is paused - cannot perform emergency withdraw"
1058 self.owner != nil: "Vault must have an owner"
1059 }
1060
1061 let tokenTypeIdentifier = tokenType.identifier
1062
1063 // Step 1: Cancel all plans using this token as source
1064 let plansToCancel: [UInt64] = []
1065 for planId in self.plans.keys {
1066 let plan = &self.plans[planId]! as &DCAPlan
1067 if plan.tokenPair.sourceTokenType == tokenType {
1068 plansToCancel.append(planId)
1069 }
1070 }
1071
1072 // Cancel each plan (this also withdraws any remaining balance for that plan)
1073 for planId in plansToCancel {
1074 // Cancel plan - this will withdraw remaining funds to receiver
1075 let plan = &self.plans[planId]! as &DCAPlan
1076
1077 // Mark plan as cancelled
1078 plan.cancel()
1079
1080 emit DCAPlanCancelled(
1081 vaultId: self.id,
1082 planId: planId,
1083 reason: "Emergency withdrawal"
1084 )
1085 }
1086
1087 // Step 2: Withdraw all remaining balance of this token type
1088 var totalWithdrawn: UFix64 = 0.0
1089
1090 if let wrapper <- self.tokenBalances.remove(key: tokenTypeIdentifier) {
1091 let balance = wrapper.vault.balance
1092 if balance > 0.0 {
1093 let withdrawn <- wrapper.vault.withdraw(amount: balance)
1094 totalWithdrawn = withdrawn.balance
1095 receiver.deposit(from: <-withdrawn)
1096 }
1097
1098 // Destroy the empty wrapper
1099 destroy wrapper
1100 }
1101
1102 // Emit emergency withdrawal event
1103 emit EmergencyWithdrawal(
1104 vaultId: self.id,
1105 owner: self.owner!.address,
1106 tokenType: tokenTypeIdentifier,
1107 amount: totalWithdrawn,
1108 timestamp: getCurrentBlock().timestamp
1109 )
1110 }
1111 }
1112
1113 /// Public interface
1114 access(all) resource interface VaultPublic {
1115 access(all) fun getTokenBalance(tokenType: String): UFix64
1116 access(all) fun getAllBalances(): {String: UFix64}
1117 access(all) fun getPlan(planId: UInt64): DCAPlan?
1118 access(all) fun getAllPlans(): {UInt64: DCAPlan}
1119 access(all) fun getPurchaseHistory(): [PurchaseRecord]
1120 access(all) fun deposit(tokenType: String, from: @{FungibleToken.Vault})
1121 }
1122
1123 /// Create vault
1124 access(all) fun createVault(owner: Address): @Vault {
1125 let vault <- create Vault(owner: owner)
1126 emit DCAVaultCreated(vaultId: vault.id, owner: vault.ownerAddress)
1127 return <-vault
1128 }
1129
1130 /// Get current protocol fee percentage
1131 access(all) fun getProtocolFeePercent(): UFix64 {
1132 return self.protocolFeePercent
1133 }
1134
1135 /// Calculate fee for a given amount
1136 access(all) fun calculateFee(_ amount: UFix64): UFix64 {
1137 return amount * self.protocolFeePercent
1138 }
1139
1140 init() {
1141 self.VaultStoragePath = /storage/DCAVaultActions
1142 self.VaultPublicPath = /public/DCAVaultActions
1143 self.AdminStoragePath = /storage/DCAVaultActionsAdmin
1144
1145 // Initialize protocol fee to 0.1%
1146 self.protocolFeePercent = 0.001 // 0.1%
1147
1148 // Set treasury to deployer initially (can be updated later by admin)
1149 self.protocolTreasuryAddress = self.account.address
1150
1151 // Initialize protocol as unpaused
1152 self.isPaused = false
1153
1154 // This ensures only the deployer can update protocol configuration
1155 self.account.storage.save(<-create Admin(ownerAddress: self.account.address), to: self.AdminStoragePath)
1156 }
1157}
1158