Smart Contract
DCATransactionHandler
A.ca7ee55e4fc3251a.DCATransactionHandler
1import FlowTransactionScheduler from 0xe467b9dd11fa00df
2import DCAController from 0xca7ee55e4fc3251a
3import DCAPlan from 0xca7ee55e4fc3251a
4import DeFiMath from 0xca7ee55e4fc3251a
5import FungibleToken from 0xf233dcee88fe0abe
6import FlowToken from 0x1654653399040a61
7import SwapRouter from 0xa6850776a94e6551
8import TeleportedTetherToken from 0xcfdd90d4a00f7b5b
9
10/// DCATransactionHandler: Scheduled transaction handler for DCA execution
11///
12/// This contract implements the FlowTransactionScheduler.TransactionHandler interface
13/// to enable autonomous DCA plan execution via Forte Scheduled Transactions.
14///
15/// Architecture:
16/// 1. User creates DCA plan with DCAController
17/// 2. Plan schedules execution via FlowTransactionScheduler
18/// 3. At scheduled time, scheduler calls this handler's executeTransaction()
19/// 4. Handler:
20/// - Validates plan is ready
21/// - Builds DeFi Actions stack (Source → Swapper → Sink)
22/// - Executes swap
23/// - Updates plan accounting
24/// - Reschedules next execution if plan is still active
25///
26/// Educational Notes:
27/// - Implements FlowTransactionScheduler.TransactionHandler interface
28/// - Access control: executeTransaction is access(FlowTransactionScheduler.Execute)
29/// - Uses DeFi Actions for composable swap execution
30/// - Stores metadata via getViews/resolveView for discoverability
31access(all) contract DCATransactionHandler {
32
33 /// Event emitted when handler starts execution
34 access(all) event HandlerExecutionStarted(
35 transactionId: UInt64,
36 planId: UInt64,
37 owner: Address,
38 timestamp: UFix64
39 )
40
41 /// Event emitted when handler completes successfully
42 access(all) event HandlerExecutionCompleted(
43 transactionId: UInt64,
44 planId: UInt64,
45 owner: Address,
46 amountIn: UFix64,
47 amountOut: UFix64,
48 nextExecutionScheduled: Bool,
49 timestamp: UFix64
50 )
51
52 /// Event emitted when handler execution fails
53 access(all) event HandlerExecutionFailed(
54 transactionId: UInt64,
55 planId: UInt64?,
56 owner: Address?,
57 reason: String,
58 timestamp: UFix64
59 )
60
61 /// Handler resource that implements the Scheduled Transaction interface
62 ///
63 /// Each user has one instance of this stored in their account.
64 /// The scheduler calls executeTransaction() when a DCA plan is due.
65 access(all) resource Handler: FlowTransactionScheduler.TransactionHandler {
66
67 /// Reference to the user's DCA controller
68 /// This capability allows the handler to access and update plans
69 access(self) let controllerCap: Capability<auth(DCAController.Owner) &DCAController.Controller>
70
71 init(controllerCap: Capability<auth(DCAController.Owner) &DCAController.Controller>) {
72 pre {
73 controllerCap.check(): "Invalid controller capability"
74 }
75 self.controllerCap = controllerCap
76 }
77
78 /// Main execution entrypoint called by FlowTransactionScheduler
79 ///
80 /// @param id: Transaction ID from the scheduler
81 /// @param data: Encoded plan data (contains planId)
82 ///
83 /// This function has restricted access - only FlowTransactionScheduler can call it
84 access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) {
85 let timestamp = getCurrentBlock().timestamp
86
87 // Parse plan ID from transaction data
88 let planData = data as! {String: UInt64}? ?? panic("Invalid transaction data format")
89 let planId = planData["planId"] ?? panic("Plan ID not found in transaction data")
90 let ownerAddress = self.controllerCap.address
91
92 emit HandlerExecutionStarted(
93 transactionId: id,
94 planId: planId,
95 owner: ownerAddress,
96 timestamp: timestamp
97 )
98
99 // Borrow controller
100 let controller = self.controllerCap.borrow()
101 ?? panic("Could not borrow DCA controller")
102
103 // Borrow plan
104 let planRef = controller.borrowPlan(id: planId)
105 ?? panic("Could not borrow plan with ID: ".concat(planId.toString()))
106
107 // Validate plan is ready
108 if !planRef.isReadyForExecution() {
109 emit HandlerExecutionFailed(
110 transactionId: id,
111 planId: planId,
112 owner: ownerAddress,
113 reason: "Plan not ready for execution",
114 timestamp: timestamp
115 )
116 return
117 }
118
119 // Check max executions
120 if planRef.hasReachedMaxExecutions() {
121 emit HandlerExecutionFailed(
122 transactionId: id,
123 planId: planId,
124 owner: ownerAddress,
125 reason: "Plan has reached maximum executions",
126 timestamp: timestamp
127 )
128 return
129 }
130
131 // Get vault capabilities
132 let sourceVaultCap = controller.getSourceVaultCapability()
133 ?? panic("Source vault capability not configured")
134
135 let targetVaultCap = controller.getTargetVaultCapability()
136 ?? panic("Target vault capability not configured")
137
138 // Validate capabilities
139 if !sourceVaultCap.check() {
140 emit HandlerExecutionFailed(
141 transactionId: id,
142 planId: planId,
143 owner: ownerAddress,
144 reason: "Invalid source vault capability",
145 timestamp: timestamp
146 )
147 return
148 }
149
150 if !targetVaultCap.check() {
151 emit HandlerExecutionFailed(
152 transactionId: id,
153 planId: planId,
154 owner: ownerAddress,
155 reason: "Invalid target vault capability",
156 timestamp: timestamp
157 )
158 return
159 }
160
161 // Execute the swap
162 let result = self.executeSwap(
163 planRef: planRef,
164 sourceVaultCap: sourceVaultCap,
165 targetVaultCap: targetVaultCap
166 )
167
168 if result.success {
169 // Record successful execution
170 planRef.recordExecution(
171 amountIn: result.amountIn!,
172 amountOut: result.amountOut!
173 )
174
175 // Schedule next execution if plan is still active
176 var nextScheduled = false
177 if planRef.status == DCAPlan.PlanStatus.Active && !planRef.hasReachedMaxExecutions() {
178 planRef.scheduleNextExecution()
179 nextScheduled = true
180
181 // TODO: In production, call FlowTransactionScheduler.schedule() here
182 // to schedule the next execution. See CLAUDE.md for integration notes.
183 }
184
185 emit HandlerExecutionCompleted(
186 transactionId: id,
187 planId: planId,
188 owner: ownerAddress,
189 amountIn: result.amountIn!,
190 amountOut: result.amountOut!,
191 nextExecutionScheduled: nextScheduled,
192 timestamp: timestamp
193 )
194 } else {
195 emit HandlerExecutionFailed(
196 transactionId: id,
197 planId: planId,
198 owner: ownerAddress,
199 reason: result.errorMessage ?? "Unknown error",
200 timestamp: timestamp
201 )
202 }
203 }
204
205 /// Execute swap using IncrementFi SwapRouter
206 ///
207 /// This uses IncrementFi's production swap infrastructure to swap tokens.
208 /// Supports USDT ↔ FLOW swaps with slippage protection.
209 ///
210 /// @param planRef: Reference to the DCA plan
211 /// @param sourceVaultCap: Capability to withdraw from source vault
212 /// @param targetVaultCap: Capability to deposit to target vault
213 /// @return ExecutionResult with success status and amounts
214 access(self) fun executeSwap(
215 planRef: &DCAPlan.Plan,
216 sourceVaultCap: Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>,
217 targetVaultCap: Capability<&{FungibleToken.Receiver}>
218 ): ExecutionResult {
219 // Get amount to invest
220 let amountIn = planRef.amountPerInterval
221
222 // Borrow source vault and check balance
223 let sourceVault = sourceVaultCap.borrow()
224 ?? panic("Could not borrow source vault")
225
226 if sourceVault.balance < amountIn {
227 return ExecutionResult(
228 success: false,
229 amountIn: nil,
230 amountOut: nil,
231 errorMessage: "Insufficient balance in source vault. Required: ".concat(amountIn.toString()).concat(", Available: ").concat(sourceVault.balance.toString())
232 )
233 }
234
235 // Withdraw tokens to swap
236 let tokensToSwap <- sourceVault.withdraw(amount: amountIn)
237
238 // Determine swap path based on token types
239 let sourceTypeId = planRef.sourceTokenType.identifier
240 let targetTypeId = planRef.targetTokenType.identifier
241
242 let tokenPath: [String] = []
243 if sourceTypeId.contains("TeleportedTetherToken") && targetTypeId.contains("FlowToken") {
244 // USDT → FLOW
245 tokenPath.append("A.cfdd90d4a00f7b5b.TeleportedTetherToken")
246 tokenPath.append("A.1654653399040a61.FlowToken")
247 } else if sourceTypeId.contains("FlowToken") && targetTypeId.contains("TeleportedTetherToken") {
248 // FLOW → USDT
249 tokenPath.append("A.1654653399040a61.FlowToken")
250 tokenPath.append("A.cfdd90d4a00f7b5b.TeleportedTetherToken")
251 } else {
252 // Unsupported swap path
253 destroy tokensToSwap
254 return ExecutionResult(
255 success: false,
256 amountIn: nil,
257 amountOut: nil,
258 errorMessage: "Unsupported token pair. Only USDT ↔ FLOW swaps are currently supported."
259 )
260 }
261
262 // Get expected output amount from IncrementFi
263 let expectedAmountsOut = SwapRouter.getAmountsOut(
264 amountIn: amountIn,
265 tokenKeyPath: tokenPath
266 )
267 let expectedAmountOut = expectedAmountsOut[expectedAmountsOut.length - 1]
268
269 // Calculate minimum output with slippage protection
270 // maxSlippageBps is in basis points (100 = 1%)
271 let slippageMultiplier = UInt64(10000) - planRef.maxSlippageBps
272 let minAmountOut = expectedAmountOut * UFix64(slippageMultiplier) / 10000.0
273
274 // Set deadline (5 minutes from now)
275 let deadline = getCurrentBlock().timestamp + 300.0
276
277 // Execute swap via IncrementFi SwapRouter
278 let swappedTokens <- SwapRouter.swapExactTokensForTokens(
279 exactVaultIn: <-tokensToSwap,
280 amountOutMin: minAmountOut,
281 tokenKeyPath: tokenPath,
282 deadline: deadline
283 )
284
285 // Get actual output amount
286 let actualAmountOut = swappedTokens.balance
287
288 // Deposit to target vault
289 let targetVault = targetVaultCap.borrow()
290 ?? panic("Could not borrow target vault")
291
292 targetVault.deposit(from: <-swappedTokens)
293
294 return ExecutionResult(
295 success: true,
296 amountIn: amountIn,
297 amountOut: actualAmountOut,
298 errorMessage: nil
299 )
300 }
301
302 /// Get supported view types (for resource metadata)
303 access(all) view fun getViews(): [Type] {
304 return [Type<StoragePath>(), Type<PublicPath>()]
305 }
306
307 /// Resolve a specific view type
308 access(all) fun resolveView(_ view: Type): AnyStruct? {
309 switch view {
310 case Type<StoragePath>():
311 return /storage/DCATransactionHandler
312 case Type<PublicPath>():
313 return /public/DCATransactionHandler
314 default:
315 return nil
316 }
317 }
318 }
319
320 /// Result struct for swap execution
321 access(all) struct ExecutionResult {
322 access(all) let success: Bool
323 access(all) let amountIn: UFix64?
324 access(all) let amountOut: UFix64?
325 access(all) let errorMessage: String?
326
327 init(success: Bool, amountIn: UFix64?, amountOut: UFix64?, errorMessage: String?) {
328 self.success = success
329 self.amountIn = amountIn
330 self.amountOut = amountOut
331 self.errorMessage = errorMessage
332 }
333 }
334
335 /// Factory function to create a new handler
336 ///
337 /// @param controllerCap: Capability to the user's DCA controller
338 /// @return New handler resource
339 access(all) fun createHandler(
340 controllerCap: Capability<auth(DCAController.Owner) &DCAController.Controller>
341 ): @Handler {
342 return <- create Handler(controllerCap: controllerCap)
343 }
344}
345