Smart Contract
DCAPlanUnified
A.ca7ee55e4fc3251a.DCAPlanUnified
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