Smart Contract

DCATransactionHandler

A.17ae3b1b0b0d50db.DCATransactionHandler

Valid From

134,183,262

Deployed

6d ago
Feb 21, 2026, 05:42:05 PM UTC

Dependents

11 imports
1import FlowTransactionScheduler from 0xe467b9dd11fa00df
2import FlowTransactionSchedulerUtils from 0xe467b9dd11fa00df
3import DCAVaultActions from 0x17ae3b1b0b0d50db
4import DeFiActions from 0x17ae3b1b0b0d50db
5import IncrementFiSwapperConnector from 0x17ae3b1b0b0d50db
6import UniswapV2SwapperConnector from 0x17ae3b1b0b0d50db
7import UniswapV3SwapperConnector 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/// DCATransactionHandler: Forte scheduled transaction handler for DCA purchases
18///
19/// This contract implements the FlowTransactionScheduler.TransactionHandler interface
20/// to enable automated, recurring DCA purchases executed by Forte's on-chain scheduler.
21///
22/// Features:
23/// - Cron-style recurring purchase scheduling
24/// - Automatic re-scheduling for periodic DCA intervals
25/// - FlowActions integration for DEX-agnostic swaps
26/// - Flexible execution parameters and fee management
27///
28access(all) contract DCATransactionHandler {
29
30    /// Events
31    access(all) event DCATransactionExecuted(planId: UInt64, scheduledTxId: UInt64, amountSpent: UFix64, amountReceived: UFix64)
32    access(all) event DCATransactionScheduled(planId: UInt64, executionTime: UFix64, priority: String)
33    access(all) event DCASeriesCompleted(planId: UInt64, totalExecutions: UInt64)
34    access(all) event DCATransactionFailed(planId: UInt64, scheduledTxId: UInt64, reason: String)
35    access(all) event HandlerCreated(address: Address)
36    
37    /// EVM-specific events for monitoring
38    access(all) event EVMSwapExecuted(planId: UInt64, dexType: String, amountIn: UFix64, amountOut: UFix64, gasUsed: UInt64)
39    access(all) event EVMSwapFailed(planId: UInt64, dexType: String, reason: String)
40    access(all) event SwapFallbackExecuted(planId: UInt64, originalDex: String, fallbackDex: 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    /// Capabilities are essential and cannot be recreated by the handler
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        init(
80            planId: UInt64,
81            vaultOwner: Address,
82            priority: FlowTransactionScheduler.Priority,
83            executionEffort: UInt64,
84            interval: UFix64?,
85            maxExec: UInt64?,
86            count: UInt64,
87            baseTime: UFix64,
88            feeCap: Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>,
89            mgrCap: Capability<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>,
90            handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>
91        ) {
92            self.planId = planId
93            self.vaultOwner = vaultOwner
94            self.priority = priority
95            self.executionEffort = executionEffort
96            self.interval = interval
97            self.maxExec = maxExec
98            self.count = count
99            self.baseTime = baseTime
100            self.feeCap = feeCap
101            self.mgrCap = mgrCap
102            self.handlerCap = handlerCap
103        }
104
105        /// Check if this DCA series should continue
106        access(all) fun shouldContinue(): Bool {
107            if let max = self.maxExec {
108                return self.count < max
109            }
110            return true
111        }
112
113        /// Get next execution time (cron-style)
114        access(all) fun getNextExecutionTime(): UFix64 {
115            // Calculate next execution time based on intervals
116            if let interval = self.interval {
117                let elapsedTime = getCurrentBlock().timestamp - self.baseTime
118                let elapsedIntervals = UInt64(elapsedTime / interval)
119                return self.baseTime + (UFix64(elapsedIntervals + 1) * interval)
120            }
121            panic("No interval set for recurring DCA")
122        }
123
124        /// Create updated config for next execution
125        access(all) fun withIncrementedCount(): ExecutionData {
126            return ExecutionData(
127                planId: self.planId,
128                vaultOwner: self.vaultOwner,
129                priority: self.priority,
130                executionEffort: self.executionEffort,
131                interval: self.interval,
132                maxExec: self.maxExec,
133                count: self.count + 1,
134                baseTime: self.baseTime,
135                feeCap: self.feeCap,
136                mgrCap: self.mgrCap,
137                handlerCap: self.handlerCap
138            )
139        }
140    }
141
142    /// Transaction Handler resource
143    access(all) resource Handler: FlowTransactionScheduler.TransactionHandler {
144
145        /// Execute a scheduled DCA purchase
146        ///
147        /// This function is called by the Forte scheduler at the scheduled time.
148        /// It performs the DCA purchase and optionally re-schedules the next one.
149        ///
150        access(FlowTransactionScheduler.Execute) fun executeTransaction(
151            id: UInt64,
152            data: AnyStruct?
153        ) {
154            pre {
155                id > 0: "Transaction ID must be positive"
156                data != nil: "Execution data cannot be nil"
157            }
158            
159            // Extract execution data
160            let executionData = data as! ExecutionData?
161                ?? panic("Invalid execution data")
162            
163            // Additional validations after data extraction
164            assert(executionData.planId > 0, message: "Plan ID must be positive")
165            assert(executionData.vaultOwner != Address(0x0), message: "Vault owner address cannot be zero")
166
167            // Get vault reference via public capability
168            // Chain the calls to help type inference
169            let vaultCap = getAccount(executionData.vaultOwner)
170                .capabilities.get<&DCAVaultActions.Vault>(DCAVaultActions.VaultPublicPath)
171            let vault = vaultCap.borrow()
172                ?? panic("Vault not found for owner")
173
174            // Get plan to verify it's still active
175            let plan = vault.getPlan(planId: executionData.planId)
176                ?? panic("Plan not found")
177
178            assert(plan.status == DCAVaultActions.PlanStatus.active, message: "Plan is not active")
179            assert(plan.isReadyForPurchase(), message: "Plan not ready for purchase")
180
181            // Fetch current price from oracle if price trigger is set
182            var currentPrice: UFix64? = nil
183            if plan.triggerType != DCAVaultActions.TriggerType.none {
184                // Get price for the target token (the one we're buying)
185                let targetSymbol = plan.tokenPair.targetToken
186
187                // Fetch price with 5 minute staleness tolerance
188                if let priceDataAny = SimplePriceOracle.getPriceWithMaxAge(symbol: targetSymbol, maxAge: 300.0) {
189                    if let priceData = priceDataAny as? SimplePriceOracle.PriceData {
190                        currentPrice = priceData.price
191                    } else {
192                        panic("Invalid price data format")
193                    }
194                } else {
195                    // If price trigger is set but we can't get price, fail the execution
196                    panic("Unable to fetch current price for ".concat(targetSymbol))
197                }
198            }
199
200            // Create swapper based on type from plan
201            let swapper <- self.createSwapper(plan)
202
203            // Calculate amount after protocol fee (0.1%)
204            // DCAVaultActions deducts the fee before swapping, so we need to get quote for amount after fee
205            let protocolFeePercent = DCAVaultActions.getProtocolFeePercent()
206            let feeAmount = plan.purchaseAmount * protocolFeePercent
207            let amountAfterFee = plan.purchaseAmount - feeAmount
208
209            // Get quote for amount AFTER fee (the actual amount that will be swapped)
210            let quote = swapper.getQuote(
211                fromTokenType: plan.tokenPair.sourceTokenType,
212                toTokenType: plan.tokenPair.targetTokenType,
213                amount: amountAfterFee
214            )
215
216            // Execute purchase with price validation
217            // Note: swapper is consumed by this call
218            vault.executePurchaseWithSwapper(
219                planId: executionData.planId,
220                swapper: <-swapper,
221                quote: quote,
222                currentPrice: currentPrice
223            )
224
225            emit DCATransactionExecuted(
226                planId: executionData.planId,
227                scheduledTxId: id,
228                amountSpent: plan.purchaseAmount,
229                amountReceived: quote.expectedAmount
230            )
231
232            // Handle OCO (One-Cancels-Other) cancellation if plan has linked plan
233            // Note: cancelLinkedPlan requires self authorization, so we can't call it from here
234            // This should be handled by the vault owner in a separate transaction
235            if plan.linkedPlanId != nil && plan.linkedPlanId! > 0 {
236            }
237
238            // Check if should re-schedule for recurring DCA
239            if executionData.shouldContinue() && plan.purchasesExecuted < plan.totalPurchases {
240                self.rescheduleNext(executionData)
241            } else {
242                emit DCASeriesCompleted(
243                    planId: executionData.planId,
244                    totalExecutions: executionData.count + 1
245                )
246            }
247        }
248
249        /// Create swapper for the specified DEX
250        /// Network-aware: uses different addresses for emulator/testnet/mainnet
251        /// Supports both Cadence DEXes (IncrementFi) and EVM DEXes (UniswapV2, UniswapV3)
252        access(self) fun createSwapper(_ plan: DCAVaultActions.DCAPlan): @{DeFiActions.Swapper} {
253            pre {
254                plan.swapperType.length > 0: "Swapper type cannot be empty"
255            }
256
257            // Handle both "increment" (from frontend) and "IncrementFi" (from backend)
258            if plan.swapperType == "IncrementFi" || plan.swapperType == "increment" {
259                // Get addresses from imported contracts (automatically resolved by flow.json aliases)
260                let routerAddress = self.getContractAddress(Type<SwapRouter>())
261                let pairAddress = self.getContractAddress(Type<SwapFactory>())
262
263                // Build fully qualified type identifiers for Increment.fi
264                let sourceTypeId = self.getContractTypeId(plan.tokenPair.sourceTokenType)
265                let targetTypeId = self.getContractTypeId(plan.tokenPair.targetTokenType)
266
267                return <- IncrementFiSwapperConnector.createSwapper(
268                    pairAddress: pairAddress,
269                    routerAddress: routerAddress,
270                    tokenKeyPath: [sourceTypeId, targetTypeId]
271                )
272            }
273
274            // Handle UniswapV2 EVM swapper (includes PunchSwap V2)
275            if plan.swapperType == "UniswapV2" 
276                || plan.swapperType == "uniswap-v2" 
277                || plan.swapperType == "punchswap-v2" {
278                return <- self.createUniswapV2Swapper(plan)
279            }
280
281            // Handle UniswapV3 EVM swapper (includes FlowSwap V3, PunchSwap V3)
282            if plan.swapperType == "UniswapV3" 
283                || plan.swapperType == "uniswap-v3"
284                || plan.swapperType == "flowswap-v3"
285                || plan.swapperType == "punchswap-v3" {
286                return <- self.createUniswapV3Swapper(plan)
287            }
288
289            // Add more swapper types as they're implemented
290            panic("Unsupported swapper type: ".concat(plan.swapperType))
291        }
292
293        /// Create Uniswap V2 swapper for EVM pools
294        /// Resolves EVM token addresses from EVMTokenRegistry
295        access(self) fun createUniswapV2Swapper(_ plan: DCAVaultActions.DCAPlan): @{DeFiActions.Swapper} {
296            // Get Cadence type identifiers
297            let sourceTypeId = self.getContractTypeId(plan.tokenPair.sourceTokenType)
298            let targetTypeId = self.getContractTypeId(plan.tokenPair.targetTokenType)
299            
300            // Resolve EVM token addresses from the registry
301            let sourceEVMAddress = self.getEVMTokenAddress(plan.tokenPair.sourceTokenType)
302            let targetEVMAddress = self.getEVMTokenAddress(plan.tokenPair.targetTokenType)
303            
304            // Build token path for the swap
305            let tokenPath: [EVM.EVMAddress] = [sourceEVMAddress, targetEVMAddress]
306            
307            return <- UniswapV2SwapperConnector.createSwapperWithDefaults(
308                tokenPath: tokenPath,
309                cadenceTokenPath: [sourceTypeId, targetTypeId]
310            )
311        }
312
313        /// Create Uniswap V3 swapper for EVM pools
314        /// Resolves EVM token addresses from EVMTokenRegistry
315        /// Uses default 0.3% fee tier - optimal for most token pairs
316        access(self) fun createUniswapV3Swapper(_ plan: DCAVaultActions.DCAPlan): @{DeFiActions.Swapper} {
317            // Get Cadence type identifiers
318            let sourceTypeId = self.getContractTypeId(plan.tokenPair.sourceTokenType)
319            let targetTypeId = self.getContractTypeId(plan.tokenPair.targetTokenType)
320            
321            // Resolve EVM token addresses from the registry
322            let tokenIn = self.getEVMTokenAddress(plan.tokenPair.sourceTokenType)
323            let tokenOut = self.getEVMTokenAddress(plan.tokenPair.targetTokenType)
324            
325            return <- UniswapV3SwapperConnector.createSwapperWithDefaults(
326                tokenIn: tokenIn,
327                tokenOut: tokenOut,
328                cadenceTokenIn: sourceTypeId,
329                cadenceTokenOut: targetTypeId
330            )
331        }
332
333        /// Create fallback swapper (IncrementFi) for when EVM swaps fail
334        /// Used as a fallback mechanism for resilience
335        access(self) fun createFallbackSwapper(_ plan: DCAVaultActions.DCAPlan): @{DeFiActions.Swapper} {
336            let routerAddress = self.getContractAddress(Type<SwapRouter>())
337            let pairAddress = self.getContractAddress(Type<SwapFactory>())
338            let sourceTypeId = self.getContractTypeId(plan.tokenPair.sourceTokenType)
339            let targetTypeId = self.getContractTypeId(plan.tokenPair.targetTokenType)
340
341            return <- IncrementFiSwapperConnector.createSwapper(
342                pairAddress: pairAddress,
343                routerAddress: routerAddress,
344                tokenKeyPath: [sourceTypeId, targetTypeId]
345            )
346        }
347
348        /// Check if a swapper type is an EVM swapper
349        access(self) fun isEVMSwapper(_ swapperType: String): Bool {
350            return swapperType == "UniswapV2" 
351                || swapperType == "uniswap-v2" 
352                || swapperType == "UniswapV3" 
353                || swapperType == "uniswap-v3"
354                || swapperType == "punchswap-v2"
355                || swapperType == "punchswap-v3"
356                || swapperType == "flowswap-v3"
357        }
358
359        /// Get estimated gas cost for EVM swaps (in FLOW)
360        /// This is added to Forte fee estimation for accurate cost calculation
361        access(self) fun getEVMGasEstimate(_ swapperType: String): UFix64 {
362            // V3 swaps typically use more gas due to concentrated liquidity logic
363            // FlowSwap V3 and PunchSwap V3 both use Uniswap V3 architecture
364            if swapperType == "UniswapV3" 
365                || swapperType == "uniswap-v3" 
366                || swapperType == "flowswap-v3"
367                || swapperType == "punchswap-v3" {
368                return 0.002 // ~200k gas at typical FLOW gas price
369            }
370            // V2 swaps use less gas
371            return 0.0015 // ~150k gas
372        }
373
374        /// Detect network by checking FlowToken address
375        access(self) fun getFlowTokenAddress(): Address {
376            // FlowToken addresses:
377            // Emulator: 0x0ae53cb6e3f42a79
378            // Testnet: 0x7e60df042a9c0868
379            // Mainnet: 0x1654653399040a61
380            let flowTokenType = Type<@FlowToken.Vault>()
381            let typeId = flowTokenType.identifier
382
383            // Extract address from type identifier
384            // Format: "A.{address}.FlowToken.Vault"
385            if typeId.length > 2 && typeId.slice(from: 0, upTo: 2) == "A." {
386                let parts = typeId.split(separator: ".")
387                if parts.length >= 2 {
388                    let addrHex = parts[1]
389                    return Address.fromString("0x".concat(addrHex)) ?? Address(0x0)
390                }
391            }
392
393            return Address(0x0)
394        }
395
396        /// Extract contract address from Type identifier
397        /// Generic helper for getting network-specific contract addresses
398        /// Type identifier format: "A.{address}.ContractName" or "A.{address}.ContractName.ResourceName"
399        access(self) fun getContractAddress(_ contractType: Type): Address {
400            let typeId = contractType.identifier
401
402            // Extract address from type identifier (format: "A.{address}.ContractName...")
403            if typeId.length > 2 && typeId.slice(from: 0, upTo: 2) == "A." {
404                let parts = typeId.split(separator: ".")
405                if parts.length >= 2 {
406                    let addrHex = parts[1]
407                    return Address.fromString("0x".concat(addrHex)) ?? Address(0x0)
408                }
409            }
410
411            return Address(0x0)
412        }
413
414        /// Extract contract type identifier (strip resource name)
415        /// Converts "A.{address}.ContractName.Vault" to "A.{address}.ContractName"
416        /// Increment.fi SwapRouter expects format: "A.1654653399040a61.FlowToken"
417        access(self) fun getContractTypeId(_ vaultType: Type): String {
418            let typeId = vaultType.identifier
419
420            // Type format: "A.{address}.ContractName.Vault"
421            // We need to strip the ".Vault" suffix to get "A.{address}.ContractName"
422            // Example: "A.1654653399040a61.FlowToken.Vault" -> "A.1654653399040a61.FlowToken"
423            let parts = typeId.split(separator: ".")
424            if parts.length >= 4 && parts[parts.length - 1] == "Vault" {
425                // Reconstruct without the ".Vault" suffix
426                // ["A", "address", "Contract", "Vault"] -> "A.address.Contract"
427                return "A.".concat(parts[1]).concat(".").concat(parts[2])
428            }
429
430            // Fallback: return full identifier if format is unexpected
431            return typeId
432        }
433
434        /// Get EVM token address for a Cadence token type
435        /// Uses EVMTokenRegistry to resolve the mapping
436        access(self) fun getEVMTokenAddress(_ tokenType: Type): EVM.EVMAddress {
437            // First check the EVMTokenRegistry
438            if let evmAddress = EVMTokenRegistry.getEVMAddress(tokenType) {
439                return evmAddress
440            }
441            
442            // Check if it's a Flow token (uses WFLOW on EVM)
443            if tokenType == Type<@FlowToken.Vault>() {
444                return EVMTokenRegistry.getFlowEVMAddress()
445            }
446            
447            // For EVM-bridged tokens, the EVM address is encoded in the type identifier
448            // Format: "A.{address}.EVMVMBridgedToken_{evmAddress}.Vault"
449            let typeId = tokenType.identifier
450            if typeId.utf8.contains("EVMVMBridgedToken_".utf8[0]) {
451                // Extract the EVM address from the type identifier
452                let parts = typeId.split(separator: ".")
453                if parts.length >= 3 {
454                    let contractName = parts[2]
455                    // Contract name format: "EVMVMBridgedToken_{evmAddress}"
456                    if contractName.length > 18 { // "EVMVMBridgedToken_" is 18 chars
457                        let evmAddressHex = contractName.slice(from: 18, upTo: contractName.length)
458                        return EVM.addressFromString("0x".concat(evmAddressHex))
459                    }
460                }
461            }
462            
463            // No EVM address found - panic with helpful error
464            panic("No EVM address found for token type: ".concat(typeId).concat(
465                ". Register the token in EVMTokenRegistry or use an EVM-bridged token."))
466        }
467
468        /// Re-schedule next DCA purchase
469        /// Uses capabilities already stored in ExecutionData
470        access(self) fun rescheduleNext(_ data: ExecutionData) {
471            pre {
472                data.executionEffort >= 10: "Execution effort must be at least 10"
473            }
474
475            // Calculate next execution time
476            let nextTime = data.getNextExecutionTime()
477            assert(nextTime > getCurrentBlock().timestamp, message: "Next execution time must be in the future")
478
479            // Create updated data and estimate fees
480            let updatedData = data.withIncrementedCount()
481            let estimation = FlowTransactionScheduler.estimate(
482                data: updatedData as AnyStruct,
483                timestamp: nextTime,
484                priority: data.priority,
485                executionEffort: data.executionEffort
486            )
487
488            let estimatedFee = estimation.flowFee ?? 0.01
489            assert(estimatedFee > 0.0, message: "Estimated fee must be positive")
490
491            // Withdraw fees using capability from ExecutionData
492            let feeProvider = data.feeCap.borrow() ?? panic("Fee provider capability is invalid")
493            let feesVault <- feeProvider.withdraw(amount: estimatedFee)
494            let fees <- feesVault as! @FlowToken.Vault
495
496            // Schedule next execution using manager capability from ExecutionData
497            let manager = data.mgrCap.borrow() ?? panic("Manager capability is invalid")
498            let scheduledId = manager.schedule(
499                handlerCap: data.handlerCap,
500                data: updatedData,
501                timestamp: nextTime,
502                priority: data.priority,
503                executionEffort: data.executionEffort,
504                fees: <-fees
505            )
506
507            emit DCATransactionScheduled(
508                planId: data.planId,
509                executionTime: nextTime,
510                priority: data.priority.rawValue.toString()
511            )
512        }
513
514        /// Get views for metadata
515        access(all) view fun getViews(): [Type] {
516            return [
517                Type<StoragePath>(),
518                Type<PublicPath>()
519            ]
520        }
521
522        /// Resolve view
523        access(all) fun resolveView(_ view: Type): AnyStruct? {
524            switch view {
525                case Type<StoragePath>():
526                    return DCATransactionHandler.HandlerStoragePath
527                case Type<PublicPath>():
528                    return DCATransactionHandler.HandlerPublicPath
529                default:
530                    return nil
531            }
532        }
533    }
534
535    /// Create a new DCA transaction handler
536    access(all) fun createHandler(): @Handler {
537        return <- create Handler()
538    }
539
540    init() {
541        self.HandlerStoragePath = /storage/DCATransactionHandler
542        self.HandlerPublicPath = /public/DCATransactionHandler
543    }
544}
545