Smart Contract

SwapKeepAliveHandler

A.b13b21a06b75536d.SwapKeepAliveHandler

Valid From

136,502,211

Deployed

1w ago
Feb 15, 2026, 03:01:44 PM UTC

Dependents

54 imports
1import FlowTransactionScheduler from 0xe467b9dd11fa00df
2import FlowToken from 0x1654653399040a61
3import FungibleToken from 0xf233dcee88fe0abe
4import EVM from 0xe467b9dd11fa00df
5import FlowEVMBridgeUtils from 0x1e4aa0b87d10b141
6
7// Version 2.0.0: Supervisor Model with Discrete Storage via Attachments
8// - Schedules 10 transactions ahead with references stored in attachment
9// - Adaptive priority pattern per batch of 10:
10//   - First primary: Medium priority ONLY if system fell behind (gap > 10 between recoveries)
11//   - Middle primaries (2-9): Always low priority
12//   - Last (10th) primary: Low priority, performs swap
13//   - Last (10th) recovery: Medium priority, scheduled with time offset - cleanup + reschedule ONLY, never swap
14// - Every 10th slot has BOTH a primary (swaps) and recovery (supervisor) transaction - recovery executes after primary
15// - This ensures resilience: if system falls behind, next batch's first primary is medium (guaranteed catch-up)
16// - Cost-efficient: only pays for medium priority when actually needed (system behind schedule detected)
17// - Recovery transactions are the sole source of truth for maintaining the schedule
18// - Uses attachment for storage: allows upgrading without adding fields to Handler resource
19// - scheduleWithBackoff panics on failure with detailed diagnostics (fail-fast approach)
20access(all) contract SwapKeepAliveHandler {
21
22    /// Encodes UniswapV2-style swapExactTokensForTokens for a 2-token path using EVM ABI helpers.
23    access(all) fun encodeSwapExactTokensForTokens(
24        amountIn: UFix64,
25        amountOutMin: UFix64,
26        tokenInHex: String,
27        tokenOutHex: String,
28        toHex: String,
29        deadline: UFix64,
30        decimals: UInt8
31    ): [UInt8] {
32        let signature = "swapExactTokensForTokens(uint256,uint256,address[],address,uint256)"
33        let amountInScaled: UInt256 = FlowEVMBridgeUtils.ufix64ToUInt256(value: amountIn, decimals: decimals)
34        let minOutScaled: UInt256 = FlowEVMBridgeUtils.ufix64ToUInt256(value: amountOutMin, decimals: decimals)
35        let path: [EVM.EVMAddress] = [
36            EVM.addressFromString(tokenInHex),
37            EVM.addressFromString(tokenOutHex)
38        ]
39        let to = EVM.addressFromString(toHex)
40        let dl: UInt256 = UInt256(UInt64(deadline))
41        return EVM.encodeABIWithSignature(signature, [
42            amountInScaled,
43            minOutScaled,
44            path,
45            to,
46            dl
47        ])
48    }
49
50    /// Encodes factory.getPool(tokenA, tokenB, fee) call to get pool address
51    access(all) fun encodeGetPool(
52        tokenAHex: String,
53        tokenBHex: String,
54        fee: UInt32
55    ): [UInt8] {
56        let signature = "getPool(address,address,uint24)"
57        let tokenA = EVM.addressFromString(tokenAHex)
58        let tokenB = EVM.addressFromString(tokenBHex)
59        return EVM.encodeABIWithSignature(signature, [tokenA, tokenB, UInt256(fee)])
60    }
61
62    /// Encodes generic PunchSwapV3HelperGeneric swapExactIn call
63    /// This generic helper works with ANY pool by accepting pool address as parameter
64    access(all) fun encodeV3SwapExactInGeneric(
65        poolHex: String,
66        tokenInHex: String,
67        tokenOutHex: String,
68        amountIn: UFix64,
69        amountOutMin: UFix64,
70        toHex: String,
71        fee: UInt32,
72        decimals: UInt8
73    ): [UInt8] {
74        let signature = "swapExactIn(address,address,address,uint256,uint256,address,uint24)"
75        let pool = EVM.addressFromString(poolHex)
76        let tokenIn = EVM.addressFromString(tokenInHex)
77        let tokenOut = EVM.addressFromString(tokenOutHex)
78        let to = EVM.addressFromString(toHex)
79        let amountInScaled: UInt256 = FlowEVMBridgeUtils.ufix64ToUInt256(value: amountIn, decimals: decimals)
80        let minOutScaled: UInt256 = FlowEVMBridgeUtils.ufix64ToUInt256(value: amountOutMin, decimals: decimals)
81        
82        return EVM.encodeABIWithSignature(signature, [
83            pool,
84            tokenIn,
85            tokenOut,
86            amountInScaled,
87            minOutScaled,
88            to,
89            UInt256(fee)
90        ])
91    }
92
93    /// Storage/public paths for this handler
94    access(all) let handlerStoragePath: StoragePath
95    access(all) let handlerPublicPath: PublicPath
96
97    /// Configuration stored in the handler.
98    /// EVM addresses are stored as hex strings to keep this contract network-agnostic
99    /// without importing bridge-specific types.
100    access(all) struct Config {
101        access(all) let routerHex: String
102        access(all) let tokenInHex: String
103        access(all) let tokenOutHex: String
104        access(all) let recipientHex: String
105        access(all) var evmGasLimit: UInt64
106        /// If true, perform V3-style swaps (via generic helper + factory lookup), else V2-style swaps
107        access(all) var isV3: Bool
108        /// Uniswap V3 fee tier in hundredths of a bip, typically 500, 3000, or 10000
109        access(all) var v3Fee: UInt32
110        access(all) var amountIn: UFix64
111        access(all) var tokenInDecimals: UInt8
112        access(all) var slippageBps: UInt16
113        access(all) var intervalSeconds: UFix64
114        access(all) var backupOffsetSeconds: UFix64
115        access(all) var deadlineSlackSeconds: UFix64
116
117        init(
118            routerHex: String,
119            tokenInHex: String,
120            tokenOutHex: String,
121            recipientHex: String,
122            evmGasLimit: UInt64,
123            isV3: Bool,
124            v3Fee: UInt32,
125            amountIn: UFix64,
126            tokenInDecimals: UInt8,
127            slippageBps: UInt16,
128            intervalSeconds: UFix64,
129            backupOffsetSeconds: UFix64,
130            deadlineSlackSeconds: UFix64
131        ) {
132            self.routerHex = routerHex
133            self.tokenInHex = tokenInHex
134            self.tokenOutHex = tokenOutHex
135            self.recipientHex = recipientHex
136            self.evmGasLimit = evmGasLimit
137            self.isV3 = isV3
138            self.v3Fee = v3Fee
139            self.amountIn = amountIn
140            self.tokenInDecimals = tokenInDecimals
141            self.slippageBps = slippageBps
142            self.intervalSeconds = intervalSeconds
143            self.backupOffsetSeconds = backupOffsetSeconds
144            self.deadlineSlackSeconds = deadlineSlackSeconds
145        }
146
147    }
148
149    /// Transaction data struct for master/slave scheduling
150    access(all) struct TransactionData {
151        access(all) let slotId: UInt64
152        access(all) let isPrimary: Bool
153
154        init(slotId: UInt64, isPrimary: Bool) {
155            self.slotId = slotId
156            self.isPrimary = isPrimary
157        }
158    }
159
160    /// Handler resource that implements the Scheduled Transaction interface
161    access(all) resource Handler: FlowTransactionScheduler.TransactionHandler {
162        /// Persistent configuration and capabilities
163        access(self) var config: Config
164
165        /// Vault capability used to withdraw Flow fees for scheduling
166        access(self) let vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>
167        /// Entitled capability to this handler, required when scheduling
168        access(self) let handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>
169        /// Capability to COA for EVM calls
170        access(self) let evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
171        /// O(1) storage: last completed slot id
172        access(all) var lastCompletedSlot: UInt64
173        
174        /// Note: Scheduled transaction storage is in the ScheduledTransactionsStorage attachment
175        /// to allow upgrading without adding fields to the resource
176
177        init(
178            config: Config,
179            vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>,
180            handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>,
181            evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
182        ) {
183            self.config = config
184            self.vaultCap = vaultCap
185            self.handlerCap = handlerCap
186            self.evmCap = evmCap
187            self.lastCompletedSlot = 0
188        }
189        
190        /// Helper to get storage attachment (must be attached separately)
191        access(self) view fun getStorage(): &ScheduledTransactionsStorage {
192            return self[ScheduledTransactionsStorage]
193                ?? panic("ScheduledTransactionsStorage attachment not found - run attach_storage transaction first")
194        }
195
196        /// Helper to get completion tracker attachment
197        access(self) view fun getRecoveryTracker(): &RecoveryCompletionHandler {
198            return self[RecoveryCompletionHandler]
199                ?? panic("RecoveryCompletionHandler not attached")
200        }
201
202        /// Execute the scheduled work.
203        /// Primary: if bool FALSE → do swap, set TRUE. If bool TRUE → bump lastCompletedSlot, set FALSE
204        /// Recovery: cleanup + reschedule (doesn't touch bool)
205        access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) {
206            let currentTs = getCurrentBlock().timestamp
207            let txData = self.getTransactionData(data: data)
208            let storage = self.getStorage()
209            let tracker = self.getRecoveryTracker()
210
211
212            // Skip if slot is already behind lastCompletedSlot
213            if txData.slotId <= self.lastCompletedSlot {
214                return
215            }
216
217            // Gradual cleanup: remove one oldest stale receipt each execution
218            storage.cleanupOneOldestStale(lastCompletedSlot: txData.slotId)
219
220            if txData.isPrimary {                
221                // Not completed, do the swap, mark this slot completed
222                self.performSwap(nowTs: currentTs)
223                
224                // Schedule 1 more primary if we have < 10 scheduled
225                self.scheduleOnePrimaryIfNeeded(currentSlotId: txData.slotId, nowTs: currentTs, systemBehind: false)
226
227                // Set recovery completed to false
228                tracker.markCompleted(false)
229
230                // Set last completed slot to the current slot
231                self.lastCompletedSlot = txData.slotId
232            } else if !tracker.completed {
233                // Recovery: cleanup + reschedule
234                let systemBehind = txData.slotId > self.lastCompletedSlot + 10
235                
236                // Schedule up to 1 more primary if we have < 10
237                self.scheduleOnePrimaryIfNeeded(currentSlotId: txData.slotId, nowTs: currentTs, systemBehind: systemBehind)
238                
239                // Schedule next recovery transaction
240                self.scheduleRecoveryIfNeeded(currentSlotId: txData.slotId, nowTs: currentTs)
241
242                // Set recovery completed to false
243                tracker.markCompleted(true)
244
245                // Set last completed slot to the current slot
246                self.lastCompletedSlot = txData.slotId
247            }
248        }
249
250        /// Helper: parse transaction data from AnyStruct
251        access(self) fun getTransactionData(data: AnyStruct?): TransactionData {
252            if let d = data {
253                if let txData = d as? TransactionData {
254                    return txData
255                }
256            }
257            panic("missing or invalid transaction data")
258        }
259
260        /// Schedule one primary transaction ahead to fill up to 10 (called by both primaries and recovery)
261        /// Automatically determines if it should be medium priority based on:
262        /// 1. Is it the first primary of a batch? (nextSlotId - 1) % 10 == 0
263        /// 2. Is the system behind? systemBehind parameter
264        /// Primaries always pass systemBehind=false, recovery passes gap > 10 check
265        access(self) fun scheduleOnePrimaryIfNeeded(currentSlotId: UInt64, nowTs: UFix64, systemBehind: Bool) {
266            let storage = self.getStorage()
267            
268            // Check if we already have 10 primaries scheduled - if so, don't schedule more
269            let currentScheduled = storage.getScheduledPrimaryCount()
270            if currentScheduled >= 10 {
271                return
272            }
273            
274            let highestScheduled = storage.getHighestScheduledSlot()
275            let nextSlotId = highestScheduled + 1
276            
277            // Check if already scheduled (edge case)
278            if storage.containsPrimarySlot(nextSlotId) {
279                return
280            }
281            
282            // Check if this is the first primary of a new batch (slot X1, X1+10, etc.)
283            let isFirstOfBatch = (nextSlotId - 1) % 10 == 0
284            
285            // Use medium priority ONLY if: it's first of batch AND system is behind
286            let useMedium = isFirstOfBatch && systemBehind
287            
288            let lowPriority = FlowTransactionScheduler.Priority.Low
289            let mediumPriority = FlowTransactionScheduler.Priority.Medium
290            let primaryEffort: UInt64 = 375
291
292            let priority = useMedium ? mediumPriority : lowPriority
293            let effort = primaryEffort
294            
295            // Timestamp based on QUEUE POSITION, not slot ID
296            let nextTs: UFix64 = nowTs + (self.config.intervalSeconds * UFix64(nextSlotId - currentSlotId))
297            
298            // Schedule next primary
299            let receipt <- self.scheduleWithBackoff(
300                startTs: nextTs,
301                slotIdToSchedule: nextSlotId,
302                isPrimary: true,
303                pr: priority,
304                effort: effort
305            )
306            
307            storage.storePrimaryReceipt(slotId: nextSlotId, receipt: <-receipt)
308        }
309        
310        /// Schedule recovery transaction for current batch if not already scheduled
311        /// Recovery is scheduled for every 10th slot (e.g., 11370, 11380, 11390)
312        access(self) fun scheduleRecoveryIfNeeded(currentSlotId: UInt64, nowTs: UFix64) {
313            // Determine the recovery slot for this batch (every 10th slot)
314            let recoverySlotId = ((currentSlotId / 10) + 1) * 10
315            
316            let storage = self.getStorage()
317            
318            // Check if already scheduled
319            if storage.containsRecoverySlot(recoverySlotId) {
320                return
321            }
322            
323            let mediumPriority = FlowTransactionScheduler.Priority.Medium
324            let recoveryEffort: UInt64 = 300
325            
326            // Calculate timestamp: if recoverySlotId > currentSlotId, schedule in future; otherwise schedule immediately
327            let slotDiff = recoverySlotId > currentSlotId ? (recoverySlotId - currentSlotId) : 1
328            let recoveryTs = nowTs + (self.config.intervalSeconds * UFix64(slotDiff)) + self.config.backupOffsetSeconds
329            
330            // Schedule recovery transaction
331            let receipt <- self.scheduleWithBackoff(
332                startTs: recoveryTs,
333                slotIdToSchedule: recoverySlotId,
334                isPrimary: false,
335                pr: mediumPriority,
336                effort: recoveryEffort
337            )
338            
339            storage.storeRecoveryReceipt(slotId: recoverySlotId, receipt: <-receipt)
340        }
341
342        /// Checks if the pool needs a keep-alive swap
343        /// Currently always returns true (conservative - ensures keep-alive)
344        /// TODO: Implement activity checking when ABI decode issues are resolved
345        access(self) fun shouldSwap(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, nowTs: UFix64): Bool {
346            // For now, always swap to ensure keep-alive functionality
347            // This is conservative but safe - better to swap than miss keep-alive
348            return true
349        }
350
351        /// Executes a token swap via the COA and KittyPunch router on Flow EVM.
352        access(self) fun performSwap(nowTs: UFix64) {
353            let coa = self.evmCap.borrow()
354                ?? panic("SwapKeepAliveHandler: invalid COA capability")
355            
356            // Check if pool has recent activity - skip swap if active
357            if !self.shouldSwap(coa: coa, nowTs: nowTs) {
358                // Pool is active, no need for keep-alive swap
359                return
360            }
361            
362            let tokenIn = EVM.addressFromString(self.config.tokenInHex)
363            let router = EVM.addressFromString(self.config.routerHex)
364            let amountInScaled: UInt256 = FlowEVMBridgeUtils.ufix64ToUInt256(
365                value: self.config.amountIn,
366                decimals: self.config.tokenInDecimals
367            )
368            
369            // Step 1: Check current allowance and approve max if insufficient
370            // ERC20 allowance(address owner, address spender) returns (uint256)
371            let allowanceCalldata = EVM.encodeABIWithSignature("allowance(address,address)", [coa.address(), router])
372            let allowanceRes = coa.call(
373                to: tokenIn,
374                data: allowanceCalldata,
375                gasLimit: 100000,
376                value: EVM.Balance(attoflow: 0)
377            )
378            
379            if allowanceRes.status == EVM.Status.successful {
380                let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: allowanceRes.data)
381                let currentAllowance = decoded[0] as! UInt256
382                
383                // If allowance is insufficient, approve max uint256 for gas efficiency
384                if currentAllowance < amountInScaled {
385                    let maxApproval: UInt256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935 // type(uint256).max
386                    let approveCalldata = EVM.encodeABIWithSignature("approve(address,uint256)", [router, maxApproval])
387                    let approveRes = coa.call(
388                        to: tokenIn,
389                        data: approveCalldata,
390                        gasLimit: 100000,
391                        value: EVM.Balance(attoflow: 0)
392                    )
393                    
394                    if approveRes.status != EVM.Status.successful {
395                        let errorMsg = "SwapKeepAliveHandler: Token approval failed"
396                            .concat(" | Status: ").concat(approveRes.status.rawValue.toString())
397                            .concat(" | ErrorCode: ").concat(approveRes.errorCode.toString())
398                            .concat(" | GasUsed: ").concat(approveRes.gasUsed.toString())
399                            .concat(" | Token: ").concat(self.config.tokenInHex)
400                            .concat(" | Router: ").concat(self.config.routerHex)
401                        panic(errorMsg)
402                    }
403                }
404            }
405
406            // Step 2: Compute swap parameters
407            let bps: UFix64 = UFix64(self.config.slippageBps)
408            let minOutFactor: UFix64 = (10000.0 - bps) / 10000.0
409            let _minOut: UFix64 = self.config.amountIn * minOutFactor
410            let deadline: UFix64 = nowTs + self.config.deadlineSlackSeconds
411
412            var payload: [UInt8] = []
413            if self.config.isV3 {
414                // V3: First get pool address from factory via ABI call  
415                // PunchSwap V3 Factory on Flow EVM mainnet
416                let factory = EVM.addressFromString("0xf331959366032a634c7cAcF5852fE01ffdB84Af0")
417                let getPoolCalldata = SwapKeepAliveHandler.encodeGetPool(
418                    tokenAHex: self.config.tokenInHex,
419                    tokenBHex: self.config.tokenOutHex,
420                    fee: self.config.v3Fee
421                )
422                
423                let poolRes = coa.call(
424                    to: factory,
425                    data: getPoolCalldata,
426                    gasLimit: 100000,
427                    value: EVM.Balance(attoflow: 0)
428                )
429                
430                assert(poolRes.status == EVM.Status.successful, message: "SwapKeepAliveHandler: factory.getPool failed")
431                let decoded = EVM.decodeABI(types: [Type<EVM.EVMAddress>()], data: poolRes.data)
432                let poolAddress = decoded[0] as! EVM.EVMAddress
433                
434                // Verify pool exists
435                let zeroAddress = EVM.addressFromString("0x0000000000000000000000000000000000000000")
436                assert(poolAddress.bytes != zeroAddress.bytes, message: "SwapKeepAliveHandler: pool does not exist for pair")
437                
438                // Transfer tokens TO the V3 helper (helper will use them in callback)
439                let tokenIn = EVM.addressFromString(self.config.tokenInHex)
440                let amountInWei = FlowEVMBridgeUtils.ufix64ToUInt256(
441                    value: self.config.amountIn,
442                    decimals: self.config.tokenInDecimals
443                )
444                let transferCalldata = EVM.encodeABIWithSignature(
445                    "transfer(address,uint256)",
446                    [router, amountInWei]
447                )
448                let transferRes = coa.call(
449                    to: tokenIn,
450                    data: transferCalldata,
451                    gasLimit: 100000,
452                    value: EVM.Balance(attoflow: 0)
453                )
454                assert(transferRes.status == EVM.Status.successful, message: "SwapKeepAliveHandler: token transfer to helper failed")
455                
456                // Now encode swap call to generic helper with pool address
457                payload = SwapKeepAliveHandler.encodeV3SwapExactInGeneric(
458                    poolHex: poolAddress.toString(),
459                    tokenInHex: self.config.tokenInHex,
460                    tokenOutHex: self.config.tokenOutHex,
461                    amountIn: self.config.amountIn,
462                    amountOutMin: _minOut,
463                    toHex: self.config.recipientHex,
464                    fee: self.config.v3Fee,
465                    decimals: self.config.tokenInDecimals
466                )
467            } else {
468                // V2: Use router directly
469                payload = SwapKeepAliveHandler.encodeSwapExactTokensForTokens(
470                    amountIn: self.config.amountIn,
471                    amountOutMin: _minOut,
472                    tokenInHex: self.config.tokenInHex,
473                    tokenOutHex: self.config.tokenOutHex,
474                    toHex: self.config.recipientHex,
475                    deadline: deadline,
476                    decimals: self.config.tokenInDecimals
477                )
478            }
479
480            // Step 3: Execute the swap
481            let gas: UInt64 = self.config.evmGasLimit
482            let value = EVM.Balance(attoflow: 0)
483            let res = coa.call(
484                to: router,
485                data: payload,
486                gasLimit: gas,
487                value: value
488            )
489            
490            // Provide detailed error information if swap fails
491            if res.status != EVM.Status.successful {
492                let errorMsg = "SwapKeepAliveHandler: Swap failed"
493                    .concat(" | Status: ").concat(res.status.rawValue.toString())
494                    .concat(" | ErrorCode: ").concat(res.errorCode.toString())
495                    .concat(" | GasUsed: ").concat(res.gasUsed.toString())
496                    .concat(" | Router: ").concat(self.config.routerHex)
497                    .concat(" | TokenIn: ").concat(self.config.tokenInHex)
498                    .concat(" | TokenOut: ").concat(self.config.tokenOutHex)
499                    .concat(" | AmountIn: ").concat(self.config.amountIn.toString())
500                panic(errorMsg)
501            }
502        }
503
504        // ============================================
505        // SCHEDULING HELPERS
506        // ============================================
507        
508        /// Schedule one primary transaction at a specific timestamp
509        /// escalated: if true, uses Medium priority (for catch-up when behind)
510        access(self) fun schedulePrimaryAt(slotId: UInt64, timestamp: UFix64, escalated: Bool) {
511            let storage = self.getStorage()
512            if storage.containsPrimarySlot(slotId) { return } // Skip if already scheduled
513            
514            let priority = escalated 
515                ? FlowTransactionScheduler.Priority.Medium 
516                : FlowTransactionScheduler.Priority.Low
517            let effort: UInt64 = escalated ? 450 : 400
518            
519            let receipt <- self.scheduleWithBackoff(
520                startTs: timestamp,
521                slotIdToSchedule: slotId,
522                isPrimary: true,
523                pr: priority,
524                effort: effort
525            )
526            storage.storePrimaryReceipt(slotId: slotId, receipt: <-receipt)
527        }
528        
529        /// Schedule one recovery transaction at a specific timestamp
530        access(self) fun scheduleRecoveryAt(slotId: UInt64, timestamp: UFix64) {
531            let storage = self.getStorage()
532            if storage.containsRecoverySlot(slotId) { return } // Skip if already scheduled
533            
534            let receipt <- self.scheduleWithBackoff(
535                startTs: timestamp,
536                slotIdToSchedule: slotId,
537                isPrimary: false,
538                pr: FlowTransactionScheduler.Priority.Medium,
539                effort: 300
540            )
541            storage.storeRecoveryReceipt(slotId: slotId, receipt: <-receipt)
542        }
543        
544        /// Schedule a batch of transactions starting from startSlot
545        /// escalateFirst: if true, first primary gets medium priority (for catch-up)
546        access(self) fun scheduleBatch(startSlot: UInt64, count: UInt64, escalateFirst: Bool) {
547            let nowTs = getCurrentBlock().timestamp
548            var i: UInt64 = 0
549            
550            while i < count {
551                let slotId = startSlot + i
552                let ts = nowTs + (self.config.intervalSeconds * UFix64(i + 1))
553                
554                // First one can be escalated if requested
555                let escalated = (i == 0 && escalateFirst)
556                self.schedulePrimaryAt(slotId: slotId, timestamp: ts, escalated: escalated)
557                
558                // Every 10th slot gets a recovery too
559                if slotId % 10 == 0 {
560                    let recoveryTs = ts + self.config.backupOffsetSeconds
561                    self.scheduleRecoveryAt(slotId: slotId, timestamp: recoveryTs)
562                }
563                
564                i = i + 1
565            }
566        }
567
568        /// Attempts to schedule a transaction using exponential backoff if the requested timestamp is unavailable.
569        /// Returns the ScheduledTransaction receipt if successful, panics with detailed error if all attempts fail.
570        access(self) fun scheduleWithBackoff(
571            startTs: UFix64,
572            slotIdToSchedule: UInt64,
573            isPrimary: Bool,
574            pr: FlowTransactionScheduler.Priority,
575            effort: UInt64
576        ): @FlowTransactionScheduler.ScheduledTransaction {
577            let vaultRef = self.vaultCap.borrow()
578                ?? panic("SwapKeepAliveHandler: invalid vault capability")
579
580            let txData = TransactionData(slotId: slotIdToSchedule, isPrimary: isPrimary)
581
582            let maxAttempts: UInt64 = 20
583            var attempts: UInt64 = 0
584            var ts: UFix64 = startTs
585            var step: UFix64 = 1.0
586            var lastError: String? = nil
587
588            while attempts < maxAttempts {
589                let est = FlowTransactionScheduler.estimate(
590                    data: txData,
591                    timestamp: ts,
592                    priority: pr,
593                    executionEffort: effort
594                )
595                if est.timestamp != nil {
596                    let fees <- vaultRef.withdraw(amount: est.flowFee ?? 0.0) as! @FlowToken.Vault
597                    let receipt <- FlowTransactionScheduler.schedule(
598                        handlerCap: self.handlerCap,
599                        data: txData,
600                        timestamp: ts,
601                        priority: pr,
602                        executionEffort: effort,
603                        fees: <-fees
604                    )
605                    return <-receipt
606                } else {
607                    // Capture error message for final panic if all attempts fail
608                    lastError = est.error
609                }
610                // exponential backoff of 1s, 2s, 4s, ...
611                ts = ts + step
612                step = step * 2.0
613                attempts = attempts + 1
614            }
615            
616            // FATAL: Could not schedule after all backoff attempts
617            let primaryStr = isPrimary ? "true" : "false"
618            let errorMsg = "SwapKeepAliveHandler: FATAL - Failed to schedule transaction after ".concat(maxAttempts.toString()).concat(" attempts")
619                .concat(" | SlotId: ").concat(slotIdToSchedule.toString())
620                .concat(" | IsPrimary: ").concat(primaryStr)
621                .concat(" | Priority: ").concat(pr.rawValue.toString())
622                .concat(" | Effort: ").concat(effort.toString())
623                .concat(" | StartTimestamp: ").concat(startTs.toString())
624                .concat(" | FinalTimestamp: ").concat(ts.toString())
625                .concat(" | LastError: ").concat(lastError ?? "Unknown")
626            panic(errorMsg)
627        }
628
629        access(all) view fun getViews(): [Type] {
630            return [Type<StoragePath>(), Type<PublicPath>()]
631        }
632
633        access(all) fun resolveView(_ view: Type): AnyStruct? {
634            switch view {
635                case Type<StoragePath>():
636                    return SwapKeepAliveHandler.handlerStoragePath
637                case Type<PublicPath>():
638                    return SwapKeepAliveHandler.handlerPublicPath
639                default:
640                    return nil
641            }
642        }
643
644        /// Get the last completed slot ID
645        access(all) view fun getLastCompletedSlot(): UInt64 {
646            return self.lastCompletedSlot
647        }
648
649        /// Get the number of scheduled transactions in flight (primary + recovery)
650        access(all) view fun getScheduledCount(): Int {
651            if let storage = self[ScheduledTransactionsStorage] {
652                return storage.getScheduledCount()
653            }
654            return 0
655        }
656
657        /// Get scheduled primary slot IDs (low priority, perform swaps)
658        access(all) view fun getScheduledPrimarySlotIds(): [UInt64] {
659            if let storage = self[ScheduledTransactionsStorage] {
660                return storage.getScheduledPrimarySlotIds()
661            }
662            return []
663        }
664
665        /// Get scheduled recovery slot IDs (medium priority, cleanup + reschedule only, no swap)
666        access(all) view fun getScheduledRecoverySlotIds(): [UInt64] {
667            if let storage = self[ScheduledTransactionsStorage] {
668                return storage.getScheduledRecoverySlotIds()
669            }
670            return []
671        }
672
673        /// Get count of scheduled primary transactions
674        access(all) view fun getScheduledPrimaryCount(): Int {
675            if let storage = self[ScheduledTransactionsStorage] {
676                return storage.getScheduledPrimaryCount()
677            }
678            return 0
679        }
680
681        /// Get count of scheduled recovery transactions
682        access(all) view fun getScheduledRecoveryCount(): Int {
683            if let storage = self[ScheduledTransactionsStorage] {
684                return storage.getScheduledRecoveryCount()
685            }
686            return 0
687        }
688
689        /// Update trade/scheduling parameters
690        /// Automatically schedules 10 transactions ahead with new parameters
691        access(all) fun setTradeParams(
692            amountIn: UFix64,
693            tokenInDecimals: UInt8,
694            slippageBps: UInt16,
695            intervalSeconds: UFix64,
696            backupOffsetSeconds: UFix64,
697            deadlineSlackSeconds: UFix64
698        ) {
699            let current = self.config
700            
701            let updated = Config(
702                routerHex: current.routerHex,
703                tokenInHex: current.tokenInHex,
704                tokenOutHex: current.tokenOutHex,
705                recipientHex: current.recipientHex,
706                evmGasLimit: current.evmGasLimit,
707                isV3: current.isV3,
708                v3Fee: current.v3Fee,
709                amountIn: amountIn,
710                tokenInDecimals: tokenInDecimals,
711                slippageBps: slippageBps,
712                intervalSeconds: intervalSeconds,
713                backupOffsetSeconds: backupOffsetSeconds,
714                deadlineSlackSeconds: deadlineSlackSeconds
715            )
716            self.config = updated
717            
718            // Schedule transactions ahead with new parameters (escalate first since config changed)
719            let newSlotId = self.lastCompletedSlot + 1
720            self.scheduleBatch(startSlot: newSlotId, count: 10, escalateFirst: true)
721        }
722
723        /// Manually trigger scheduling of transactions ahead
724        access(all) fun scheduleAhead(startSlotId: UInt64, batchSize: UInt64, escalateFirst: Bool) {
725            self.scheduleBatch(startSlot: startSlotId, count: batchSize, escalateFirst: escalateFirst)
726        }
727
728        /// Cancel a specific scheduled transaction by slot ID
729        /// For 10th slots: cancels BOTH the primary and the recovery transaction
730        access(all) fun cancelSlot(slotId: UInt64) {
731            let storage = self.getStorage()
732            storage.cancelSlot(slotId: slotId)
733        }
734
735        /// Cancel all scheduled transactions (both primary and recovery)
736        access(all) fun cancelAll() {
737            let storage = self.getStorage()
738            storage.cancelAll()
739        }
740    }
741
742    /// Factory for the handler resource
743    access(all) fun createHandler(
744        config: Config,
745        vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>,
746        handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>,
747        evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
748    ): @Handler {
749        return <- create Handler(
750            config: config,
751            vaultCap: vaultCap,
752            handlerCap: handlerCap,
753            evmCap: evmCap
754        )
755    }
756
757    /// Attachment to add scheduled transaction storage to existing Handler resources
758    /// This allows upgrading without adding fields to the Handler resource
759    access(all) attachment ScheduledTransactionsStorage for Handler {
760        
761        /// Store references to scheduled primary transactions (low priority, perform swaps)
762        access(self) var scheduledPrimaryTransactions: @{UInt64: FlowTransactionScheduler.ScheduledTransaction}
763        
764        /// Store references to scheduled recovery transactions (medium priority, cleanup + reschedule, no swap)
765        access(self) var scheduledRecoveryTransactions: @{UInt64: FlowTransactionScheduler.ScheduledTransaction}
766        
767        init() {
768            self.scheduledPrimaryTransactions <- {}
769            self.scheduledRecoveryTransactions <- {}
770        }
771        
772        /// Get the number of scheduled transactions in flight (primary + recovery)
773        access(all) view fun getScheduledCount(): Int {
774            return self.scheduledPrimaryTransactions.keys.length + self.scheduledRecoveryTransactions.keys.length
775        }
776        
777        /// Get scheduled primary slot IDs (low priority, perform swaps)
778        access(all) view fun getScheduledPrimarySlotIds(): [UInt64] {
779            return self.scheduledPrimaryTransactions.keys
780        }
781        
782        /// Get scheduled recovery slot IDs (medium priority, cleanup + reschedule only, no swap)
783        access(all) view fun getScheduledRecoverySlotIds(): [UInt64] {
784            return self.scheduledRecoveryTransactions.keys
785        }
786        
787        /// Get count of scheduled primary transactions
788        access(all) view fun getScheduledPrimaryCount(): Int {
789            return self.scheduledPrimaryTransactions.keys.length
790        }
791        
792        /// Get count of scheduled recovery transactions
793        access(all) view fun getScheduledRecoveryCount(): Int {
794            return self.scheduledRecoveryTransactions.keys.length
795        }
796        
797        /// Check if a slot is already scheduled
798        access(all) view fun containsPrimarySlot(_ slotId: UInt64): Bool {
799            return self.scheduledPrimaryTransactions.containsKey(slotId)
800        }
801        
802        access(all) view fun containsRecoverySlot(_ slotId: UInt64): Bool {
803            return self.scheduledRecoveryTransactions.containsKey(slotId)
804        }
805        
806        /// Get highest scheduled slot ID
807        access(all) view fun getHighestScheduledSlot(): UInt64 {
808            var highest: UInt64 = 0
809            for key in self.scheduledPrimaryTransactions.keys {
810                if key > highest {
811                    highest = key
812                }
813            }
814            for key in self.scheduledRecoveryTransactions.keys {
815                if key > highest {
816                    highest = key
817                }
818            }
819            return highest
820        }
821        
822        /// Store a primary transaction receipt
823        access(all) fun storePrimaryReceipt(slotId: UInt64, receipt: @FlowTransactionScheduler.ScheduledTransaction) {
824            let old <- self.scheduledPrimaryTransactions.insert(key: slotId, <-receipt)
825            destroy old
826        }
827        
828        /// Store a recovery transaction receipt
829        access(all) fun storeRecoveryReceipt(slotId: UInt64, receipt: @FlowTransactionScheduler.ScheduledTransaction) {
830            let old <- self.scheduledRecoveryTransactions.insert(key: slotId, <-receipt)
831            destroy old
832        }
833
834        /// Gradual cleanup: remove one oldest stale receipt (slotId <= lastCompletedSlot)
835        access(all) fun cleanupOneOldestStale(lastCompletedSlot: UInt64) {
836            // Find and remove oldest stale primary receipt
837            var oldestPrimary: UInt64? = nil
838            for slotId in self.scheduledPrimaryTransactions.keys {
839                if slotId <= lastCompletedSlot {
840                    if oldestPrimary == nil || slotId < oldestPrimary! {
841                        oldestPrimary = slotId
842                    }
843                }
844            }
845            if let slotId = oldestPrimary {
846                let stale <- self.scheduledPrimaryTransactions.remove(key: slotId)
847                destroy stale
848            }
849
850            // Find and remove oldest stale recovery receipt
851            var oldestRecovery: UInt64? = nil
852            for slotId in self.scheduledRecoveryTransactions.keys {
853                if slotId <= lastCompletedSlot {
854                    if oldestRecovery == nil || slotId < oldestRecovery! {
855                        oldestRecovery = slotId
856                    }
857                }
858            }
859            if let slotId = oldestRecovery {
860                let stale <- self.scheduledRecoveryTransactions.remove(key: slotId)
861                destroy stale
862            }
863        }
864
865        /// Clean up all stale receipts (slotId <= lastCompletedSlot)
866        access(all) fun cleanupStaleReceipts(lastCompletedSlot: UInt64) {
867            // Clean up stale primary receipts
868            let primaryKeys = self.scheduledPrimaryTransactions.keys
869            for slotId in primaryKeys {
870                if slotId <= lastCompletedSlot {
871                    let stale <- self.scheduledPrimaryTransactions.remove(key: slotId)
872                    destroy stale
873                }
874            }
875            
876            // Clean up stale recovery receipts
877            let recoveryKeys = self.scheduledRecoveryTransactions.keys
878            for slotId in recoveryKeys {
879                if slotId <= lastCompletedSlot {
880                    let stale <- self.scheduledRecoveryTransactions.remove(key: slotId)
881                    destroy stale
882                }
883            }
884        }
885        
886        /// Cancel a specific scheduled transaction by slot ID
887        /// For 10th slots: cancels BOTH the primary and the recovery transaction
888        access(all) fun cancelSlot(slotId: UInt64) {
889            var cancelled = false
890            
891            // Cancel primary transaction if exists
892            if let receipt <- self.scheduledPrimaryTransactions.remove(key: slotId) {
893                destroy FlowTransactionScheduler.cancel(scheduledTx: <-receipt)
894                cancelled = true
895            }
896            
897            // Also cancel recovery transaction if this is a 10th slot (both exist)
898            // Pattern: slots like 11170, 11180, 11190 where slotId % 10 == 0
899            if slotId % 10 == 0 {
900                if let recoveryReceipt <- self.scheduledRecoveryTransactions.remove(key: slotId) {
901                    destroy FlowTransactionScheduler.cancel(scheduledTx: <-recoveryReceipt)
902                    cancelled = true
903                }
904            }
905            
906            if !cancelled {
907                panic("No scheduled transaction found for slot ".concat(slotId.toString()))
908            }
909        }
910
911        /// Cancel all scheduled transactions (both primary and recovery)
912        access(all) fun cancelAll() {
913            // Cancel all primary transactions
914            let primaryKeys = self.scheduledPrimaryTransactions.keys
915            for slotId in primaryKeys {
916                if let receipt <- self.scheduledPrimaryTransactions.remove(key: slotId) {
917                    destroy FlowTransactionScheduler.cancel(scheduledTx: <-receipt)
918                }
919            }
920            
921            // Cancel all recovery transactions
922            let recoveryKeys = self.scheduledRecoveryTransactions.keys
923            for slotId in recoveryKeys {
924                if let receipt <- self.scheduledRecoveryTransactions.remove(key: slotId) {
925                    destroy FlowTransactionScheduler.cancel(scheduledTx: <-receipt)
926                }
927            }
928        }
929
930        /// Force remove all receipts without canceling (for orphaned transactions)
931        access(all) fun forceRemoveAllReceipts() {
932            // Remove all primary receipts
933            let primaryKeys = self.scheduledPrimaryTransactions.keys
934            for slotId in primaryKeys {
935                let receipt <- self.scheduledPrimaryTransactions.remove(key: slotId)
936                destroy receipt
937            }
938            
939            // Remove all recovery receipts
940            let recoveryKeys = self.scheduledRecoveryTransactions.keys
941            for slotId in recoveryKeys {
942                let receipt <- self.scheduledRecoveryTransactions.remove(key: slotId)
943                destroy receipt
944            }
945        }
946    }
947
948    /// O(1) tracker: stores which slot completed and whether it's done
949    access(all) attachment RecoveryCompletionHandler for Handler {
950        access(all) var completed: Bool
951
952        init() {
953            self.completed = false
954        }
955
956        access(all) fun markCompleted(_ completed: Bool) {
957            self.completed = completed
958        }
959    }
960
961    access(all) init() {
962        self.handlerStoragePath = /storage/SwapKeepAliveHandler
963        self.handlerPublicPath = /public/SwapKeepAliveHandler
964    }
965
966    // ============================================================================
967    // DEPRECATED TYPES (Kept for contract upgrade compatibility)
968    // ============================================================================
969    
970    /// DEPRECATED: Attachment for batch size storage (no longer used, kept for upgrade compatibility)
971    access(all) attachment BatchSizeStorage for Handler {
972        init() {}
973    }
974        
975    access(all) attachment SupervisorStateStorage for Handler {
976        init() {}
977    }
978    
979    /// DEPRECATED: Use TransactionData instead
980    /// V2 - Added isRecoverySequence flag
981    access(all) struct TransactionDataV2 {
982        access(all) let slotId: UInt64
983        access(all) let isPrimary: Bool
984        access(all) let isRecoverySequence: Bool
985        
986        init(slotId: UInt64, isPrimary: Bool, isRecoverySequence: Bool) {
987            self.slotId = slotId
988            self.isPrimary = isPrimary
989            self.isRecoverySequence = isRecoverySequence
990        }
991    }
992    
993    /// DEPRECATED: Use TransactionData instead
994    /// V3 - Added batchSize for batch scheduling support
995    access(all) struct TransactionDataV3 {
996        access(all) let slotId: UInt64
997        access(all) let isPrimary: Bool
998        access(all) let isRecoverySequence: Bool
999        access(all) let batchSize: UInt64
1000        
1001        init(slotId: UInt64, isPrimary: Bool, isRecoverySequence: Bool, batchSize: UInt64) {
1002            self.slotId = slotId
1003            self.isPrimary = isPrimary
1004            self.isRecoverySequence = isRecoverySequence
1005            self.batchSize = batchSize
1006        }
1007    }
1008
1009    // Deprecated
1010    access(all) attachment PrimaryCompletionTracker for Handler {
1011        init() {}
1012    }
1013}
1014
1015
1016