Smart Contract
SwapKeepAliveHandlerV2
A.b13b21a06b75536d.SwapKeepAliveHandlerV2
1import FlowTransactionScheduler from 0xe467b9dd11fa00df
2import FlowToken from 0x1654653399040a61
3import FungibleToken from 0xf233dcee88fe0abe
4import EVM from 0xe467b9dd11fa00df
5import FlowEVMBridgeUtils from 0x1e4aa0b87d10b141
6
7access(all) contract SwapKeepAliveHandlerV2 {
8
9 access(all) entitlement Admin
10
11 /// Encodes UniswapV2-style swapExactTokensForTokens for a 2-token path using EVM ABI helpers.
12 access(all) fun encodeSwapExactTokensForTokens(
13 amountIn: UFix64,
14 amountOutMin: UFix64,
15 tokenInHex: String,
16 tokenOutHex: String,
17 toHex: String,
18 deadline: UFix64,
19 decimals: UInt8
20 ): [UInt8] {
21 let signature = "swapExactTokensForTokens(uint256,uint256,address[],address,uint256)"
22 let amountInScaled: UInt256 = FlowEVMBridgeUtils.ufix64ToUInt256(value: amountIn, decimals: decimals)
23 let minOutScaled: UInt256 = FlowEVMBridgeUtils.ufix64ToUInt256(value: amountOutMin, decimals: decimals)
24 let path: [EVM.EVMAddress] = [
25 EVM.addressFromString(tokenInHex),
26 EVM.addressFromString(tokenOutHex)
27 ]
28 let to = EVM.addressFromString(toHex)
29 let dl: UInt256 = UInt256(UInt64(deadline))
30 return EVM.encodeABIWithSignature(signature, [
31 amountInScaled,
32 minOutScaled,
33 path,
34 to,
35 dl
36 ])
37 }
38
39 /// Encodes factory.getPool(tokenA, tokenB, fee) call to get pool address
40 access(all) fun encodeGetPool(
41 tokenAHex: String,
42 tokenBHex: String,
43 fee: UInt32
44 ): [UInt8] {
45 let signature = "getPool(address,address,uint24)"
46 let tokenA = EVM.addressFromString(tokenAHex)
47 let tokenB = EVM.addressFromString(tokenBHex)
48 return EVM.encodeABIWithSignature(signature, [tokenA, tokenB, UInt256(fee)])
49 }
50
51 /// Encodes generic PunchSwapV3HelperGeneric swapExactIn call
52 access(all) fun encodeV3SwapExactInGeneric(
53 poolHex: String,
54 tokenInHex: String,
55 tokenOutHex: String,
56 amountIn: UFix64,
57 amountOutMin: UFix64,
58 toHex: String,
59 fee: UInt32,
60 decimals: UInt8
61 ): [UInt8] {
62 let signature = "swapExactIn(address,address,address,uint256,uint256,address,uint24)"
63 let pool = EVM.addressFromString(poolHex)
64 let tokenIn = EVM.addressFromString(tokenInHex)
65 let tokenOut = EVM.addressFromString(tokenOutHex)
66 let to = EVM.addressFromString(toHex)
67 let amountInScaled: UInt256 = FlowEVMBridgeUtils.ufix64ToUInt256(value: amountIn, decimals: decimals)
68 let minOutScaled: UInt256 = FlowEVMBridgeUtils.ufix64ToUInt256(value: amountOutMin, decimals: decimals)
69
70 return EVM.encodeABIWithSignature(signature, [
71 pool,
72 tokenIn,
73 tokenOut,
74 amountInScaled,
75 minOutScaled,
76 to,
77 UInt256(fee)
78 ])
79 }
80
81 // ============================================
82 // CONFIG STRUCTS - Shared + Protocol-Specific
83 // ============================================
84
85 /// Shared configuration for all swap types
86 access(all) struct BaseConfig {
87 access(all) let tokenInHex: String
88 access(all) let tokenOutHex: String
89 access(all) let recipientHex: String
90 access(all) var evmGasLimit: UInt64
91 access(all) var amountIn: UFix64
92 access(all) var tokenInDecimals: UInt8
93 access(all) var slippageBps: UInt16
94 access(all) var intervalSeconds: UFix64
95 access(all) var backupOffsetSeconds: UFix64
96
97 init(
98 tokenInHex: String,
99 tokenOutHex: String,
100 recipientHex: String,
101 evmGasLimit: UInt64,
102 amountIn: UFix64,
103 tokenInDecimals: UInt8,
104 slippageBps: UInt16,
105 intervalSeconds: UFix64,
106 backupOffsetSeconds: UFix64
107 ) {
108 self.tokenInHex = tokenInHex
109 self.tokenOutHex = tokenOutHex
110 self.recipientHex = recipientHex
111 self.evmGasLimit = evmGasLimit
112 self.amountIn = amountIn
113 self.tokenInDecimals = tokenInDecimals
114 self.slippageBps = slippageBps
115 self.intervalSeconds = intervalSeconds
116 self.backupOffsetSeconds = backupOffsetSeconds
117 }
118 }
119
120 /// Swap protocol type
121 access(all) enum SwapProtocol: UInt8 {
122 access(all) case V2
123 access(all) case V3
124 }
125
126 /// Interface for protocol-specific parameters
127 access(all) struct interface ProtocolParams {
128 access(all) fun getProtocol(): SwapProtocol
129 }
130
131 /// V2-specific parameters (UniswapV2 style)
132 access(all) struct V2Params: ProtocolParams {
133 access(all) let routerHex: String
134 access(all) var deadlineSlackSeconds: UFix64
135
136 init(routerHex: String, deadlineSlackSeconds: UFix64) {
137 self.routerHex = routerHex
138 self.deadlineSlackSeconds = deadlineSlackSeconds
139 }
140
141 access(all) fun getProtocol(): SwapProtocol {
142 return SwapProtocol.V2
143 }
144 }
145
146 /// V3-specific parameters (UniswapV3 style)
147 access(all) struct V3Params: ProtocolParams {
148 access(all) let helperHex: String // V3 swap helper contract
149 access(all) let factoryHex: String // V3 factory for pool lookup
150 access(all) var fee: UInt32 // Fee tier (500, 3000, 10000)
151
152 init(helperHex: String, factoryHex: String, fee: UInt32) {
153 self.helperHex = helperHex
154 self.factoryHex = factoryHex
155 self.fee = fee
156 }
157
158 access(all) fun getProtocol(): SwapProtocol {
159 return SwapProtocol.V3
160 }
161 }
162
163 /// Full configuration - base + protocol-specific
164 access(all) struct Config {
165 access(all) let base: BaseConfig
166 access(all) let protocolParams: {ProtocolParams}
167
168 init(base: BaseConfig, protocolParams: {ProtocolParams}) {
169 self.base = base
170 self.protocolParams = protocolParams
171 }
172 }
173
174 /// Pending transaction info - stored locally, keyed by txn ID
175 access(all) struct PendingTxParams {
176 access(all) let isPrimary: Bool
177
178 init(isPrimary: Bool) {
179 self.isPrimary = isPrimary
180 }
181 }
182
183 /// Handler resource that implements the Scheduled Transaction interface
184 access(all) resource Handler: FlowTransactionScheduler.TransactionHandler {
185 /// Persistent configuration and capabilities
186 access(self) var config: Config
187
188 /// Vault capability used to withdraw Flow fees for scheduling
189 access(self) let vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>
190 /// Entitled capability to this handler, required when scheduling
191 access(self) let handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>
192 /// Capability to COA for EVM calls
193 access(self) let evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
194
195 /// deprecated test
196 access(self) var scheduledTxParams: {UInt64: PendingTxParams}
197
198 access(all) var primaryReceipts: @{UInt64: FlowTransactionScheduler.ScheduledTransaction}
199 access(all) var recoveryReceipts: @{UInt64: FlowTransactionScheduler.ScheduledTransaction}
200
201 /// Farthest scheduled timestamps (for scheduling relative to these)
202 access(all) var farthestPrimaryTs: UFix64
203 access(all) var farthestRecoveryTs: UFix64
204
205
206 init(
207 config: Config,
208 vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>,
209 handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>,
210 evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
211 ) {
212 self.config = config
213 self.vaultCap = vaultCap
214 self.handlerCap = handlerCap
215 self.evmCap = evmCap
216 self.scheduledTxParams = {}
217 self.primaryReceipts <- {}
218 self.recoveryReceipts <- {}
219 self.farthestPrimaryTs = 0.0
220 self.farthestRecoveryTs = 0.0
221 }
222
223 /// Execute the scheduled work - lookup txn info by ID, no args needed
224 access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) {
225 let currentTs = getCurrentBlock().timestamp
226
227 let params = data as! PendingTxParams
228
229 // Remove receipt from appropriate storage
230 if params.isPrimary {
231 let receipt <- self.primaryReceipts.remove(key: id)
232 destroy receipt
233
234 // Primary: swap + schedule next primary (fixed/linear compute)
235 // Recovery scheduling is handled by recovery itself
236 self.performSwap(nowTs: currentTs)
237 self.scheduleOnePrimaryIfNeeded(nowTs: currentTs, systemBehind: false)
238 } else {
239 let receipt <- self.recoveryReceipts.remove(key: id)
240 destroy receipt
241
242 // Recovery: handle scheduling gaps (no swap)
243 let systemBehind = self.primaryReceipts.keys.length < 5
244
245 // Fill primaries if any were missed
246 self.scheduleOnePrimaryIfNeeded(nowTs: currentTs, systemBehind: systemBehind)
247
248 // Schedule next recovery
249 self.scheduleRecoveryIfNeeded(nowTs: currentTs)
250 }
251 }
252
253 /// Schedule one primary transaction if we have < 10 scheduled
254 access(self) fun scheduleOnePrimaryIfNeeded(nowTs: UFix64, systemBehind: Bool) {
255 let primaryCount = self.primaryReceipts.keys.length
256 if primaryCount >= 10 {
257 return
258 }
259
260 let useMedium = systemBehind && primaryCount == 0
261 let priority = useMedium
262 ? FlowTransactionScheduler.Priority.Medium
263 : FlowTransactionScheduler.Priority.Low
264 let effort: UInt64 = 375
265
266 // Schedule relative to farthest primary (or from now if none)
267 let baseTs = self.farthestPrimaryTs > nowTs ? self.farthestPrimaryTs : nowTs
268 let ts = baseTs + self.config.base.intervalSeconds
269
270 self.scheduleTransaction(timestamp: ts, isPrimary: true, priority: priority, effort: effort)
271 }
272
273 /// Schedule recovery transaction if none pending
274 access(self) fun scheduleRecoveryIfNeeded(nowTs: UFix64) {
275 // Check if we already have a recovery scheduled
276 if self.recoveryReceipts.keys.length > 0 {
277 return
278 }
279
280 let priority = FlowTransactionScheduler.Priority.Medium
281 let effort: UInt64 = 300
282
283 // Schedule recovery relative to farthest primary + offset
284 let baseTs = self.farthestPrimaryTs > nowTs ? self.farthestPrimaryTs : nowTs
285 let ts = baseTs + self.config.base.backupOffsetSeconds
286
287 self.scheduleTransaction(timestamp: ts, isPrimary: false, priority: priority, effort: effort)
288 }
289
290 /// Core scheduling function
291 access(self) fun scheduleTransaction(
292 timestamp: UFix64,
293 isPrimary: Bool,
294 priority: FlowTransactionScheduler.Priority,
295 effort: UInt64
296 ) {
297 let vaultRef = self.vaultCap.borrow()
298 ?? panic("SwapKeepAliveHandlerV2: invalid vault capability")
299
300 let txParams = PendingTxParams(isPrimary: isPrimary)
301
302 var ts = timestamp
303 var step: UFix64 = 1.0
304 var attempts: UInt64 = 0
305 let maxAttempts: UInt64 = 20
306
307 while attempts < maxAttempts {
308 let est = FlowTransactionScheduler.estimate(
309 data: txParams,
310 timestamp: ts,
311 priority: priority,
312 executionEffort: effort
313 )
314
315 if est.timestamp != nil {
316 let fees <- vaultRef.withdraw(amount: est.flowFee ?? 0.0) as! @FlowToken.Vault
317 let receipt <- FlowTransactionScheduler.schedule(
318 handlerCap: self.handlerCap,
319 data: txParams,
320 timestamp: ts,
321 priority: priority,
322 executionEffort: effort,
323 fees: <-fees
324 )
325
326 // Store txn info locally, keyed by the receipt's ID
327 let txnId = receipt.id
328
329 // Store receipt in appropriate dict and update farthest timestamp
330 if isPrimary {
331 let old <- self.primaryReceipts.insert(key: txnId, <-receipt)
332 destroy old
333 if ts > self.farthestPrimaryTs {
334 self.farthestPrimaryTs = ts
335 }
336 } else {
337 let old <- self.recoveryReceipts.insert(key: txnId, <-receipt)
338 destroy old
339 if ts > self.farthestRecoveryTs {
340 self.farthestRecoveryTs = ts
341 }
342 }
343
344 return
345 }
346
347 // Exponential backoff
348 ts = ts + step
349 step = step * 2.0
350 attempts = attempts + 1
351 }
352
353 panic("SwapKeepAliveHandlerV2: Failed to schedule after ".concat(maxAttempts.toString()).concat(" attempts"))
354 }
355
356 /// Executes a token swap via the COA
357 access(self) fun performSwap(nowTs: UFix64) {
358 let coa = self.evmCap.borrow()
359 ?? panic("SwapKeepAliveHandlerV2: invalid COA capability")
360
361 let base = self.config.base
362 let tokenIn = EVM.addressFromString(base.tokenInHex)
363 let amountInScaled: UInt256 = FlowEVMBridgeUtils.ufix64ToUInt256(
364 value: base.amountIn,
365 decimals: base.tokenInDecimals
366 )
367
368 // Calculate min output with slippage
369 let bps: UFix64 = UFix64(base.slippageBps)
370 let minOutFactor: UFix64 = (10000.0 - bps) / 10000.0
371 let _minOut: UFix64 = base.amountIn * minOutFactor
372
373 // Execute based on protocol type
374 let params = self.config.protocolParams
375 switch params.getProtocol() {
376 case SwapProtocol.V2:
377 let v2 = params as! V2Params
378 self.performV2Swap(coa: coa, tokenIn: tokenIn, amountInScaled: amountInScaled, minOut: _minOut, nowTs: nowTs, v2: v2)
379 case SwapProtocol.V3:
380 let v3 = params as! V3Params
381 self.performV3Swap(coa: coa, tokenIn: tokenIn, amountInScaled: amountInScaled, minOut: _minOut, v3: v3)
382 }
383 }
384
385 /// V2 swap implementation
386 access(self) fun performV2Swap(
387 coa: auth(EVM.Call) &EVM.CadenceOwnedAccount,
388 tokenIn: EVM.EVMAddress,
389 amountInScaled: UInt256,
390 minOut: UFix64,
391 nowTs: UFix64,
392 v2: V2Params
393 ) {
394 let base = self.config.base
395 let router = EVM.addressFromString(v2.routerHex)
396
397 // Check and approve allowance
398 self.ensureAllowance(coa: coa, tokenIn: tokenIn, spender: router, amount: amountInScaled)
399
400 // Build V2 swap payload
401 let deadline: UFix64 = nowTs + v2.deadlineSlackSeconds
402 let payload = SwapKeepAliveHandlerV2.encodeSwapExactTokensForTokens(
403 amountIn: base.amountIn,
404 amountOutMin: minOut,
405 tokenInHex: base.tokenInHex,
406 tokenOutHex: base.tokenOutHex,
407 toHex: base.recipientHex,
408 deadline: deadline,
409 decimals: base.tokenInDecimals
410 )
411
412 // Execute
413 let res = coa.call(
414 to: router,
415 data: payload,
416 gasLimit: base.evmGasLimit,
417 value: EVM.Balance(attoflow: 0)
418 )
419
420 if res.status != EVM.Status.successful {
421 panic("SwapKeepAliveHandlerV2: V2 swap failed | Status: ".concat(res.status.rawValue.toString()))
422 }
423 }
424
425 /// V3 swap implementation
426 access(self) fun performV3Swap(
427 coa: auth(EVM.Call) &EVM.CadenceOwnedAccount,
428 tokenIn: EVM.EVMAddress,
429 amountInScaled: UInt256,
430 minOut: UFix64,
431 v3: V3Params
432 ) {
433 let base = self.config.base
434 let helper = EVM.addressFromString(v3.helperHex)
435 let factory = EVM.addressFromString(v3.factoryHex)
436
437 // Lookup pool from factory
438 let getPoolCalldata = SwapKeepAliveHandlerV2.encodeGetPool(
439 tokenAHex: base.tokenInHex,
440 tokenBHex: base.tokenOutHex,
441 fee: v3.fee
442 )
443
444 let poolRes = coa.call(
445 to: factory,
446 data: getPoolCalldata,
447 gasLimit: 100000,
448 value: EVM.Balance(attoflow: 0)
449 )
450
451 assert(poolRes.status == EVM.Status.successful, message: "SwapKeepAliveHandlerV2: factory.getPool failed")
452 let decoded = EVM.decodeABI(types: [Type<EVM.EVMAddress>()], data: poolRes.data)
453 let poolAddress = decoded[0] as! EVM.EVMAddress
454
455 let zeroAddress = EVM.addressFromString("0x0000000000000000000000000000000000000000")
456 assert(poolAddress.bytes != zeroAddress.bytes, message: "SwapKeepAliveHandlerV2: pool does not exist")
457
458 // Transfer tokens to helper (V3 helper uses callback pattern)
459 let transferCalldata = EVM.encodeABIWithSignature(
460 "transfer(address,uint256)",
461 [helper, amountInScaled]
462 )
463 let transferRes = coa.call(
464 to: tokenIn,
465 data: transferCalldata,
466 gasLimit: 100000,
467 value: EVM.Balance(attoflow: 0)
468 )
469 assert(transferRes.status == EVM.Status.successful, message: "SwapKeepAliveHandlerV2: token transfer to helper failed")
470
471 // Build V3 swap payload
472 let payload = SwapKeepAliveHandlerV2.encodeV3SwapExactInGeneric(
473 poolHex: poolAddress.toString(),
474 tokenInHex: base.tokenInHex,
475 tokenOutHex: base.tokenOutHex,
476 amountIn: base.amountIn,
477 amountOutMin: minOut,
478 toHex: base.recipientHex,
479 fee: v3.fee,
480 decimals: base.tokenInDecimals
481 )
482
483 // Execute
484 let res = coa.call(
485 to: helper,
486 data: payload,
487 gasLimit: base.evmGasLimit,
488 value: EVM.Balance(attoflow: 0)
489 )
490
491 if res.status != EVM.Status.successful {
492 panic("SwapKeepAliveHandlerV2: V3 swap failed | Status: ".concat(res.status.rawValue.toString()))
493 }
494 }
495
496 /// Helper: ensure token allowance for spender
497 access(self) fun ensureAllowance(
498 coa: auth(EVM.Call) &EVM.CadenceOwnedAccount,
499 tokenIn: EVM.EVMAddress,
500 spender: EVM.EVMAddress,
501 amount: UInt256
502 ) {
503 let allowanceCalldata = EVM.encodeABIWithSignature("allowance(address,address)", [coa.address(), spender])
504 let allowanceRes = coa.call(
505 to: tokenIn,
506 data: allowanceCalldata,
507 gasLimit: 100000,
508 value: EVM.Balance(attoflow: 0)
509 )
510
511 if allowanceRes.status == EVM.Status.successful {
512 let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: allowanceRes.data)
513 let currentAllowance = decoded[0] as! UInt256
514
515 if currentAllowance < amount {
516 let maxApproval: UInt256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935
517 let approveCalldata = EVM.encodeABIWithSignature("approve(address,uint256)", [spender, maxApproval])
518 let approveRes = coa.call(
519 to: tokenIn,
520 data: approveCalldata,
521 gasLimit: 100000,
522 value: EVM.Balance(attoflow: 0)
523 )
524
525 if approveRes.status != EVM.Status.successful {
526 panic("SwapKeepAliveHandlerV2: Token approval failed")
527 }
528 }
529 }
530 }
531
532 // ============================================
533 // VIEW FUNCTIONS
534 // ============================================
535
536 access(all) view fun getViews(): [Type] {
537 return []
538 }
539
540 access(all) fun resolveView(_ view: Type): AnyStruct? {
541 return nil
542 }
543
544 access(all) view fun getPrimaryCount(): Int {
545 return self.primaryReceipts.keys.length
546 }
547
548 access(all) view fun getRecoveryCount(): Int {
549 return self.recoveryReceipts.keys.length
550 }
551
552 access(all) view fun getPendingCount(): Int {
553 return self.primaryReceipts.keys.length + self.recoveryReceipts.keys.length
554 }
555
556 access(all) view fun getScheduledIds(): [UInt64] {
557 return self.primaryReceipts.keys.concat(self.recoveryReceipts.keys)
558 }
559
560 // ============================================
561 // ADMIN FUNCTIONS
562 // ============================================
563
564 /// Schedule a batch of transactions
565 access(Admin) fun scheduleBatch(count: UInt64, escalateFirst: Bool) {
566 let nowTs = getCurrentBlock().timestamp
567 var i: UInt64 = 0
568
569 while i < count && self.primaryReceipts.keys.length < 10 {
570 let escalated = (i == 0 && escalateFirst)
571 let priority = escalated
572 ? FlowTransactionScheduler.Priority.Medium
573 : FlowTransactionScheduler.Priority.Low
574
575 // Use farthest timestamp tracking - scheduleTransaction updates it
576 self.scheduleOnePrimaryIfNeeded(nowTs: nowTs, systemBehind: escalated)
577 i = i + 1
578 }
579
580 // Schedule recovery if needed
581 self.scheduleRecoveryIfNeeded(nowTs: nowTs)
582 }
583
584 /// Cancel a specific transaction by ID
585 access(Admin) fun cancelById(txnId: UInt64) {
586 if let receipt <- self.primaryReceipts.remove(key: txnId) {
587 let status = FlowTransactionScheduler.getStatus(id: txnId)
588 if status == FlowTransactionScheduler.Status.Scheduled {
589 destroy FlowTransactionScheduler.cancel(scheduledTx: <-receipt)
590 } else {
591 // Transaction is not in Scheduled state (Executed, Canceled, Unknown, or nil)
592 // Just destroy the local receipt without calling cancel
593 destroy receipt
594 }
595 }
596 if let receipt <- self.recoveryReceipts.remove(key: txnId) {
597 let status = FlowTransactionScheduler.getStatus(id: txnId)
598 if status == FlowTransactionScheduler.Status.Scheduled {
599 destroy FlowTransactionScheduler.cancel(scheduledTx: <-receipt)
600 } else {
601 destroy receipt
602 }
603 }
604 }
605
606 /// Cancel all transactions
607 access(Admin) fun cancelAll() {
608 // Cancel primaries
609 let primaryKeys = self.primaryReceipts.keys
610 for id in primaryKeys {
611 self.cancelById(txnId: id)
612 }
613
614 // Cancel recoveries
615 let recoveryKeys = self.recoveryReceipts.keys
616 for id in recoveryKeys {
617 self.cancelById(txnId: id)
618 }
619
620 self.scheduledTxParams = {}
621 self.farthestPrimaryTs = 0.0
622 self.farthestRecoveryTs = 0.0
623 }
624 }
625
626 /// Factory for the handler resource
627 access(all) fun createHandler(
628 config: Config,
629 vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>,
630 handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>,
631 evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
632 ): @Handler {
633 return <- create Handler(
634 config: config,
635 vaultCap: vaultCap,
636 handlerCap: handlerCap,
637 evmCap: evmCap
638 )
639 }
640
641 access(all) init() {}
642}
643
644