Smart Contract

DCAServiceEVM

A.ca7ee55e4fc3251a.DCAServiceEVM

Valid From

135,717,031

Deployed

5d ago
Feb 23, 2026, 12:47:18 AM UTC

Dependents

17 imports
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