Smart Contract

DCATransactionHandlerV4

A.17ae3b1b0b0d50db.DCATransactionHandlerV4

Valid From

137,012,173

Deployed

5d ago
Feb 22, 2026, 02:23:53 AM UTC

Dependents

25 imports
1import FlowTransactionScheduler from 0xe467b9dd11fa00df
2import FlowTransactionSchedulerUtils from 0xe467b9dd11fa00df
3import DCAVaultActions from 0x17ae3b1b0b0d50db
4import DeFiActions from 0x17ae3b1b0b0d50db
5import IncrementFiSwapperConnector from 0x17ae3b1b0b0d50db
6import UniswapV3SwapperConnectorV3 from 0x17ae3b1b0b0d50db
7import EVMTokenRegistry from 0x17ae3b1b0b0d50db
8import SwapRouter from 0xa6850776a94e6551
9import SwapFactory from 0xb063c16cac85dbd1
10import FlowToken from 0x1654653399040a61
11import FungibleToken from 0xf233dcee88fe0abe
12import PriceOracle from 0x17ae3b1b0b0d50db
13import SimplePriceOracle from 0x17ae3b1b0b0d50db
14import EVM from 0xe467b9dd11fa00df
15import FlowFees from 0xf919ee77447b7497
16import FlowStorageFees from 0xe467b9dd11fa00df
17
18/// DCATransactionHandlerV4: Forte scheduled transaction handler for DCA purchases
19///
20/// This V4 contract uses the new UniswapV3SwapperConnectorV3 which follows
21/// the official FlowActions pattern with proper bridging support.
22///
23/// V4 Features:
24/// - Uses UniswapV3SwapperConnectorV3 with tokenPath/feePath support
25/// - Proper FlowEVMBridge integration for token bridging
26/// - Multi-hop swap support via tokenPath arrays
27/// - Backend-provided routing information
28///
29access(all) contract DCATransactionHandlerV4 {
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
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 - minimal to stay under Forte's 1KB limit
47    access(all) struct ExecutionData {
48        access(all) let planId: UInt64
49        access(all) let vaultOwner: Address
50        access(contract) let priority: FlowTransactionScheduler.Priority
51        access(all) let executionEffort: UInt64
52        access(all) let interval: UFix64?
53        access(all) let maxExec: UInt64?
54        access(all) var count: UInt64
55        access(all) let baseTime: UFix64
56        access(contract) let feeCap: Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>
57        access(contract) let mgrCap: Capability<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>
58        access(contract) let handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>
59
60        init(
61            planId: UInt64,
62            vaultOwner: Address,
63            priority: FlowTransactionScheduler.Priority,
64            executionEffort: UInt64,
65            interval: UFix64?,
66            maxExec: UInt64?,
67            count: UInt64,
68            baseTime: UFix64,
69            feeCap: Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>,
70            mgrCap: Capability<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>,
71            handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>
72        ) {
73            self.planId = planId
74            self.vaultOwner = vaultOwner
75            self.priority = priority
76            self.executionEffort = executionEffort
77            self.interval = interval
78            self.maxExec = maxExec
79            self.count = count
80            self.baseTime = baseTime
81            self.feeCap = feeCap
82            self.mgrCap = mgrCap
83            self.handlerCap = handlerCap
84        }
85
86        access(all) fun shouldContinue(): Bool {
87            if self.maxExec == nil { return true }
88            return self.count < self.maxExec!
89        }
90
91        access(all) fun nextExecTime(): UFix64 {
92            if self.interval == nil { return 0.0 }
93            return self.baseTime + (UFix64(self.count + 1) * self.interval!)
94        }
95
96        access(contract) fun incrementCount() {
97            self.count = self.count + 1
98        }
99
100        access(all) fun setExecutionEffort(_ effort: UInt64) {
101             // Not possible to modify 'let' field in struct.
102             // We need a helper to create a new struct with updated effort.
103        }
104
105        access(contract) fun getPriority(): FlowTransactionScheduler.Priority {
106            return self.priority
107        }
108    }
109
110    /// Helper to create new ExecutionData with updated effort
111    access(contract) fun updateEffort(data: ExecutionData, newEffort: UInt64): ExecutionData {
112        return ExecutionData(
113            planId: data.planId,
114            vaultOwner: data.vaultOwner,
115            priority: data.getPriority(),
116            executionEffort: newEffort,
117            interval: data.interval,
118            maxExec: data.maxExec,
119            count: data.count, // Preserve count
120            baseTime: data.baseTime,
121            feeCap: data.feeCap,
122            mgrCap: data.mgrCap,
123            handlerCap: data.handlerCap
124        )
125    }
126
127    /// Handler resource implementing TransactionHandler interface
128    access(all) resource Handler: FlowTransactionScheduler.TransactionHandler {
129        access(self) var coaCap: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>?
130        access(self) var handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>?
131
132        init() {
133            self.coaCap = nil
134            self.handlerCap = nil
135        }
136
137        /// Set COA capability for EVM swaps
138        /// Only the resource owner can set this capability
139        access(all) fun setCOACapability(cap: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>) {
140            pre {
141                self.owner != nil: "Handler must be stored before setting COA capability"
142            }
143            self.coaCap = cap
144        }
145
146        /// Check if COA is set
147        access(all) fun hasCOA(): Bool {
148            return self.coaCap != nil && self.coaCap!.check()
149        }
150
151        /// Get COA capability (internal use)
152        access(self) fun getCOACap(): Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>? {
153            return self.coaCap
154        }
155
156        /// Set handler capability
157        /// Only the resource owner can set this capability
158        access(all) fun setHandlerCapability(cap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>) {
159            pre {
160                self.owner != nil: "Handler must be stored before setting handler capability"
161            }
162            self.handlerCap = cap
163        }
164
165        /// Execute the scheduled DCA transaction
166        access(FlowTransactionScheduler.Execute)
167        fun executeTransaction(id: UInt64, data: AnyStruct?) {
168            let executionData = data as? ExecutionData
169                ?? panic("Invalid execution data")
170
171            let vaultCap = getAccount(executionData.vaultOwner)
172                .capabilities.get<&DCAVaultActions.Vault>(DCAVaultActions.VaultPublicPath)
173            let vault = vaultCap.borrow()
174                ?? panic("Could not borrow vault reference")
175
176            let plan = vault.getPlan(planId: executionData.planId)
177                ?? panic("Plan not found")
178
179            assert(plan.isActive(), message: "Plan is not active")
180            assert(plan.isReadyForPurchase(), message: "Plan not ready for purchase")
181
182            // Check trigger conditions if set
183            var currentPrice: UFix64? = nil
184
185            if plan.getTriggerType() != DCAVaultActions.TriggerType.none {
186                let targetSymbol = plan.getTokenPair().targetToken
187
188                if let priceDataAny = SimplePriceOracle.getPriceWithMaxAge(symbol: targetSymbol, maxAge: 300.0) {
189                    if let priceData = priceDataAny as? SimplePriceOracle.PriceData {
190                        currentPrice = priceData.price
191
192                        if currentPrice == nil {
193                            panic("Current price is nil")
194                        } else {
195                            assert(plan.checkPriceTrigger(currentPrice: currentPrice!), message: "Price condition not met")
196                        }
197                    } else {
198                        panic("Invalid price data format")
199                    }
200                } else {
201                    panic("Unable to fetch current price for ".concat(targetSymbol))
202                }
203            }
204            // Create swapper based on type
205            let swapper <- self.createSwapper(plan, coaCap: self.getCOACap())
206
207            // Calculate amount after protocol fee (0.1%)
208            let protocolFeePercent = DCAVaultActions.getProtocolFeePercent()
209            let feeAmount = plan.purchaseAmount * protocolFeePercent
210            let amountAfterFee = plan.purchaseAmount - feeAmount
211
212            // Get quote for the swap
213            let quote = swapper.getQuote(
214                fromTokenType: plan.getTokenPair().getSourceTokenType(),
215                toTokenType: plan.getTokenPair().getTargetTokenType(),
216                amount: amountAfterFee
217            )
218
219            // Execute the purchase through vault
220            vault.executePurchaseWithSwapper(
221                planId: executionData.planId,
222                swapper: <-swapper,
223                quote: quote,
224                currentPrice: currentPrice
225            )
226
227            emit DCATransactionExecuted(
228                planId: executionData.planId,
229                scheduledTxId: id,
230                amountSpent: plan.purchaseAmount,
231                amountReceived: quote.expectedAmount
232            )
233
234            // Re-fetch plan to get updated state after purchase
235            let updatedPlan = vault.getPlan(planId: executionData.planId)
236                ?? panic("Plan not found after execution")
237
238            // Re-schedule next execution if recurring and plan still has purchases remaining
239            if executionData.shouldContinue() && updatedPlan.purchasesExecuted < updatedPlan.totalPurchases {
240                self.rescheduleNext(executionData, swapperType: plan.swapperType)
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        access(self) fun createSwapper(
251            _ plan: DCAVaultActions.DCAPlan,
252            coaCap: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>?
253        ): @{DeFiActions.Swapper} {
254            pre {
255                plan.swapperType.length > 0: "Swapper type cannot be empty"
256            }
257
258            // Handle Cadence DEXes (IncrementFi)
259            if plan.swapperType == "IncrementFi" || plan.swapperType == "increment" {
260                let routerAddress = self.getContractAddress(Type<SwapRouter>())
261                let pairAddress = self.getContractAddress(Type<SwapFactory>())
262                let sourceTypeId = self.getContractTypeId(plan.getTokenPair().getSourceTokenType())
263                let targetTypeId = self.getContractTypeId(plan.getTokenPair().getTargetTokenType())
264
265                return <- IncrementFiSwapperConnector.createSwapper(
266                    pairAddress: pairAddress,
267                    routerAddress: routerAddress,
268                    tokenKeyPath: [sourceTypeId, targetTypeId]
269                )
270            }
271
272            // Handle EVM swappers (V2 or V3) - all use V3 connector with tokenPath
273            if self.isEVMSwapper(plan.swapperType) {
274                return <- self.createEVMSwapper(plan, coaCap: coaCap)
275            }
276
277            panic("Unsupported swapper type: ".concat(plan.swapperType))
278        }
279
280        /// Create EVM swapper using V3 connector with tokenPath/feePath
281        access(self) fun createEVMSwapper(
282            _ plan: DCAVaultActions.DCAPlan,
283            coaCap: Capability<auth(EVM.Owner) &EVM.CadenceOwnedAccount>?
284        ): @{DeFiActions.Swapper} {
285            // Verify COA capability is available
286            if coaCap == nil {
287                panic("COA capability not set in handler. Please re-create your DCA plan to set up EVM swap support.")
288            }
289            if !coaCap!.check() {
290                panic("COA capability is invalid. Please re-create your DCA plan.")
291            }
292            
293            // Resolve EVM addresses for tokens
294            let tokenIn = self.getEVMTokenAddress(plan.getTokenPair().getSourceTokenType())
295            let tokenOut = self.getEVMTokenAddress(plan.getTokenPair().getTargetTokenType())
296            
297            // Build token path (direct swap for now, can be extended for multi-hop)
298            let tokenPath: [EVM.EVMAddress] = [tokenIn, tokenOut]
299            
300            // Build fee path - use 3000 (0.3%) as default for V3
301            // For V2-style swaps this is ignored by the router
302            let feePath: [UInt32] = [3000]
303            
304            // Create V3 swapper with defaults (FlowSwap V3)
305            return <- UniswapV3SwapperConnectorV3.createSwapperWithDefaults(
306                tokenPath: tokenPath,
307                feePath: feePath,
308                inVaultType: plan.getTokenPair().getSourceTokenType(),
309                outVaultType: plan.getTokenPair().getTargetTokenType(),
310                coaCapability: coaCap!
311            )
312        }
313
314        /// Check if a swapper type is an EVM swapper
315        access(self) fun isEVMSwapper(_ swapperType: String): Bool {
316            return swapperType == "UniswapV2" 
317                || swapperType == "uniswap-v2" 
318                || swapperType == "UniswapV3" 
319                || swapperType == "uniswap-v3"
320                || swapperType == "punchswap-v2"
321                || swapperType == "punchswap-v3"
322                || swapperType == "flowswap-v3"
323                || swapperType == "evm-auto"
324                || swapperType == "evm"
325        }
326
327        /// Get EVM token address for a Cadence token type
328        access(self) fun getEVMTokenAddress(_ tokenType: Type): EVM.EVMAddress {
329            // Check registry first
330            if let evmAddress = EVMTokenRegistry.getEVMAddress(tokenType) {
331                return evmAddress
332            }
333            
334            // Fallback for FLOW -> WFLOW
335            if tokenType == Type<@FlowToken.Vault>() {
336                // WFLOW address on Flow EVM Mainnet
337                return EVM.addressFromString("0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e")
338            }
339            
340            panic("No EVM address found for token type: ".concat(tokenType.identifier))
341        }
342
343        /// Get contract address from type
344        access(self) fun getContractAddress(_ type: Type): Address {
345            let typeId = type.identifier
346            let parts = typeId.split(separator: ".")
347            if parts.length >= 2 {
348                return Address.fromString("0x".concat(parts[1]))!
349            }
350            panic("Could not extract address from type: ".concat(typeId))
351        }
352
353        /// Get contract type identifier
354        access(self) fun getContractTypeId(_ type: Type): String {
355            return type.identifier
356        }
357
358        /// Re-schedule the next execution
359        access(self) fun rescheduleNext(_ data: ExecutionData, swapperType: String) {
360            if data.interval == nil { return }
361
362            let manager = data.mgrCap.borrow()
363                ?? panic("Could not borrow manager capability")
364
365            // Schedule based on current block time to avoid drift when previous exec ran late
366            let currentTime = getCurrentBlock().timestamp
367            let nextTime = currentTime + data.interval!
368            
369            var updatedData = data
370            updatedData.incrementCount()
371
372            // Determine effort for next execution.
373            // NOTE: We no longer have a reliable on-chain measurement of last execution effort.
374            // DCAVaultActions.executePurchaseWithSwapper currently returns 0 as a placeholder.
375            // So we carry forward the existing effort and apply a safety minimum by swapper type.
376            var nextEffort: UInt64 = data.executionEffort
377
378            if self.isEVMSwapper(swapperType) {
379                // EVM swaps include bridging overhead.
380                if nextEffort < 2000 { nextEffort = 2000 }
381            } else {
382                // Cadence-only swaps are cheaper.
383                if nextEffort < 500 { nextEffort = 500 }
384            }
385
386            // Apply the new effort to the data structure
387            updatedData = DCATransactionHandlerV4.updateEffort(data: updatedData, newEffort: nextEffort)
388
389            // Estimate the fee required for scheduling
390            let baseFee = FlowFees.computeFees(inclusionEffort: 1.0, executionEffort: UFix64(updatedData.executionEffort)/100000000.0)
391
392            // Scale the execution fee by the multiplier for the priority
393            let scaledExecutionFee = baseFee * FlowTransactionScheduler.getConfig().priorityFeeMultipliers[data.getPriority()]!
394
395            // Estimated off chain
396            let dataSizeMB = 0.001
397            // Calculate the FLOW required to pay for storage of the transaction data
398            let storageFee = FlowStorageFees.storageCapacityToFlow(dataSizeMB)
399
400            // add everything together along with the inclusion fee
401            let feeEstimate = scaledExecutionFee + storageFee + 0.00001
402            
403            // Use estimated fee with 5% buffer for safety, cap at 10.0 FLOW
404            var feeAmount = feeEstimate * 1.05
405            if feeAmount > 10.00 {
406                feeAmount = 10.00
407            }
408            
409            let feeVault <- data.feeCap.borrow()!.withdraw(amount: feeAmount)
410            let feeTokens <- feeVault as! @FlowToken.Vault
411
412            let scheduledId = manager.schedule(
413                handlerCap: data.handlerCap,
414                data: updatedData,
415                timestamp: nextTime,
416                priority: data.getPriority(),
417                executionEffort: updatedData.executionEffort,
418                fees: <-feeTokens
419            )
420
421            let priority = data.getPriority()
422            let priorityStr = priority == FlowTransactionScheduler.Priority.Low ? "Low"
423                : priority == FlowTransactionScheduler.Priority.Medium ? "Medium"
424                : "High"
425
426            emit DCATransactionScheduled(
427                planId: data.planId,
428                executionTime: nextTime,
429                priority: priorityStr
430            )
431        }
432
433        /// Resolve contract path for handler storage
434        access(all) fun resolveContractPath(): StoragePath? {
435            return DCATransactionHandlerV4.HandlerStoragePath
436        }
437    }
438
439    /// Create a new Handler resource
440    access(all) fun createHandler(): @Handler {
441        return <- create Handler()
442    }
443
444    init() {
445        self.HandlerStoragePath = /storage/DCATransactionHandlerV4
446        self.HandlerPublicPath = /public/DCATransactionHandlerV4
447    }
448}
449
450