Smart Contract
SwapKeepAliveHandler
A.b13b21a06b75536d.SwapKeepAliveHandler
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