Smart Contract

DCATransactionHandlerV3

A.ca7ee55e4fc3251a.DCATransactionHandlerV3

Valid From

135,596,796

Deployed

5d ago
Feb 23, 2026, 12:43:39 AM UTC

Dependents

6 imports
1import FlowTransactionScheduler from 0xe467b9dd11fa00df
2import FlowTransactionSchedulerUtils from 0xe467b9dd11fa00df
3import DCAControllerV3 from 0xca7ee55e4fc3251a
4import DCAPlanV3 from 0xca7ee55e4fc3251a
5import DeFiMath from 0xca7ee55e4fc3251a
6import FungibleToken from 0xf233dcee88fe0abe
7import FlowToken from 0x1654653399040a61
8import EVM from 0xe467b9dd11fa00df
9import UniswapV3SwapperConnector from 0xca7ee55e4fc3251a
10import EVMTokenRegistry from 0xca7ee55e4fc3251a
11import DeFiActions from 0xca7ee55e4fc3251a
12
13/// DCATransactionHandler: Scheduled transaction handler for DCA execution (EVM DEX swaps)
14///
15/// This contract implements the FlowTransactionScheduler.TransactionHandler interface
16/// to enable autonomous DCA plan execution via Forte Scheduled Transactions.
17///
18/// Architecture:
19/// 1. User creates DCA plan with DCAController
20/// 2. Plan schedules execution via FlowTransactionScheduler
21/// 3. At scheduled time, scheduler calls this handler's executeTransaction()
22/// 4. Handler:
23///    - Validates plan is ready
24///    - Creates UniswapV3Swapper with COA capability
25///    - Executes swap on Flow EVM (FlowSwap V3 → PunchSwap V2 fallback)
26///    - Updates plan accounting
27///    - Reschedules next execution if plan is still active
28///
29/// Educational Notes:
30/// - Implements FlowTransactionScheduler.TransactionHandler interface
31/// - Access control: executeTransaction is access(FlowTransactionScheduler.Execute)
32/// - Uses DeFiActions.Swapper pattern for protocol-agnostic swaps
33/// - COA-based EVM execution (no manual wallet approvals)
34/// - Stores metadata via getViews/resolveView for discoverability
35access(all) contract DCATransactionHandlerV3 {
36
37    /// Configuration for scheduling next execution
38    access(all) struct ScheduleConfig {
39        access(all) let schedulerManagerCap: Capability<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>
40        access(all) let priority: FlowTransactionScheduler.Priority
41        access(all) let executionEffort: UInt64
42
43        init(
44            schedulerManagerCap: Capability<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>,
45            priority: FlowTransactionScheduler.Priority,
46            executionEffort: UInt64
47        ) {
48            self.schedulerManagerCap = schedulerManagerCap
49            self.priority = priority
50            self.executionEffort = executionEffort
51        }
52    }
53
54    /// Transaction data passed to handler
55    access(all) struct DCATransactionData {
56        access(all) let planId: UInt64
57        access(all) let scheduleConfig: ScheduleConfig
58
59        init(planId: UInt64, scheduleConfig: ScheduleConfig) {
60            self.planId = planId
61            self.scheduleConfig = scheduleConfig
62        }
63    }
64
65    /// Event emitted when handler starts execution
66    access(all) event HandlerExecutionStarted(
67        transactionId: UInt64,
68        planId: UInt64,
69        owner: Address,
70        timestamp: UFix64
71    )
72
73    /// Event emitted when handler completes successfully
74    access(all) event HandlerExecutionCompleted(
75        transactionId: UInt64,
76        planId: UInt64,
77        owner: Address,
78        amountIn: UFix64,
79        amountOut: UFix64,
80        nextExecutionScheduled: Bool,
81        timestamp: UFix64
82    )
83
84    /// Event emitted when handler execution fails
85    access(all) event HandlerExecutionFailed(
86        transactionId: UInt64,
87        planId: UInt64?,
88        owner: Address?,
89        reason: String,
90        timestamp: UFix64
91    )
92
93    /// Event emitted when next execution scheduling fails
94    access(all) event NextExecutionSchedulingFailed(
95        planId: UInt64,
96        owner: Address,
97        reason: String,
98        timestamp: UFix64
99    )
100
101    /// Handler resource that implements the Scheduled Transaction interface
102    ///
103    /// Each user has one instance of this stored in their account.
104    /// The scheduler calls executeTransaction() when a DCA plan is due.
105    access(all) resource Handler: FlowTransactionScheduler.TransactionHandler {
106
107        /// Reference to the user's DCA controller
108        /// This capability allows the handler to access and update plans
109        access(self) let controllerCap: Capability<auth(DCAControllerV3.Owner) &DCAControllerV3.Controller>
110
111        init(controllerCap: Capability<auth(DCAControllerV3.Owner) &DCAControllerV3.Controller>) {
112            pre {
113                controllerCap.check(): "Invalid controller capability"
114            }
115            self.controllerCap = controllerCap
116        }
117
118        /// Main execution entrypoint called by FlowTransactionScheduler
119        ///
120        /// @param id: Transaction ID from the scheduler
121        /// @param data: Encoded plan data (contains planId)
122        ///
123        /// This function has restricted access - only FlowTransactionScheduler can call it
124        access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) {
125            let timestamp = getCurrentBlock().timestamp
126
127            // Parse transaction data
128            let txData = data as! DCATransactionData? ?? panic("Invalid transaction data format")
129            let planId = txData.planId
130            let scheduleConfig = txData.scheduleConfig
131            let ownerAddress = self.controllerCap.address
132
133            emit HandlerExecutionStarted(
134                transactionId: id,
135                planId: planId,
136                owner: ownerAddress,
137                timestamp: timestamp
138            )
139
140            // Borrow controller
141            let controller = self.controllerCap.borrow()
142                ?? panic("Could not borrow DCA controller")
143
144            // Borrow plan
145            let planRef = controller.borrowPlan(id: planId)
146                ?? panic("Could not borrow plan with ID: ".concat(planId.toString()))
147
148            // Validate plan is ready
149            if !planRef.isReadyForExecution() {
150                emit HandlerExecutionFailed(
151                    transactionId: id,
152                    planId: planId,
153                    owner: ownerAddress,
154                    reason: "Plan not ready for execution",
155                    timestamp: timestamp
156                )
157                return
158            }
159
160            // Check max executions
161            if planRef.hasReachedMaxExecutions() {
162                emit HandlerExecutionFailed(
163                    transactionId: id,
164                    planId: planId,
165                    owner: ownerAddress,
166                    reason: "Plan has reached maximum executions",
167                    timestamp: timestamp
168                )
169                return
170            }
171
172            // Get vault capabilities
173            let sourceVaultCap = controller.getSourceVaultCapability()
174                ?? panic("Source vault capability not configured")
175
176            let targetVaultCap = controller.getTargetVaultCapability()
177                ?? panic("Target vault capability not configured")
178
179            let coaCap = controller.getCOACapability()
180                ?? panic("COA capability not configured")
181
182            // Validate capabilities
183            if !sourceVaultCap.check() {
184                emit HandlerExecutionFailed(
185                    transactionId: id,
186                    planId: planId,
187                    owner: ownerAddress,
188                    reason: "Invalid source vault capability",
189                    timestamp: timestamp
190                )
191                return
192            }
193
194            if !targetVaultCap.check() {
195                emit HandlerExecutionFailed(
196                    transactionId: id,
197                    planId: planId,
198                    owner: ownerAddress,
199                    reason: "Invalid target vault capability",
200                    timestamp: timestamp
201                )
202                return
203            }
204
205            if !coaCap.check() {
206                emit HandlerExecutionFailed(
207                    transactionId: id,
208                    planId: planId,
209                    owner: ownerAddress,
210                    reason: "Invalid COA capability",
211                    timestamp: timestamp
212                )
213                return
214            }
215
216            // Execute the swap
217            let result = self.executeSwap(
218                planRef: planRef,
219                sourceVaultCap: sourceVaultCap,
220                targetVaultCap: targetVaultCap,
221                coaCap: coaCap
222            )
223
224            if result.success {
225                // Record successful execution
226                planRef.recordExecution(
227                    amountIn: result.amountIn!,
228                    amountOut: result.amountOut!
229                )
230
231                // Schedule next execution if plan is still active
232                var nextScheduled = false
233                if planRef.status == DCAPlanV3.PlanStatus.Active && !planRef.hasReachedMaxExecutions() {
234                    planRef.scheduleNextExecution()
235
236                    // Attempt to schedule next execution via Manager
237                    let schedulingResult = self.scheduleNextExecution(
238                        planId: planId,
239                        nextExecutionTime: planRef.nextExecutionTime,
240                        scheduleConfig: scheduleConfig
241                    )
242
243                    nextScheduled = schedulingResult
244                }
245
246                emit HandlerExecutionCompleted(
247                    transactionId: id,
248                    planId: planId,
249                    owner: ownerAddress,
250                    amountIn: result.amountIn!,
251                    amountOut: result.amountOut!,
252                    nextExecutionScheduled: nextScheduled,
253                    timestamp: timestamp
254                )
255            } else {
256                emit HandlerExecutionFailed(
257                    transactionId: id,
258                    planId: planId,
259                    owner: ownerAddress,
260                    reason: result.errorMessage ?? "Unknown error",
261                    timestamp: timestamp
262                )
263            }
264        }
265
266        /// Execute swap using UniswapV3SwapperConnector on Flow EVM
267        ///
268        /// This uses production EVM DEX infrastructure (FlowSwap V3 → PunchSwap V2 fallback)
269        /// with COA-based execution and FlowEVMBridge for token conversion.
270        ///
271        /// @param planRef: Reference to the DCA plan
272        /// @param sourceVaultCap: Capability to withdraw from source vault
273        /// @param targetVaultCap: Capability to deposit to target vault
274        /// @param coaCap: COA capability for EVM operations
275        /// @return ExecutionResult with success status and amounts
276        access(self) fun executeSwap(
277            planRef: &DCAPlanV3.Plan,
278            sourceVaultCap: Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>,
279            targetVaultCap: Capability<&{FungibleToken.Receiver}>,
280            coaCap: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>
281        ): ExecutionResult {
282            // Get amount to invest
283            let amountIn = planRef.amountPerInterval
284
285            // Borrow source vault and check balance
286            let sourceVault = sourceVaultCap.borrow()
287                ?? panic("Could not borrow source vault")
288
289            if sourceVault.balance < amountIn {
290                return ExecutionResult(
291                    success: false,
292                    amountIn: nil,
293                    amountOut: nil,
294                    errorMessage: "Insufficient balance in source vault. Required: ".concat(amountIn.toString()).concat(", Available: ").concat(sourceVault.balance.toString())
295                )
296            }
297
298            // Get EVM addresses for source and target tokens
299            let sourceEVMAddr = EVMTokenRegistry.getEVMAddress(planRef.sourceTokenType)
300            if sourceEVMAddr == nil {
301                return ExecutionResult(
302                    success: false,
303                    amountIn: nil,
304                    amountOut: nil,
305                    errorMessage: "Source token not registered in EVMTokenRegistry: ".concat(planRef.sourceTokenType.identifier)
306                )
307            }
308
309            let targetEVMAddr = EVMTokenRegistry.getEVMAddress(planRef.targetTokenType)
310            if targetEVMAddr == nil {
311                return ExecutionResult(
312                    success: false,
313                    amountIn: nil,
314                    amountOut: nil,
315                    errorMessage: "Target token not registered in EVMTokenRegistry: ".concat(planRef.targetTokenType.identifier)
316                )
317            }
318
319            // Create EVM token path (source → target)
320            let evmTokenPath: [EVM.EVMAddress] = [sourceEVMAddr!, targetEVMAddr!]
321
322            // Use 3000 (0.3%) fee tier for most pairs (standard UniswapV3 fee)
323            let feePath: [UInt32] = [3000]
324
325            // Create swapper with defaults (FlowSwap V3 router)
326            let swapper <- UniswapV3SwapperConnector.createSwapperWithDefaults(
327                tokenPath: evmTokenPath,
328                feePath: feePath,
329                inVaultType: planRef.sourceTokenType,
330                outVaultType: planRef.targetTokenType,
331                coaCapability: coaCap
332            )
333
334            // Get quote from swapper
335            let quote = swapper.getQuote(
336                fromTokenType: planRef.sourceTokenType,
337                toTokenType: planRef.targetTokenType,
338                amount: amountIn
339            )
340
341            // Apply user's slippage tolerance to quote
342            // maxSlippageBps is in basis points (100 = 1%)
343            let slippageMultiplier = UInt64(10000) - planRef.maxSlippageBps
344            let minAmountOut = quote.expectedAmount * UFix64(slippageMultiplier) / 10000.0
345
346            // Create adjusted quote with user's slippage protection
347            let adjustedQuote = DeFiActions.Quote(
348                expectedAmount: quote.expectedAmount,
349                minAmount: minAmountOut,
350                slippageTolerance: UFix64(planRef.maxSlippageBps) / 10000.0,
351                deadline: getCurrentBlock().timestamp + 300.0, // 5 minutes
352                data: quote.data
353            )
354
355            // Withdraw tokens to swap
356            let tokensToSwap <- sourceVault.withdraw(amount: amountIn)
357
358            // Execute swap
359            let swappedTokens <- swapper.swap(
360                inVault: <-tokensToSwap,
361                quote: adjustedQuote
362            )
363
364            // Clean up swapper resource
365            destroy swapper
366
367            // Get actual output amount
368            let actualAmountOut = swappedTokens.balance
369
370            // Deposit to target vault
371            let targetVault = targetVaultCap.borrow()
372                ?? panic("Could not borrow target vault")
373
374            targetVault.deposit(from: <-swappedTokens)
375
376            return ExecutionResult(
377                success: true,
378                amountIn: amountIn,
379                amountOut: actualAmountOut,
380                errorMessage: nil
381            )
382        }
383
384        /// Schedule the next execution via FlowTransactionScheduler Manager
385        ///
386        /// This function handles the recursive scheduling pattern using the Manager approach:
387        /// 1. Extract ScheduleConfig from transaction data (contains Manager capability)
388        /// 2. Estimate fees for next execution
389        /// 3. Withdraw FLOW fees from user's controller
390        /// 4. Call manager.scheduleByHandler() to schedule next execution
391        ///
392        /// @param planId: Plan ID to include in transaction data
393        /// @param nextExecutionTime: Timestamp for next execution
394        /// @param scheduleConfig: Configuration containing Manager capability
395        /// @return Bool indicating if scheduling succeeded
396        access(self) fun scheduleNextExecution(
397            planId: UInt64,
398            nextExecutionTime: UFix64?,
399            scheduleConfig: ScheduleConfig
400        ): Bool {
401            let ownerAddress = self.controllerCap.address
402            let timestamp = getCurrentBlock().timestamp
403
404            // Verify nextExecutionTime is provided
405            if nextExecutionTime == nil {
406                emit NextExecutionSchedulingFailed(
407                    planId: planId,
408                    owner: ownerAddress,
409                    reason: "Next execution time not set",
410                    timestamp: timestamp
411                )
412                return false
413            }
414
415            // Prepare transaction data with plan ID and schedule config
416            let transactionData = DCATransactionData(
417                planId: planId,
418                scheduleConfig: scheduleConfig
419            )
420
421            // Estimate fees for the next execution
422            let estimate = FlowTransactionScheduler.estimate(
423                data: transactionData,
424                timestamp: nextExecutionTime!,
425                priority: scheduleConfig.priority,
426                executionEffort: scheduleConfig.executionEffort
427            )
428
429            // Check if estimation was successful
430            assert(
431                estimate.timestamp != nil || scheduleConfig.priority == FlowTransactionScheduler.Priority.Low,
432                message: estimate.error ?? "Fee estimation failed"
433            )
434
435            // Borrow the controller to access fee vault
436            let controller = self.controllerCap.borrow()
437            if controller == nil {
438                emit NextExecutionSchedulingFailed(
439                    planId: planId,
440                    owner: ownerAddress,
441                    reason: "Could not borrow controller",
442                    timestamp: timestamp
443                )
444                return false
445            }
446
447            // Get fee vault capability from controller
448            let feeVaultCap = controller!.getFeeVaultCapability()
449            if feeVaultCap == nil || !feeVaultCap!.check() {
450                emit NextExecutionSchedulingFailed(
451                    planId: planId,
452                    owner: ownerAddress,
453                    reason: "Fee vault capability invalid or not set",
454                    timestamp: timestamp
455                )
456                return false
457            }
458
459            // Withdraw fees
460            let feeVault = feeVaultCap!.borrow()
461            if feeVault == nil {
462                emit NextExecutionSchedulingFailed(
463                    planId: planId,
464                    owner: ownerAddress,
465                    reason: "Could not borrow fee vault",
466                    timestamp: timestamp
467                )
468                return false
469            }
470
471            let feeAmount = estimate.flowFee ?? 0.0
472            if feeVault!.balance < feeAmount {
473                emit NextExecutionSchedulingFailed(
474                    planId: planId,
475                    owner: ownerAddress,
476                    reason: "Insufficient FLOW for fees. Required: ".concat(feeAmount.toString()).concat(", Available: ").concat(feeVault!.balance.toString()),
477                    timestamp: timestamp
478                )
479                return false
480            }
481
482            let fees <- feeVault!.withdraw(amount: feeAmount)
483
484            // Borrow scheduler manager
485            let schedulerManager = scheduleConfig.schedulerManagerCap.borrow()
486            if schedulerManager == nil {
487                destroy fees
488                emit NextExecutionSchedulingFailed(
489                    planId: planId,
490                    owner: ownerAddress,
491                    reason: "Could not borrow scheduler manager",
492                    timestamp: timestamp
493                )
494                return false
495            }
496
497            // Use scheduleByHandler() on the Manager to schedule next execution
498            // This works because the handler was previously scheduled through this Manager
499            let scheduledId = schedulerManager!.scheduleByHandler(
500                handlerTypeIdentifier: self.getType().identifier,
501                handlerUUID: self.uuid,
502                data: transactionData,
503                timestamp: nextExecutionTime!,
504                priority: scheduleConfig.priority,
505                executionEffort: scheduleConfig.executionEffort,
506                fees: <-fees as! @FlowToken.Vault
507            )
508
509            // scheduledId > 0 means success
510            if scheduledId == 0 {
511                emit NextExecutionSchedulingFailed(
512                    planId: planId,
513                    owner: ownerAddress,
514                    reason: "Manager.scheduleByHandler() returned 0 (failed to schedule)",
515                    timestamp: timestamp
516                )
517                return false
518            }
519
520            return true
521        }
522
523        /// Get supported view types (for resource metadata)
524        access(all) view fun getViews(): [Type] {
525            return [Type<StoragePath>(), Type<PublicPath>()]
526        }
527
528        /// Resolve a specific view type
529        access(all) fun resolveView(_ view: Type): AnyStruct? {
530            switch view {
531                case Type<StoragePath>():
532                    return /storage/DCATransactionHandlerV3
533                case Type<PublicPath>():
534                    return /public/DCATransactionHandlerV3
535                default:
536                    return nil
537            }
538        }
539    }
540
541    /// Result struct for swap execution
542    access(all) struct ExecutionResult {
543        access(all) let success: Bool
544        access(all) let amountIn: UFix64?
545        access(all) let amountOut: UFix64?
546        access(all) let errorMessage: String?
547
548        init(success: Bool, amountIn: UFix64?, amountOut: UFix64?, errorMessage: String?) {
549            self.success = success
550            self.amountIn = amountIn
551            self.amountOut = amountOut
552            self.errorMessage = errorMessage
553        }
554    }
555
556    /// Factory function to create a new handler
557    ///
558    /// @param controllerCap: Capability to the user's DCA controller
559    /// @return New handler resource
560    access(all) fun createHandler(
561        controllerCap: Capability<auth(DCAControllerV3.Owner) &DCAControllerV3.Controller>
562    ): @Handler {
563        return <- create Handler(controllerCap: controllerCap)
564    }
565
566    /// Helper function to create schedule configuration
567    access(all) fun createScheduleConfig(
568        schedulerManagerCap: Capability<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>,
569        priority: FlowTransactionScheduler.Priority,
570        executionEffort: UInt64
571    ): ScheduleConfig {
572        return ScheduleConfig(
573            schedulerManagerCap: schedulerManagerCap,
574            priority: priority,
575            executionEffort: executionEffort
576        )
577    }
578
579    /// Helper function to create transaction data
580    access(all) fun createTransactionData(
581        planId: UInt64,
582        scheduleConfig: ScheduleConfig
583    ): DCATransactionData {
584        return DCATransactionData(
585            planId: planId,
586            scheduleConfig: scheduleConfig
587        )
588    }
589}
590