Smart Contract

DCAPlanUnified

A.ca7ee55e4fc3251a.DCAPlanUnified

Valid From

135,621,321

Deployed

5d ago
Feb 23, 2026, 12:44:11 AM UTC

Dependents

27 imports
1import DeFiMath from 0xca7ee55e4fc3251a
2import FungibleToken from 0xf233dcee88fe0abe
3
4/// DCAPlanUnified: Unified DCA Plan Resource
5///
6/// Supports both IncrementFi (USDC) and EVM DEX (USDF) swaps.
7/// Token routing is determined by the handler at execution time.
8///
9/// Storage: /storage/DCAPlanUnified
10access(all) contract DCAPlanUnified {
11
12    access(all) event PlanCreated(
13        planId: UInt64,
14        owner: Address,
15        sourceTokenType: String,
16        targetTokenType: String,
17        amountPerInterval: UFix64,
18        intervalSeconds: UInt64,
19        maxSlippageBps: UInt64
20    )
21
22    access(all) event PlanExecuted(
23        planId: UInt64,
24        executionCount: UInt64,
25        amountIn: UFix64,
26        amountOut: UFix64,
27        executionPriceFP128: UInt128,
28        newAvgPriceFP128: UInt128,
29        timestamp: UFix64
30    )
31
32    access(all) event PlanUpdated(
33        planId: UInt64,
34        active: Bool,
35        nextExecutionTime: UFix64?
36    )
37
38    access(all) event PlanPaused(planId: UInt64, timestamp: UFix64)
39    access(all) event PlanResumed(planId: UInt64, nextExecutionTime: UFix64)
40
41    access(all) let PlanStoragePath: StoragePath
42    access(all) let PlanPublicPath: PublicPath
43
44    access(all) var nextPlanId: UInt64
45
46    access(all) enum PlanStatus: UInt8 {
47        access(all) case Active
48        access(all) case Paused
49        access(all) case Completed
50        access(all) case Cancelled
51    }
52
53    /// Helper to detect tokens that require EVM/UniswapV3 swap path
54    /// Currently only USDF (no IncrementFi liquidity) - USDC uses IncrementFi
55    access(all) view fun isEVMToken(tokenType: Type): Bool {
56        // Only USDF requires EVM path - USDC has IncrementFi liquidity
57        // USDF contract: 0x2aabea2058b5ac2d339b163c6ab6f2b6d53aabed
58        return tokenType.identifier.contains("2aabea2058b5ac2d339b163c6ab6f2b6d53aabed")
59    }
60
61    access(all) resource Plan {
62        access(all) let id: UInt64
63        access(all) let sourceTokenType: Type
64        access(all) let targetTokenType: Type
65        access(all) let amountPerInterval: UFix64
66        access(all) let intervalSeconds: UInt64
67        access(all) let maxSlippageBps: UInt64
68        access(all) let maxExecutions: UInt64?
69
70        access(all) var status: PlanStatus
71        access(all) var nextExecutionTime: UFix64?
72        access(all) var executionCount: UInt64
73        access(all) var totalSourceInvested: UFix64
74        access(all) var totalTargetAcquired: UFix64
75        access(all) var avgExecutionPriceFP128: UInt128
76        access(all) let createdAt: UFix64
77        access(all) var lastExecutedAt: UFix64?
78
79        init(
80            sourceTokenType: Type,
81            targetTokenType: Type,
82            amountPerInterval: UFix64,
83            intervalSeconds: UInt64,
84            maxSlippageBps: UInt64,
85            maxExecutions: UInt64?,
86            firstExecutionTime: UFix64
87        ) {
88            pre {
89                amountPerInterval > 0.0: "Amount per interval must be positive"
90                intervalSeconds > 0: "Interval must be positive"
91                DeFiMath.isValidSlippage(slippageBps: maxSlippageBps): "Invalid slippage value"
92                firstExecutionTime > getCurrentBlock().timestamp: "First execution must be in the future"
93            }
94
95            self.id = DCAPlanUnified.nextPlanId
96            DCAPlanUnified.nextPlanId = DCAPlanUnified.nextPlanId + 1
97
98            self.sourceTokenType = sourceTokenType
99            self.targetTokenType = targetTokenType
100            self.amountPerInterval = amountPerInterval
101            self.intervalSeconds = intervalSeconds
102            self.maxSlippageBps = maxSlippageBps
103            self.maxExecutions = maxExecutions
104
105            self.status = PlanStatus.Active
106            self.nextExecutionTime = firstExecutionTime
107            self.executionCount = 0
108            self.totalSourceInvested = 0.0
109            self.totalTargetAcquired = 0.0
110            self.avgExecutionPriceFP128 = 0
111
112            self.createdAt = getCurrentBlock().timestamp
113            self.lastExecutedAt = nil
114        }
115
116        /// Check if this plan requires EVM execution
117        access(all) view fun requiresEVM(): Bool {
118            return DCAPlanUnified.isEVMToken(tokenType: self.sourceTokenType) ||
119                   DCAPlanUnified.isEVMToken(tokenType: self.targetTokenType)
120        }
121
122        access(all) view fun isReadyForExecution(): Bool {
123            if self.status != PlanStatus.Active {
124                return false
125            }
126            if let nextTime = self.nextExecutionTime {
127                return getCurrentBlock().timestamp >= nextTime
128            }
129            return false
130        }
131
132        access(all) view fun hasReachedMaxExecutions(): Bool {
133            if let max = self.maxExecutions {
134                return self.executionCount >= max
135            }
136            return false
137        }
138
139        access(all) fun recordExecution(amountIn: UFix64, amountOut: UFix64) {
140            pre {
141                self.status == PlanStatus.Active: "Plan must be active"
142                amountIn > 0.0: "Amount in must be positive"
143                amountOut > 0.0: "Amount out must be positive"
144            }
145
146            let executionPrice = DeFiMath.calculatePriceFP128(
147                amountIn: amountIn,
148                amountOut: amountOut
149            )
150
151            let newAvgPrice = DeFiMath.updateWeightedAveragePriceFP128(
152                previousAvgPriceFP128: self.avgExecutionPriceFP128,
153                totalPreviousIn: self.totalSourceInvested,
154                newAmountIn: amountIn,
155                newAmountOut: amountOut
156            )
157
158            self.totalSourceInvested = self.totalSourceInvested + amountIn
159            self.totalTargetAcquired = self.totalTargetAcquired + amountOut
160            self.avgExecutionPriceFP128 = newAvgPrice
161            self.executionCount = self.executionCount + 1
162            self.lastExecutedAt = getCurrentBlock().timestamp
163
164            emit PlanExecuted(
165                planId: self.id,
166                executionCount: self.executionCount,
167                amountIn: amountIn,
168                amountOut: amountOut,
169                executionPriceFP128: executionPrice,
170                newAvgPriceFP128: newAvgPrice,
171                timestamp: getCurrentBlock().timestamp
172            )
173
174            if self.hasReachedMaxExecutions() {
175                self.status = PlanStatus.Completed
176                self.nextExecutionTime = nil
177                emit PlanUpdated(planId: self.id, active: false, nextExecutionTime: nil)
178            }
179        }
180
181        access(all) fun scheduleNextExecution() {
182            pre {
183                self.status == PlanStatus.Active: "Plan must be active to schedule"
184                !self.hasReachedMaxExecutions(): "Plan has reached max executions"
185            }
186
187            let currentTime = getCurrentBlock().timestamp
188            let nextTime = currentTime + UFix64(self.intervalSeconds)
189            self.nextExecutionTime = nextTime
190
191            emit PlanUpdated(
192                planId: self.id,
193                active: true,
194                nextExecutionTime: nextTime
195            )
196        }
197
198        access(all) fun pause() {
199            pre {
200                self.status == PlanStatus.Active: "Plan must be active to pause"
201            }
202            self.status = PlanStatus.Paused
203            self.nextExecutionTime = nil
204            emit PlanPaused(planId: self.id, timestamp: getCurrentBlock().timestamp)
205        }
206
207        access(all) fun resume(nextExecutionTime: UFix64?) {
208            pre {
209                self.status == PlanStatus.Paused: "Plan must be paused to resume"
210                !self.hasReachedMaxExecutions(): "Cannot resume completed plan"
211            }
212            self.status = PlanStatus.Active
213            let nextTime = nextExecutionTime ?? (getCurrentBlock().timestamp + UFix64(self.intervalSeconds))
214            self.nextExecutionTime = nextTime
215            emit PlanResumed(planId: self.id, nextExecutionTime: nextTime)
216        }
217
218        access(all) fun getDetails(): PlanDetails {
219            return PlanDetails(
220                id: self.id,
221                sourceTokenType: self.sourceTokenType.identifier,
222                targetTokenType: self.targetTokenType.identifier,
223                amountPerInterval: self.amountPerInterval,
224                intervalSeconds: self.intervalSeconds,
225                maxSlippageBps: self.maxSlippageBps,
226                maxExecutions: self.maxExecutions,
227                status: self.status.rawValue,
228                nextExecutionTime: self.nextExecutionTime,
229                executionCount: self.executionCount,
230                totalSourceInvested: self.totalSourceInvested,
231                totalTargetAcquired: self.totalTargetAcquired,
232                avgExecutionPriceFP128: self.avgExecutionPriceFP128,
233                avgExecutionPriceDisplay: DeFiMath.fp128ToUFix64(priceFP128: self.avgExecutionPriceFP128),
234                createdAt: self.createdAt,
235                lastExecutedAt: self.lastExecutedAt,
236                requiresEVM: self.requiresEVM()
237            )
238        }
239    }
240
241    access(all) struct PlanDetails {
242        access(all) let id: UInt64
243        access(all) let sourceTokenType: String
244        access(all) let targetTokenType: String
245        access(all) let amountPerInterval: UFix64
246        access(all) let intervalSeconds: UInt64
247        access(all) let maxSlippageBps: UInt64
248        access(all) let maxExecutions: UInt64?
249        access(all) let status: UInt8
250        access(all) let nextExecutionTime: UFix64?
251        access(all) let executionCount: UInt64
252        access(all) let totalSourceInvested: UFix64
253        access(all) let totalTargetAcquired: UFix64
254        access(all) let avgExecutionPriceFP128: UInt128
255        access(all) let avgExecutionPriceDisplay: UFix64
256        access(all) let createdAt: UFix64
257        access(all) let lastExecutedAt: UFix64?
258        access(all) let requiresEVM: Bool
259
260        init(
261            id: UInt64,
262            sourceTokenType: String,
263            targetTokenType: String,
264            amountPerInterval: UFix64,
265            intervalSeconds: UInt64,
266            maxSlippageBps: UInt64,
267            maxExecutions: UInt64?,
268            status: UInt8,
269            nextExecutionTime: UFix64?,
270            executionCount: UInt64,
271            totalSourceInvested: UFix64,
272            totalTargetAcquired: UFix64,
273            avgExecutionPriceFP128: UInt128,
274            avgExecutionPriceDisplay: UFix64,
275            createdAt: UFix64,
276            lastExecutedAt: UFix64?,
277            requiresEVM: Bool
278        ) {
279            self.id = id
280            self.sourceTokenType = sourceTokenType
281            self.targetTokenType = targetTokenType
282            self.amountPerInterval = amountPerInterval
283            self.intervalSeconds = intervalSeconds
284            self.maxSlippageBps = maxSlippageBps
285            self.maxExecutions = maxExecutions
286            self.status = status
287            self.nextExecutionTime = nextExecutionTime
288            self.executionCount = executionCount
289            self.totalSourceInvested = totalSourceInvested
290            self.totalTargetAcquired = totalTargetAcquired
291            self.avgExecutionPriceFP128 = avgExecutionPriceFP128
292            self.avgExecutionPriceDisplay = avgExecutionPriceDisplay
293            self.createdAt = createdAt
294            self.lastExecutedAt = lastExecutedAt
295            self.requiresEVM = requiresEVM
296        }
297    }
298
299    access(all) fun createPlan(
300        sourceTokenType: Type,
301        targetTokenType: Type,
302        amountPerInterval: UFix64,
303        intervalSeconds: UInt64,
304        maxSlippageBps: UInt64,
305        maxExecutions: UInt64?,
306        firstExecutionTime: UFix64
307    ): @Plan {
308        return <- create Plan(
309            sourceTokenType: sourceTokenType,
310            targetTokenType: targetTokenType,
311            amountPerInterval: amountPerInterval,
312            intervalSeconds: intervalSeconds,
313            maxSlippageBps: maxSlippageBps,
314            maxExecutions: maxExecutions,
315            firstExecutionTime: firstExecutionTime
316        )
317    }
318
319    init() {
320        self.nextPlanId = 1
321        self.PlanStoragePath = /storage/DCAPlanUnified
322        self.PlanPublicPath = /public/DCAPlanUnified
323    }
324}
325