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