Smart Contract
DCAPlan
A.ca7ee55e4fc3251a.DCAPlan
1import DeFiMath from 0xca7ee55e4fc3251a
2import FungibleToken from 0xf233dcee88fe0abe
3
4/// DCAPlan: Resource representing a single Dollar-Cost Averaging investment plan
5///
6/// Each plan represents a recurring investment strategy:
7/// - Source token (e.g., FLOW) → Target token (e.g., Beaver)
8/// - Executes at regular intervals using Scheduled Transactions
9/// - Tracks performance metrics (total invested, acquired, average price)
10/// - Uses IncrementFi connectors for swaps
11///
12/// Educational Notes:
13/// - Plans are resources owned by users, stored in DCAController
14/// - Each execution is atomic: withdraw → swap → deposit
15/// - Slippage protection prevents unfavorable trades
16access(all) contract DCAPlan {
17
18 /// Event emitted when a new DCA plan is created
19 access(all) event PlanCreated(
20 planId: UInt64,
21 owner: Address,
22 sourceTokenType: String,
23 targetTokenType: String,
24 amountPerInterval: UFix64,
25 intervalSeconds: UInt64,
26 maxSlippageBps: UInt64
27 )
28
29 /// Event emitted when a plan is executed
30 access(all) event PlanExecuted(
31 planId: UInt64,
32 executionCount: UInt64,
33 amountIn: UFix64,
34 amountOut: UFix64,
35 executionPriceFP128: UInt128,
36 newAvgPriceFP128: UInt128,
37 timestamp: UFix64
38 )
39
40 /// Event emitted when a plan is updated
41 access(all) event PlanUpdated(
42 planId: UInt64,
43 active: Bool,
44 nextExecutionTime: UFix64?
45 )
46
47 /// Event emitted when a plan is paused
48 access(all) event PlanPaused(planId: UInt64, timestamp: UFix64)
49
50 /// Event emitted when a plan is resumed
51 access(all) event PlanResumed(planId: UInt64, nextExecutionTime: UFix64)
52
53 /// Storage and public paths
54 access(all) let PlanStoragePath: StoragePath
55 access(all) let PlanPublicPath: PublicPath
56
57 /// Global plan ID counter
58 access(all) var nextPlanId: UInt64
59
60 /// Status enum for plan state
61 access(all) enum PlanStatus: UInt8 {
62 access(all) case Active
63 access(all) case Paused
64 access(all) case Completed
65 access(all) case Cancelled
66 }
67
68 /// The core DCA Plan resource
69 ///
70 /// This resource encapsulates all state and logic for a single DCA plan.
71 /// It is owned by the user and stored in their DCAController.
72 access(all) resource Plan {
73 /// Unique identifier for this plan
74 access(all) let id: UInt64
75
76 /// Source token type identifier (e.g., A.1654653399040a61.FlowToken.Vault)
77 access(all) let sourceTokenType: Type
78
79 /// Target token type identifier (e.g., A.687e1a7aef17b78b.Beaver.Vault)
80 access(all) let targetTokenType: Type
81
82 /// Amount of source token to invest per interval
83 access(all) let amountPerInterval: UFix64
84
85 /// Interval between executions in seconds
86 access(all) let intervalSeconds: UInt64
87
88 /// Maximum acceptable slippage in basis points (e.g., 100 = 1%)
89 access(all) let maxSlippageBps: UInt64
90
91 /// Optional maximum number of executions (nil = unlimited)
92 access(all) let maxExecutions: UInt64?
93
94 /// Current status of the plan
95 access(all) var status: PlanStatus
96
97 /// Timestamp of next scheduled execution
98 access(all) var nextExecutionTime: UFix64?
99
100 /// Number of times this plan has been executed
101 access(all) var executionCount: UInt64
102
103 /// Total amount of source token invested (sum of all executions)
104 access(all) var totalSourceInvested: UFix64
105
106 /// Total amount of target token acquired (sum of all executions)
107 access(all) var totalTargetAcquired: UFix64
108
109 /// Weighted average execution price in FP128 format
110 /// Represents target tokens received per source token
111 access(all) var avgExecutionPriceFP128: UInt128
112
113 /// Timestamp when plan was created
114 access(all) let createdAt: UFix64
115
116 /// Timestamp when plan was last executed
117 access(all) var lastExecutedAt: UFix64?
118
119 init(
120 sourceTokenType: Type,
121 targetTokenType: Type,
122 amountPerInterval: UFix64,
123 intervalSeconds: UInt64,
124 maxSlippageBps: UInt64,
125 maxExecutions: UInt64?,
126 firstExecutionTime: UFix64
127 ) {
128 pre {
129 amountPerInterval > 0.0: "Amount per interval must be positive"
130 intervalSeconds > 0: "Interval must be positive"
131 DeFiMath.isValidSlippage(slippageBps: maxSlippageBps): "Invalid slippage value"
132 firstExecutionTime > getCurrentBlock().timestamp: "First execution must be in the future"
133 }
134
135 self.id = DCAPlan.nextPlanId
136 DCAPlan.nextPlanId = DCAPlan.nextPlanId + 1
137
138 self.sourceTokenType = sourceTokenType
139 self.targetTokenType = targetTokenType
140 self.amountPerInterval = amountPerInterval
141 self.intervalSeconds = intervalSeconds
142 self.maxSlippageBps = maxSlippageBps
143 self.maxExecutions = maxExecutions
144
145 self.status = PlanStatus.Active
146 self.nextExecutionTime = firstExecutionTime
147 self.executionCount = 0
148 self.totalSourceInvested = 0.0
149 self.totalTargetAcquired = 0.0
150 self.avgExecutionPriceFP128 = 0
151
152 self.createdAt = getCurrentBlock().timestamp
153 self.lastExecutedAt = nil
154 }
155
156 /// Check if plan is ready for execution
157 ///
158 /// @return true if plan should execute now
159 access(all) view fun isReadyForExecution(): Bool {
160 if self.status != PlanStatus.Active {
161 return false
162 }
163
164 if let nextTime = self.nextExecutionTime {
165 return getCurrentBlock().timestamp >= nextTime
166 }
167
168 return false
169 }
170
171 /// Check if plan has reached max executions
172 access(all) view fun hasReachedMaxExecutions(): Bool {
173 if let max = self.maxExecutions {
174 return self.executionCount >= max
175 }
176 return false
177 }
178
179 /// Record a successful execution
180 ///
181 /// Updates all accounting fields and calculates new average price.
182 /// This should be called by the scheduled handler after swap execution.
183 access(all) fun recordExecution(amountIn: UFix64, amountOut: UFix64) {
184 pre {
185 self.status == PlanStatus.Active: "Plan must be active"
186 amountIn > 0.0: "Amount in must be positive"
187 amountOut > 0.0: "Amount out must be positive"
188 }
189
190 // Calculate execution price
191 let executionPrice = DeFiMath.calculatePriceFP128(
192 amountIn: amountIn,
193 amountOut: amountOut
194 )
195
196 // Update weighted average price
197 let newAvgPrice = DeFiMath.updateWeightedAveragePriceFP128(
198 previousAvgPriceFP128: self.avgExecutionPriceFP128,
199 totalPreviousIn: self.totalSourceInvested,
200 newAmountIn: amountIn,
201 newAmountOut: amountOut
202 )
203
204 // Update accounting
205 self.totalSourceInvested = self.totalSourceInvested + amountIn
206 self.totalTargetAcquired = self.totalTargetAcquired + amountOut
207 self.avgExecutionPriceFP128 = newAvgPrice
208 self.executionCount = self.executionCount + 1
209 self.lastExecutedAt = getCurrentBlock().timestamp
210
211 // Emit execution event
212 emit PlanExecuted(
213 planId: self.id,
214 executionCount: self.executionCount,
215 amountIn: amountIn,
216 amountOut: amountOut,
217 executionPriceFP128: executionPrice,
218 newAvgPriceFP128: newAvgPrice,
219 timestamp: getCurrentBlock().timestamp
220 )
221
222 // Check if plan should complete
223 if self.hasReachedMaxExecutions() {
224 self.status = PlanStatus.Completed
225 self.nextExecutionTime = nil
226 emit PlanUpdated(planId: self.id, active: false, nextExecutionTime: nil)
227 }
228 }
229
230 /// Schedule next execution
231 ///
232 /// Calculates and sets the next execution time based on interval.
233 /// Should be called after recordExecution.
234 access(all) fun scheduleNextExecution() {
235 pre {
236 self.status == PlanStatus.Active: "Plan must be active to schedule"
237 !self.hasReachedMaxExecutions(): "Plan has reached max executions"
238 }
239
240 let currentTime = getCurrentBlock().timestamp
241 let nextTime = currentTime + UFix64(self.intervalSeconds)
242 self.nextExecutionTime = nextTime
243
244 emit PlanUpdated(
245 planId: self.id,
246 active: true,
247 nextExecutionTime: nextTime
248 )
249 }
250
251 /// Pause the plan
252 ///
253 /// Prevents further executions until resumed.
254 access(all) fun pause() {
255 pre {
256 self.status == PlanStatus.Active: "Plan must be active to pause"
257 }
258
259 self.status = PlanStatus.Paused
260 self.nextExecutionTime = nil
261
262 emit PlanPaused(planId: self.id, timestamp: getCurrentBlock().timestamp)
263 }
264
265 /// Resume the plan
266 ///
267 /// Re-activates plan and schedules next execution.
268 access(all) fun resume(nextExecutionTime: UFix64?) {
269 pre {
270 self.status == PlanStatus.Paused: "Plan must be paused to resume"
271 !self.hasReachedMaxExecutions(): "Cannot resume completed plan"
272 }
273
274 self.status = PlanStatus.Active
275
276 // If next execution time provided, use it; otherwise use interval from now
277 let nextTime = nextExecutionTime ?? (getCurrentBlock().timestamp + UFix64(self.intervalSeconds))
278 self.nextExecutionTime = nextTime
279
280 emit PlanResumed(planId: self.id, nextExecutionTime: nextTime)
281 }
282
283 /// Get plan details as a struct for easy querying
284 access(all) fun getDetails(): PlanDetails {
285 return PlanDetails(
286 id: self.id,
287 sourceTokenType: self.sourceTokenType.identifier,
288 targetTokenType: self.targetTokenType.identifier,
289 amountPerInterval: self.amountPerInterval,
290 intervalSeconds: self.intervalSeconds,
291 maxSlippageBps: self.maxSlippageBps,
292 maxExecutions: self.maxExecutions,
293 status: self.status.rawValue,
294 nextExecutionTime: self.nextExecutionTime,
295 executionCount: self.executionCount,
296 totalSourceInvested: self.totalSourceInvested,
297 totalTargetAcquired: self.totalTargetAcquired,
298 avgExecutionPriceFP128: self.avgExecutionPriceFP128,
299 avgExecutionPriceDisplay: DeFiMath.fp128ToUFix64(priceFP128: self.avgExecutionPriceFP128),
300 createdAt: self.createdAt,
301 lastExecutedAt: self.lastExecutedAt
302 )
303 }
304 }
305
306 /// Public struct for plan details (used in scripts)
307 access(all) struct PlanDetails {
308 access(all) let id: UInt64
309 access(all) let sourceTokenType: String
310 access(all) let targetTokenType: String
311 access(all) let amountPerInterval: UFix64
312 access(all) let intervalSeconds: UInt64
313 access(all) let maxSlippageBps: UInt64
314 access(all) let maxExecutions: UInt64?
315 access(all) let status: UInt8
316 access(all) let nextExecutionTime: UFix64?
317 access(all) let executionCount: UInt64
318 access(all) let totalSourceInvested: UFix64
319 access(all) let totalTargetAcquired: UFix64
320 access(all) let avgExecutionPriceFP128: UInt128
321 access(all) let avgExecutionPriceDisplay: UFix64
322 access(all) let createdAt: UFix64
323 access(all) let lastExecutedAt: UFix64?
324
325 init(
326 id: UInt64,
327 sourceTokenType: String,
328 targetTokenType: String,
329 amountPerInterval: UFix64,
330 intervalSeconds: UInt64,
331 maxSlippageBps: UInt64,
332 maxExecutions: UInt64?,
333 status: UInt8,
334 nextExecutionTime: UFix64?,
335 executionCount: UInt64,
336 totalSourceInvested: UFix64,
337 totalTargetAcquired: UFix64,
338 avgExecutionPriceFP128: UInt128,
339 avgExecutionPriceDisplay: UFix64,
340 createdAt: UFix64,
341 lastExecutedAt: UFix64?
342 ) {
343 self.id = id
344 self.sourceTokenType = sourceTokenType
345 self.targetTokenType = targetTokenType
346 self.amountPerInterval = amountPerInterval
347 self.intervalSeconds = intervalSeconds
348 self.maxSlippageBps = maxSlippageBps
349 self.maxExecutions = maxExecutions
350 self.status = status
351 self.nextExecutionTime = nextExecutionTime
352 self.executionCount = executionCount
353 self.totalSourceInvested = totalSourceInvested
354 self.totalTargetAcquired = totalTargetAcquired
355 self.avgExecutionPriceFP128 = avgExecutionPriceFP128
356 self.avgExecutionPriceDisplay = avgExecutionPriceDisplay
357 self.createdAt = createdAt
358 self.lastExecutedAt = lastExecutedAt
359 }
360 }
361
362 /// Create a new DCA plan
363 access(all) fun createPlan(
364 sourceTokenType: Type,
365 targetTokenType: Type,
366 amountPerInterval: UFix64,
367 intervalSeconds: UInt64,
368 maxSlippageBps: UInt64,
369 maxExecutions: UInt64?,
370 firstExecutionTime: UFix64
371 ): @Plan {
372 return <- create Plan(
373 sourceTokenType: sourceTokenType,
374 targetTokenType: targetTokenType,
375 amountPerInterval: amountPerInterval,
376 intervalSeconds: intervalSeconds,
377 maxSlippageBps: maxSlippageBps,
378 maxExecutions: maxExecutions,
379 firstExecutionTime: firstExecutionTime
380 )
381 }
382
383 init() {
384 self.nextPlanId = 1
385 self.PlanStoragePath = /storage/DCAPlan
386 self.PlanPublicPath = /public/DCAPlan
387 }
388}
389