Smart Contract

DCAPlanV2

A.ca7ee55e4fc3251a.DCAPlanV2

Valid From

134,638,113

Deployed

5d ago
Feb 22, 2026, 02:51:42 AM UTC

Dependents

10 imports
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 DCAPlanV2 {
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 = DCAPlanV2.nextPlanId
136            DCAPlanV2.nextPlanId = DCAPlanV2.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