Smart Contract

DCAVaultActions

A.17ae3b1b0b0d50db.DCAVaultActions

Valid From

137,012,143

Deployed

6d ago
Feb 21, 2026, 05:42:05 PM UTC

Dependents

56 imports
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