Smart Contract

DCATransactionHandlerV2

A.17ae3b1b0b0d50db.DCATransactionHandlerV2

Valid From

134,184,115

Deployed

6d ago
Feb 22, 2026, 02:18:41 AM UTC

Dependents

2 imports
1import FlowTransactionScheduler from 0xe467b9dd11fa00df
2import FlowTransactionSchedulerUtils from 0xe467b9dd11fa00df
3import DCAVaultActions from 0x17ae3b1b0b0d50db
4import DeFiActions from 0x17ae3b1b0b0d50db
5import IncrementFiSwapperConnector from 0x17ae3b1b0b0d50db
6import UniswapV2SwapperConnectorV2 from 0x17ae3b1b0b0d50db
7import UniswapV3SwapperConnectorV2 from 0x17ae3b1b0b0d50db
8import EVMTokenRegistry from 0x17ae3b1b0b0d50db
9import SwapRouter from 0xa6850776a94e6551
10import SwapFactory from 0xb063c16cac85dbd1
11import FlowToken from 0x1654653399040a61
12import FungibleToken from 0xf233dcee88fe0abe
13import PriceOracle from 0x17ae3b1b0b0d50db
14import SimplePriceOracle from 0x17ae3b1b0b0d50db
15import EVM from 0xe467b9dd11fa00df
16
17/// DCATransactionHandlerV2: Forte scheduled transaction handler for DCA purchases
18///
19/// This V2 contract implements the FlowTransactionScheduler.TransactionHandler interface
20/// to enable automated, recurring DCA purchases executed by Forte's on-chain scheduler.
21///
22/// V2 Features:
23/// - Full COA-based EVM swap execution
24/// - Support for UniswapV2 and UniswapV3 on Flow EVM
25/// - Token bridging between Cadence and EVM
26/// - Cron-style recurring purchase scheduling
27/// - FlowActions integration for DEX-agnostic swaps
28///
29access(all) contract DCATransactionHandlerV2 {
30
31    /// Events
32    access(all) event DCATransactionExecuted(planId: UInt64, scheduledTxId: UInt64, amountSpent: UFix64, amountReceived: UFix64)
33    access(all) event DCATransactionScheduled(planId: UInt64, executionTime: UFix64, priority: String)
34    access(all) event DCASeriesCompleted(planId: UInt64, totalExecutions: UInt64)
35    access(all) event DCATransactionFailed(planId: UInt64, scheduledTxId: UInt64, reason: String)
36    access(all) event HandlerCreated(address: Address)
37    
38    /// EVM-specific events for monitoring
39    access(all) event EVMSwapExecuted(planId: UInt64, dexType: String, amountIn: UFix64, amountOut: UFix64)
40    access(all) event EVMSwapFailed(planId: UInt64, dexType: String, reason: String)
41
42    /// Storage paths
43    access(all) let HandlerStoragePath: StoragePath
44    access(all) let HandlerPublicPath: PublicPath
45
46    /// Execution data structure passed to scheduled transactions
47    /// NOTE: Minimal design to stay under Forte's 1KB data size limit
48    /// V2 includes COA capability for EVM swaps
49    access(all) struct ExecutionData {
50        /// DCA plan identifier
51        access(all) let planId: UInt64
52
53        /// Address of the vault owner
54        access(all) let vaultOwner: Address
55
56        /// Execution priority
57        access(all) let priority: FlowTransactionScheduler.Priority
58
59        /// Execution effort (gas budget)
60        access(all) let executionEffort: UInt64
61
62        /// For recurring DCA: interval between purchases
63        access(all) let interval: UFix64?
64
65        /// For recurring DCA: maximum executions (nil = unlimited)
66        access(all) let maxExec: UInt64?
67
68        /// Current execution count
69        access(all) var count: UInt64
70
71        /// Base timestamp for calculating next execution
72        access(all) let baseTime: UFix64
73
74        /// Required capabilities (minimal set)
75        access(all) let feeCap: Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>
76        access(all) let mgrCap: Capability<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>
77        access(all) let handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>
78        
79        /// COA capability for EVM swaps (required for V2 EVM connectors)
80        access(all) let coaCap: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>?
81
82        init(
83            planId: UInt64,
84            vaultOwner: Address,
85            priority: FlowTransactionScheduler.Priority,
86            executionEffort: UInt64,
87            interval: UFix64?,
88            maxExec: UInt64?,
89            count: UInt64,
90            baseTime: UFix64,
91            feeCap: Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>,
92            mgrCap: Capability<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>,
93            handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>,
94            coaCap: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>?
95        ) {
96            self.planId = planId
97            self.vaultOwner = vaultOwner
98            self.priority = priority
99            self.executionEffort = executionEffort
100            self.interval = interval
101            self.maxExec = maxExec
102            self.count = count
103            self.baseTime = baseTime
104            self.feeCap = feeCap
105            self.mgrCap = mgrCap
106            self.handlerCap = handlerCap
107            self.coaCap = coaCap
108        }
109
110        /// Check if this DCA series should continue
111        access(all) fun shouldContinue(): Bool {
112            if let max = self.maxExec {
113                return self.count < max
114            }
115            return true
116        }
117
118        /// Get next execution time (cron-style)
119        access(all) fun getNextExecutionTime(): UFix64 {
120            if let interval = self.interval {
121                let elapsedTime = getCurrentBlock().timestamp - self.baseTime
122                let elapsedIntervals = UInt64(elapsedTime / interval)
123                return self.baseTime + (UFix64(elapsedIntervals + 1) * interval)
124            }
125            panic("No interval set for recurring DCA")
126        }
127
128        /// Create updated config for next execution
129        access(all) fun withIncrementedCount(): ExecutionData {
130            return ExecutionData(
131                planId: self.planId,
132                vaultOwner: self.vaultOwner,
133                priority: self.priority,
134                executionEffort: self.executionEffort,
135                interval: self.interval,
136                maxExec: self.maxExec,
137                count: self.count + 1,
138                baseTime: self.baseTime,
139                feeCap: self.feeCap,
140                mgrCap: self.mgrCap,
141                handlerCap: self.handlerCap,
142                coaCap: self.coaCap
143            )
144        }
145    }
146
147    /// Transaction Handler resource
148    access(all) resource Handler: FlowTransactionScheduler.TransactionHandler {
149
150        /// Execute a scheduled DCA purchase
151        access(FlowTransactionScheduler.Execute) fun executeTransaction(
152            id: UInt64,
153            data: AnyStruct?
154        ) {
155            pre {
156                id > 0: "Transaction ID must be positive"
157                data != nil: "Execution data cannot be nil"
158            }
159
160            // Parse execution data
161            let executionData = data as! ExecutionData?
162                ?? panic("Invalid execution data")
163
164            // Get vault reference from owner's account
165            let vaultCap = getAccount(executionData.vaultOwner)
166                .capabilities.get<&DCAVaultActions.Vault>(DCAVaultActions.VaultPublicPath)
167            let vault = vaultCap.borrow()
168                ?? panic("Could not borrow vault reference")
169
170            // Get the DCA plan
171            let plan = vault.getPlan(planId: executionData.planId)
172                ?? panic("Plan not found")
173
174            // Verify plan is active
175            assert(plan.status == DCAVaultActions.PlanStatus.active, message: "Plan is not active")
176            assert(plan.isReadyForPurchase(), message: "Plan not ready for purchase")
177
178            // Check trigger conditions if set
179            var currentPrice: UFix64? = nil
180            if plan.triggerType != DCAVaultActions.TriggerType.none {
181                let targetSymbol = plan.tokenPair.targetToken
182                
183                // Fetch price with 5 minute staleness tolerance
184                if let priceDataAny = SimplePriceOracle.getPriceWithMaxAge(symbol: targetSymbol, maxAge: 300.0) {
185                    if let priceData = priceDataAny as? SimplePriceOracle.PriceData {
186                        currentPrice = priceData.price
187                        
188                        if let triggerPrice = plan.triggerPrice {
189                            if plan.triggerDirection == DCAVaultActions.TriggerDirection.buyAbove {
190                                assert(currentPrice! >= triggerPrice, message: "Price condition not met")
191                            } else if plan.triggerDirection == DCAVaultActions.TriggerDirection.sellBelow {
192                                assert(currentPrice! <= triggerPrice, message: "Price condition not met")
193                            }
194                        }
195                    } else {
196                        panic("Invalid price data format")
197                    }
198                } else {
199                    panic("Unable to fetch current price for ".concat(targetSymbol))
200                }
201            }
202
203            // Create swapper based on type from plan with COA capability
204            let swapper <- self.createSwapper(plan, coaCap: executionData.coaCap)
205
206            // Calculate amount after protocol fee (0.1%)
207            let protocolFeePercent = DCAVaultActions.getProtocolFeePercent()
208            let feeAmount = plan.purchaseAmount * protocolFeePercent
209            let amountAfterFee = plan.purchaseAmount - feeAmount
210
211            // Get quote for the swap
212            let quote = swapper.getQuote(
213                fromTokenType: plan.tokenPair.sourceTokenType,
214                toTokenType: plan.tokenPair.targetTokenType,
215                amount: amountAfterFee
216            )
217
218            // Execute the purchase through vault
219            vault.executePurchaseWithSwapper(
220                planId: executionData.planId,
221                swapper: <-swapper,
222                quote: quote,
223                currentPrice: currentPrice
224            )
225
226            emit DCATransactionExecuted(
227                planId: executionData.planId,
228                scheduledTxId: id,
229                amountSpent: plan.purchaseAmount,
230                amountReceived: quote.expectedAmount
231            )
232
233            // Check if EVM swap
234            if self.isEVMSwapper(plan.swapperType) {
235                emit EVMSwapExecuted(
236                    planId: executionData.planId,
237                    dexType: plan.swapperType,
238                    amountIn: plan.purchaseAmount,
239                    amountOut: quote.expectedAmount
240                )
241            }
242
243            // Re-schedule next execution if this is a recurring DCA and should continue
244            if executionData.shouldContinue() && plan.purchasesExecuted < plan.totalPurchases {
245                self.rescheduleNext(executionData)
246            } else {
247                emit DCASeriesCompleted(
248                    planId: executionData.planId,
249                    totalExecutions: executionData.count + 1
250                )
251            }
252        }
253
254        /// Create swapper for the specified DEX
255        /// V2: Uses COA-based connectors for EVM swaps
256        access(self) fun createSwapper(
257            _ plan: DCAVaultActions.DCAPlan,
258            coaCap: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>?
259        ): @{DeFiActions.Swapper} {
260            pre {
261                plan.swapperType.length > 0: "Swapper type cannot be empty"
262            }
263
264            // Handle Cadence DEXes (IncrementFi)
265            if plan.swapperType == "IncrementFi" || plan.swapperType == "increment" {
266                let routerAddress = self.getContractAddress(Type<SwapRouter>())
267                let pairAddress = self.getContractAddress(Type<SwapFactory>())
268                let sourceTypeId = self.getContractTypeId(plan.tokenPair.sourceTokenType)
269                let targetTypeId = self.getContractTypeId(plan.tokenPair.targetTokenType)
270
271                return <- IncrementFiSwapperConnector.createSwapper(
272                    pairAddress: pairAddress,
273                    routerAddress: routerAddress,
274                    tokenKeyPath: [sourceTypeId, targetTypeId]
275                )
276            }
277
278            // Handle UniswapV2 EVM swapper (includes PunchSwap V2)
279            // Uses V2 connector with COA support
280            if plan.swapperType == "UniswapV2" 
281                || plan.swapperType == "uniswap-v2" 
282                || plan.swapperType == "punchswap-v2" {
283                return <- self.createUniswapV2Swapper(plan, coaCap: coaCap)
284            }
285
286            // Handle UniswapV3 EVM swapper (includes FlowSwap V3, PunchSwap V3)
287            // Uses V2 connector with COA support
288            if plan.swapperType == "UniswapV3" 
289                || plan.swapperType == "uniswap-v3"
290                || plan.swapperType == "flowswap-v3"
291                || plan.swapperType == "punchswap-v3" {
292                return <- self.createUniswapV3Swapper(plan, coaCap: coaCap)
293            }
294
295            panic("Unsupported swapper type: ".concat(plan.swapperType))
296        }
297
298        /// Create Uniswap V2 swapper with COA for EVM execution
299        access(self) fun createUniswapV2Swapper(
300            _ plan: DCAVaultActions.DCAPlan,
301            coaCap: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>?
302        ): @{DeFiActions.Swapper} {
303            let sourceTypeId = self.getContractTypeId(plan.tokenPair.sourceTokenType)
304            let targetTypeId = self.getContractTypeId(plan.tokenPair.targetTokenType)
305            
306            let sourceEVMAddress = self.getEVMTokenAddress(plan.tokenPair.sourceTokenType)
307            let targetEVMAddress = self.getEVMTokenAddress(plan.tokenPair.targetTokenType)
308            
309            let tokenPath: [EVM.EVMAddress] = [sourceEVMAddress, targetEVMAddress]
310            
311            return <- UniswapV2SwapperConnectorV2.createSwapperWithDefaults(
312                tokenPath: tokenPath,
313                cadenceTokenPath: [sourceTypeId, targetTypeId],
314                coaCapability: coaCap,
315                inVaultType: plan.tokenPair.sourceTokenType,
316                outVaultType: plan.tokenPair.targetTokenType
317            )
318        }
319
320        /// Create Uniswap V3 swapper with COA for EVM execution
321        access(self) fun createUniswapV3Swapper(
322            _ plan: DCAVaultActions.DCAPlan,
323            coaCap: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>?
324        ): @{DeFiActions.Swapper} {
325            let sourceTypeId = self.getContractTypeId(plan.tokenPair.sourceTokenType)
326            let targetTypeId = self.getContractTypeId(plan.tokenPair.targetTokenType)
327            
328            let tokenIn = self.getEVMTokenAddress(plan.tokenPair.sourceTokenType)
329            let tokenOut = self.getEVMTokenAddress(plan.tokenPair.targetTokenType)
330            
331            return <- UniswapV3SwapperConnectorV2.createSwapperWithDefaults(
332                tokenIn: tokenIn,
333                tokenOut: tokenOut,
334                cadenceTokenIn: sourceTypeId,
335                cadenceTokenOut: targetTypeId,
336                coaCapability: coaCap,
337                inVaultType: plan.tokenPair.sourceTokenType,
338                outVaultType: plan.tokenPair.targetTokenType
339            )
340        }
341
342        /// Check if a swapper type is an EVM swapper
343        access(self) fun isEVMSwapper(_ swapperType: String): Bool {
344            return swapperType == "UniswapV2" 
345                || swapperType == "uniswap-v2" 
346                || swapperType == "UniswapV3" 
347                || swapperType == "uniswap-v3"
348                || swapperType == "punchswap-v2"
349                || swapperType == "punchswap-v3"
350                || swapperType == "flowswap-v3"
351        }
352
353        /// Get EVM token address for a Cadence token type
354        access(self) fun getEVMTokenAddress(_ tokenType: Type): EVM.EVMAddress {
355            if let evmAddress = EVMTokenRegistry.getEVMAddress(tokenType) {
356                return evmAddress
357            }
358            
359            if tokenType == Type<@FlowToken.Vault>() {
360                return EVMTokenRegistry.getFlowEVMAddress()
361            }
362            
363            panic("No EVM address found for token type: ".concat(tokenType.identifier))
364        }
365
366        /// Extract contract address from Type identifier
367        access(self) fun getContractAddress(_ contractType: Type): Address {
368            let typeId = contractType.identifier
369            if typeId.length > 2 && typeId.slice(from: 0, upTo: 2) == "A." {
370                let parts = typeId.split(separator: ".")
371                if parts.length >= 2 {
372                    let addrHex = parts[1]
373                    return Address.fromString("0x".concat(addrHex)) ?? Address(0x0)
374                }
375            }
376            return Address(0x0)
377        }
378
379        /// Extract contract type identifier (strip resource name)
380        access(self) fun getContractTypeId(_ vaultType: Type): String {
381            let typeId = vaultType.identifier
382            let parts = typeId.split(separator: ".")
383            if parts.length >= 4 && parts[parts.length - 1] == "Vault" {
384                return "A.".concat(parts[1]).concat(".").concat(parts[2])
385            }
386            return typeId
387        }
388
389        /// Re-schedule next DCA purchase
390        access(self) fun rescheduleNext(_ data: ExecutionData) {
391            pre {
392                data.executionEffort >= 10: "Execution effort must be at least 10"
393            }
394
395            let nextTime = data.getNextExecutionTime()
396            assert(nextTime > getCurrentBlock().timestamp, message: "Next execution time must be in the future")
397
398            let updatedData = data.withIncrementedCount()
399            let estimation = FlowTransactionScheduler.estimate(
400                data: updatedData as AnyStruct,
401                timestamp: nextTime,
402                priority: data.priority,
403                executionEffort: data.executionEffort
404            )
405
406            let estimatedFee = estimation.flowFee ?? 0.01
407            assert(estimatedFee > 0.0, message: "Estimated fee must be positive")
408
409            let feeProvider = data.feeCap.borrow() ?? panic("Fee provider capability is invalid")
410            let feesVault <- feeProvider.withdraw(amount: estimatedFee)
411            let fees <- feesVault as! @FlowToken.Vault
412
413            let manager = data.mgrCap.borrow() ?? panic("Manager capability is invalid")
414            let scheduledId = manager.schedule(
415                handlerCap: data.handlerCap,
416                data: updatedData,
417                timestamp: nextTime,
418                priority: data.priority,
419                executionEffort: data.executionEffort,
420                fees: <-fees
421            )
422
423            emit DCATransactionScheduled(
424                planId: data.planId,
425                executionTime: nextTime,
426                priority: data.priority.rawValue.toString()
427            )
428        }
429
430        /// Get views for metadata
431        access(all) view fun getViews(): [Type] {
432            return [Type<StoragePath>(), Type<PublicPath>()]
433        }
434
435        /// Resolve view
436        access(all) fun resolveView(_ view: Type): AnyStruct? {
437            switch view {
438                case Type<StoragePath>():
439                    return DCATransactionHandlerV2.HandlerStoragePath
440                case Type<PublicPath>():
441                    return DCATransactionHandlerV2.HandlerPublicPath
442                default:
443                    return nil
444            }
445        }
446    }
447
448    /// Create a new DCA transaction handler
449    access(all) fun createHandler(): @Handler {
450        return <- create Handler()
451    }
452
453    init() {
454        self.HandlerStoragePath = /storage/DCATransactionHandlerV2
455        self.HandlerPublicPath = /public/DCATransactionHandlerV2
456    }
457}
458
459