Smart Contract
DCATransactionHandlerV3
A.ca7ee55e4fc3251a.DCATransactionHandlerV3
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