Smart Contract

DCATransactionHandlerV3

A.17ae3b1b0b0d50db.DCATransactionHandlerV3

Valid From

134,199,062

Deployed

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