Smart Contract
DCAServiceEVM
A.ca7ee55e4fc3251a.DCAServiceEVM
1import EVM from 0xe467b9dd11fa00df
2import FlowToken from 0x1654653399040a61
3import FungibleToken from 0xf233dcee88fe0abe
4
5/// DCAServiceEVM: EVM-Native DCA Service with Shared COA
6///
7/// Implements Flow CTO's recommended architecture:
8/// - Single shared COA embedded in contract handles all DCA executions
9/// - Users only interact via Metamask (ERC-20 approve)
10/// - Backend creates plans using deployer key
11///
12access(all) contract DCAServiceEVM {
13
14 // ============================================================
15 // Events
16 // ============================================================
17
18 access(all) event ContractInitialized(coaAddress: String)
19 access(all) event PlanCreated(
20 planId: UInt64,
21 userEVMAddress: String,
22 sourceToken: String,
23 targetToken: String,
24 amountPerInterval: UInt256,
25 intervalSeconds: UInt64
26 )
27 access(all) event PlanExecuted(
28 planId: UInt64,
29 userEVMAddress: String,
30 amountIn: UInt256,
31 amountOut: UInt256,
32 executionCount: UInt64
33 )
34 access(all) event PlanPaused(planId: UInt64)
35 access(all) event PlanResumed(planId: UInt64, nextExecutionTime: UFix64)
36 access(all) event PlanCancelled(planId: UInt64)
37 access(all) event ExecutionFailed(planId: UInt64, reason: String)
38 access(all) event InsufficientAllowance(planId: UInt64, required: UInt256, available: UInt256)
39
40 // ============================================================
41 // State
42 // ============================================================
43
44 access(self) let coa: @EVM.CadenceOwnedAccount
45 access(self) let plans: {UInt64: PlanData}
46 access(all) var nextPlanId: UInt64
47 access(all) let adminAddress: Address
48 access(all) let routerAddress: EVM.EVMAddress
49 access(all) let wflowAddress: EVM.EVMAddress
50
51 // ============================================================
52 // Plan Status Enum
53 // ============================================================
54
55 access(all) enum PlanStatus: UInt8 {
56 access(all) case Active
57 access(all) case Paused
58 access(all) case Completed
59 access(all) case Cancelled
60 }
61
62 // ============================================================
63 // Plan Data Struct (simple struct with all public fields)
64 // ============================================================
65
66 access(all) struct PlanData {
67 access(all) let id: UInt64
68 access(all) let userEVMAddressBytes: [UInt8; 20]
69 access(all) let sourceTokenBytes: [UInt8; 20]
70 access(all) let targetTokenBytes: [UInt8; 20]
71 access(all) let amountPerInterval: UInt256
72 access(all) let intervalSeconds: UInt64
73 access(all) let maxSlippageBps: UInt64
74 access(all) let maxExecutions: UInt64?
75 access(all) let feeTier: UInt32
76 access(all) let createdAt: UFix64
77
78 // Mutable state stored separately
79 access(all) let statusRaw: UInt8
80 access(all) let nextExecutionTime: UFix64?
81 access(all) let executionCount: UInt64
82 access(all) let totalSourceSpent: UInt256
83 access(all) let totalTargetReceived: UInt256
84
85 access(all) fun getStatus(): PlanStatus {
86 return PlanStatus(rawValue: self.statusRaw) ?? PlanStatus.Active
87 }
88
89 access(all) fun getUserEVMAddress(): EVM.EVMAddress {
90 return EVM.EVMAddress(bytes: self.userEVMAddressBytes)
91 }
92
93 access(all) fun getSourceToken(): EVM.EVMAddress {
94 return EVM.EVMAddress(bytes: self.sourceTokenBytes)
95 }
96
97 access(all) fun getTargetToken(): EVM.EVMAddress {
98 return EVM.EVMAddress(bytes: self.targetTokenBytes)
99 }
100
101 init(
102 id: UInt64,
103 userEVMAddress: EVM.EVMAddress,
104 sourceToken: EVM.EVMAddress,
105 targetToken: EVM.EVMAddress,
106 amountPerInterval: UInt256,
107 intervalSeconds: UInt64,
108 maxSlippageBps: UInt64,
109 maxExecutions: UInt64?,
110 feeTier: UInt32,
111 firstExecutionTime: UFix64?,
112 statusRaw: UInt8,
113 executionCount: UInt64,
114 totalSourceSpent: UInt256,
115 totalTargetReceived: UInt256
116 ) {
117 self.id = id
118 self.userEVMAddressBytes = userEVMAddress.bytes
119 self.sourceTokenBytes = sourceToken.bytes
120 self.targetTokenBytes = targetToken.bytes
121 self.amountPerInterval = amountPerInterval
122 self.intervalSeconds = intervalSeconds
123 self.maxSlippageBps = maxSlippageBps
124 self.maxExecutions = maxExecutions
125 self.feeTier = feeTier
126 self.createdAt = getCurrentBlock().timestamp
127 self.statusRaw = statusRaw
128 self.nextExecutionTime = firstExecutionTime
129 self.executionCount = executionCount
130 self.totalSourceSpent = totalSourceSpent
131 self.totalTargetReceived = totalTargetReceived
132 }
133 }
134
135 // ============================================================
136 // Public View Functions
137 // ============================================================
138
139 access(all) view fun getCOAAddress(): EVM.EVMAddress {
140 return self.coa.address()
141 }
142
143 access(all) view fun getPlan(planId: UInt64): PlanData? {
144 return self.plans[planId]
145 }
146
147 access(all) fun getUserPlans(userEVMAddress: EVM.EVMAddress): [PlanData] {
148 let result: [PlanData] = []
149 let targetAddrLower = userEVMAddress.toString().toLower()
150 for planId in self.plans.keys {
151 if let plan = self.plans[planId] {
152 if plan.getUserEVMAddress().toString().toLower() == targetAddrLower {
153 result.append(plan)
154 }
155 }
156 }
157 return result
158 }
159
160 access(all) view fun getTotalPlans(): Int {
161 return self.plans.length
162 }
163
164 access(all) fun checkAllowance(
165 userEVMAddress: EVM.EVMAddress,
166 tokenAddress: EVM.EVMAddress
167 ): UInt256 {
168 let calldata = EVM.encodeABIWithSignature(
169 "allowance(address,address)",
170 [userEVMAddress, self.coa.address()]
171 )
172 let result = self.coa.call(
173 to: tokenAddress,
174 data: calldata,
175 gasLimit: 50_000,
176 value: EVM.Balance(attoflow: 0)
177 )
178 if result.status == EVM.Status.successful && result.data.length >= 32 {
179 let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: result.data)
180 if decoded.length > 0 {
181 return decoded[0] as! UInt256
182 }
183 }
184 return 0
185 }
186
187 // ============================================================
188 // Plan Management
189 // ============================================================
190
191 access(all) fun createPlan(
192 userEVMAddress: EVM.EVMAddress,
193 sourceToken: EVM.EVMAddress,
194 targetToken: EVM.EVMAddress,
195 amountPerInterval: UInt256,
196 intervalSeconds: UInt64,
197 maxSlippageBps: UInt64,
198 maxExecutions: UInt64?,
199 feeTier: UInt32,
200 firstExecutionTime: UFix64
201 ): UInt64 {
202 pre {
203 amountPerInterval > 0: "Amount must be positive"
204 intervalSeconds > 0: "Interval must be positive"
205 maxSlippageBps <= 5000: "Max slippage cannot exceed 50%"
206 firstExecutionTime > getCurrentBlock().timestamp: "First execution must be in future"
207 }
208
209 let planId = self.nextPlanId
210 self.nextPlanId = self.nextPlanId + 1
211
212 let plan = PlanData(
213 id: planId,
214 userEVMAddress: userEVMAddress,
215 sourceToken: sourceToken,
216 targetToken: targetToken,
217 amountPerInterval: amountPerInterval,
218 intervalSeconds: intervalSeconds,
219 maxSlippageBps: maxSlippageBps,
220 maxExecutions: maxExecutions,
221 feeTier: feeTier,
222 firstExecutionTime: firstExecutionTime,
223 statusRaw: PlanStatus.Active.rawValue,
224 executionCount: 0,
225 totalSourceSpent: 0,
226 totalTargetReceived: 0
227 )
228
229 self.plans[planId] = plan
230
231 emit PlanCreated(
232 planId: planId,
233 userEVMAddress: userEVMAddress.toString(),
234 sourceToken: sourceToken.toString(),
235 targetToken: targetToken.toString(),
236 amountPerInterval: amountPerInterval,
237 intervalSeconds: intervalSeconds
238 )
239
240 return planId
241 }
242
243 access(all) fun pausePlan(planId: UInt64) {
244 let plan = self.plans[planId] ?? panic("Plan not found")
245 self.plans[planId] = PlanData(
246 id: plan.id,
247 userEVMAddress: plan.getUserEVMAddress(),
248 sourceToken: plan.getSourceToken(),
249 targetToken: plan.getTargetToken(),
250 amountPerInterval: plan.amountPerInterval,
251 intervalSeconds: plan.intervalSeconds,
252 maxSlippageBps: plan.maxSlippageBps,
253 maxExecutions: plan.maxExecutions,
254 feeTier: plan.feeTier,
255 firstExecutionTime: nil,
256 statusRaw: PlanStatus.Paused.rawValue,
257 executionCount: plan.executionCount,
258 totalSourceSpent: plan.totalSourceSpent,
259 totalTargetReceived: plan.totalTargetReceived
260 )
261 emit PlanPaused(planId: planId)
262 }
263
264 access(all) fun resumePlan(planId: UInt64, nextExecTime: UFix64?) {
265 let plan = self.plans[planId] ?? panic("Plan not found")
266 let execTime = nextExecTime ?? (getCurrentBlock().timestamp + UFix64(plan.intervalSeconds))
267 self.plans[planId] = PlanData(
268 id: plan.id,
269 userEVMAddress: plan.getUserEVMAddress(),
270 sourceToken: plan.getSourceToken(),
271 targetToken: plan.getTargetToken(),
272 amountPerInterval: plan.amountPerInterval,
273 intervalSeconds: plan.intervalSeconds,
274 maxSlippageBps: plan.maxSlippageBps,
275 maxExecutions: plan.maxExecutions,
276 feeTier: plan.feeTier,
277 firstExecutionTime: execTime,
278 statusRaw: PlanStatus.Active.rawValue,
279 executionCount: plan.executionCount,
280 totalSourceSpent: plan.totalSourceSpent,
281 totalTargetReceived: plan.totalTargetReceived
282 )
283 emit PlanResumed(planId: planId, nextExecutionTime: execTime)
284 }
285
286 access(all) fun cancelPlan(planId: UInt64) {
287 let plan = self.plans[planId] ?? panic("Plan not found")
288 self.plans[planId] = PlanData(
289 id: plan.id,
290 userEVMAddress: plan.getUserEVMAddress(),
291 sourceToken: plan.getSourceToken(),
292 targetToken: plan.getTargetToken(),
293 amountPerInterval: plan.amountPerInterval,
294 intervalSeconds: plan.intervalSeconds,
295 maxSlippageBps: plan.maxSlippageBps,
296 maxExecutions: plan.maxExecutions,
297 feeTier: plan.feeTier,
298 firstExecutionTime: nil,
299 statusRaw: PlanStatus.Cancelled.rawValue,
300 executionCount: plan.executionCount,
301 totalSourceSpent: plan.totalSourceSpent,
302 totalTargetReceived: plan.totalTargetReceived
303 )
304 emit PlanCancelled(planId: planId)
305 }
306
307 // ============================================================
308 // Execution
309 // ============================================================
310
311 access(all) fun executePlan(planId: UInt64): Bool {
312 let planOpt = self.plans[planId]
313 if planOpt == nil {
314 emit ExecutionFailed(planId: planId, reason: "Plan not found")
315 return false
316 }
317 let plan = planOpt!
318
319 if plan.getStatus() != PlanStatus.Active {
320 emit ExecutionFailed(planId: planId, reason: "Plan not active")
321 return false
322 }
323
324 if let maxExec = plan.maxExecutions {
325 if plan.executionCount >= maxExec {
326 self.updatePlanStatus(planId: planId, status: PlanStatus.Completed, nextExecTime: nil)
327 emit ExecutionFailed(planId: planId, reason: "Max executions reached")
328 return false
329 }
330 }
331
332 let userAddr = plan.getUserEVMAddress()
333 let sourceToken = plan.getSourceToken()
334 let targetToken = plan.getTargetToken()
335
336 let allowance = self.checkAllowance(userEVMAddress: userAddr, tokenAddress: sourceToken)
337 if allowance < plan.amountPerInterval {
338 emit InsufficientAllowance(planId: planId, required: plan.amountPerInterval, available: allowance)
339 return false
340 }
341
342 let pullSuccess = self.pullTokens(from: userAddr, token: sourceToken, amount: plan.amountPerInterval)
343 if !pullSuccess {
344 emit ExecutionFailed(planId: planId, reason: "Failed to pull tokens")
345 return false
346 }
347
348 let amountOut = self.executeSwap(
349 tokenIn: sourceToken,
350 tokenOut: targetToken,
351 amountIn: plan.amountPerInterval,
352 minAmountOut: 0,
353 feeTier: plan.feeTier
354 )
355 if amountOut == 0 {
356 let _ = self.sendTokens(to: userAddr, token: sourceToken, amount: plan.amountPerInterval)
357 emit ExecutionFailed(planId: planId, reason: "Swap failed")
358 return false
359 }
360
361 let sendSuccess = self.sendTokens(to: userAddr, token: targetToken, amount: amountOut)
362 if !sendSuccess {
363 emit ExecutionFailed(planId: planId, reason: "Failed to send tokens")
364 return false
365 }
366
367 // Update plan with new execution stats
368 let newExecCount = plan.executionCount + 1
369 let newSourceSpent = plan.totalSourceSpent + plan.amountPerInterval
370 let newTargetReceived = plan.totalTargetReceived + amountOut
371
372 var newStatus = PlanStatus.Active.rawValue
373 var newNextExecTime: UFix64? = getCurrentBlock().timestamp + UFix64(plan.intervalSeconds)
374
375 if let maxExec = plan.maxExecutions {
376 if newExecCount >= maxExec {
377 newStatus = PlanStatus.Completed.rawValue
378 newNextExecTime = nil
379 }
380 }
381
382 self.plans[planId] = PlanData(
383 id: plan.id,
384 userEVMAddress: userAddr,
385 sourceToken: sourceToken,
386 targetToken: targetToken,
387 amountPerInterval: plan.amountPerInterval,
388 intervalSeconds: plan.intervalSeconds,
389 maxSlippageBps: plan.maxSlippageBps,
390 maxExecutions: plan.maxExecutions,
391 feeTier: plan.feeTier,
392 firstExecutionTime: newNextExecTime,
393 statusRaw: newStatus,
394 executionCount: newExecCount,
395 totalSourceSpent: newSourceSpent,
396 totalTargetReceived: newTargetReceived
397 )
398
399 emit PlanExecuted(
400 planId: planId,
401 userEVMAddress: userAddr.toString(),
402 amountIn: plan.amountPerInterval,
403 amountOut: amountOut,
404 executionCount: newExecCount
405 )
406
407 return true
408 }
409
410 access(self) fun updatePlanStatus(planId: UInt64, status: PlanStatus, nextExecTime: UFix64?) {
411 let plan = self.plans[planId]!
412 self.plans[planId] = PlanData(
413 id: plan.id,
414 userEVMAddress: plan.getUserEVMAddress(),
415 sourceToken: plan.getSourceToken(),
416 targetToken: plan.getTargetToken(),
417 amountPerInterval: plan.amountPerInterval,
418 intervalSeconds: plan.intervalSeconds,
419 maxSlippageBps: plan.maxSlippageBps,
420 maxExecutions: plan.maxExecutions,
421 feeTier: plan.feeTier,
422 firstExecutionTime: nextExecTime,
423 statusRaw: status.rawValue,
424 executionCount: plan.executionCount,
425 totalSourceSpent: plan.totalSourceSpent,
426 totalTargetReceived: plan.totalTargetReceived
427 )
428 }
429
430 // ============================================================
431 // EVM Interaction
432 // ============================================================
433
434 access(self) fun pullTokens(from: EVM.EVMAddress, token: EVM.EVMAddress, amount: UInt256): Bool {
435 let calldata = EVM.encodeABIWithSignature(
436 "transferFrom(address,address,uint256)",
437 [from, self.coa.address(), amount]
438 )
439 let result = self.coa.call(to: token, data: calldata, gasLimit: 100_000, value: EVM.Balance(attoflow: 0))
440 return result.status == EVM.Status.successful
441 }
442
443 access(self) fun sendTokens(to: EVM.EVMAddress, token: EVM.EVMAddress, amount: UInt256): Bool {
444 let calldata = EVM.encodeABIWithSignature("transfer(address,uint256)", [to, amount])
445 let result = self.coa.call(to: token, data: calldata, gasLimit: 100_000, value: EVM.Balance(attoflow: 0))
446 return result.status == EVM.Status.successful
447 }
448
449 access(self) fun executeSwap(
450 tokenIn: EVM.EVMAddress,
451 tokenOut: EVM.EVMAddress,
452 amountIn: UInt256,
453 minAmountOut: UInt256,
454 feeTier: UInt32
455 ): UInt256 {
456 // Approve router
457 let approveData = EVM.encodeABIWithSignature("approve(address,uint256)", [self.routerAddress, amountIn])
458 let approveResult = self.coa.call(to: tokenIn, data: approveData, gasLimit: 100_000, value: EVM.Balance(attoflow: 0))
459 if approveResult.status != EVM.Status.successful { return 0 }
460
461 // Build path: tokenIn + fee + tokenOut
462 var pathBytes: [UInt8] = []
463 for byte in tokenIn.bytes { pathBytes.append(byte) }
464 pathBytes.append(UInt8((feeTier >> 16) & 0xFF))
465 pathBytes.append(UInt8((feeTier >> 8) & 0xFF))
466 pathBytes.append(UInt8(feeTier & 0xFF))
467 for byte in tokenOut.bytes { pathBytes.append(byte) }
468
469 // exactInput selector: 0xb858183f
470 let selector: [UInt8] = [0xb8, 0x58, 0x18, 0x3f]
471 let tupleData = self.encodeExactInputParams(pathBytes: pathBytes, recipient: self.coa.address(), amountIn: amountIn, amountOutMin: minAmountOut)
472 let head = self.abiUInt256(32)
473 let calldata = selector.concat(head).concat(tupleData)
474
475 let swapResult = self.coa.call(to: self.routerAddress, data: calldata, gasLimit: 500_000, value: EVM.Balance(attoflow: 0))
476
477 if swapResult.status == EVM.Status.successful && swapResult.data.length >= 32 {
478 let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: swapResult.data)
479 if decoded.length > 0 { return decoded[0] as! UInt256 }
480 }
481 return 0
482 }
483
484 // ============================================================
485 // ABI Helpers
486 // ============================================================
487
488 access(self) fun abiUInt256(_ value: UInt256): [UInt8] {
489 var result: [UInt8] = []
490 var remaining = value
491 var bytes: [UInt8] = []
492 if remaining == 0 { bytes.append(0) }
493 else { while remaining > 0 { bytes.append(UInt8(remaining % 256)); remaining = remaining / 256 } }
494 while bytes.length < 32 { bytes.append(0) }
495 var i = 31
496 while i >= 0 { result.append(bytes[i]); if i == 0 { break }; i = i - 1 }
497 return result
498 }
499
500 access(self) fun abiAddress(_ addr: EVM.EVMAddress): [UInt8] {
501 var result: [UInt8] = []
502 var i = 0
503 while i < 12 { result.append(0); i = i + 1 }
504 for byte in addr.bytes { result.append(byte) }
505 return result
506 }
507
508 access(self) fun abiDynamicBytes(_ data: [UInt8]): [UInt8] {
509 var result: [UInt8] = []
510 result = result.concat(self.abiUInt256(UInt256(data.length)))
511 result = result.concat(data)
512 let padding = (32 - (data.length % 32)) % 32
513 var i = 0
514 while i < padding { result.append(0); i = i + 1 }
515 return result
516 }
517
518 access(self) fun encodeExactInputParams(pathBytes: [UInt8], recipient: EVM.EVMAddress, amountIn: UInt256, amountOutMin: UInt256): [UInt8] {
519 let tupleHeadSize = 32 * 4
520 var head: [[UInt8]] = []
521 var tail: [[UInt8]] = []
522 head.append(self.abiUInt256(UInt256(tupleHeadSize)))
523 tail.append(self.abiDynamicBytes(pathBytes))
524 head.append(self.abiAddress(recipient))
525 head.append(self.abiUInt256(amountIn))
526 head.append(self.abiUInt256(amountOutMin))
527 var result: [UInt8] = []
528 for part in head { result = result.concat(part) }
529 for part in tail { result = result.concat(part) }
530 return result
531 }
532
533 // ============================================================
534 // Gas Management
535 // ============================================================
536
537 access(all) fun depositGas(vault: @{FungibleToken.Vault}) {
538 pre { vault.isInstance(Type<@FlowToken.Vault>()): "Must deposit FLOW" }
539 self.coa.deposit(from: <- (vault as! @FlowToken.Vault))
540 }
541
542 access(all) view fun getCOABalance(): UFix64 {
543 return self.coa.balance().inFLOW()
544 }
545
546 // ============================================================
547 // Init
548 // ============================================================
549
550 init() {
551 self.coa <- EVM.createCadenceOwnedAccount()
552 self.plans = {}
553 self.nextPlanId = 1
554 self.adminAddress = self.account.address
555 self.routerAddress = EVM.addressFromString("0xeEDC6Ff75e1b10B903D9013c358e446a73d35341")
556 self.wflowAddress = EVM.addressFromString("0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e")
557 emit ContractInitialized(coaAddress: self.coa.address().toString())
558 }
559}
560