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