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