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