Smart Contract
DCATransactionHandlerV4
A.17ae3b1b0b0d50db.DCATransactionHandlerV4
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