Smart Contract
OracleArbHandler
A.b9973a32e6c52813.OracleArbHandler
1import FlowTransactionScheduler from 0xe467b9dd11fa00df
2import FlowToken from 0x1654653399040a61
3import FungibleToken from 0xf233dcee88fe0abe
4import EVM from 0xe467b9dd11fa00df
5import FlowEVMBridgeUtils from 0x1e4aa0b87d10b141
6import BandOracle from 0x6801a6222ebf784a
7
8/// Oracle Arbitrage Handler - trades WBTC/WETH when pool price diverges from oracle
9/// - Profitable: executes full arb trade in correct direction
10/// - Unprofitable: executes minimal keep-alive trade
11access(all) contract OracleArbHandler {
12
13 // WFLOW contract address on Flow EVM mainnet
14 access(all) let WFLOW_ADDRESS: String
15
16 access(all) entitlement Admin
17
18 // ============================================
19 // ABI ENCODING HELPERS
20 // ============================================
21
22 /// Encode V3 quoteExactInputSingle for price simulation
23 /// QuoterV2 uses struct: QuoteExactInputSingleParams { tokenIn, tokenOut, amountIn, fee, sqrtPriceLimitX96 }
24 access(all) fun encodeQuoteExactInputSingle(
25 tokenInHex: String,
26 tokenOutHex: String,
27 fee: UInt32,
28 amountIn: UInt256,
29 sqrtPriceLimitX96: UInt256
30 ): [UInt8] {
31 let signature = "quoteExactInputSingle((address,address,uint256,uint24,uint160))"
32 let tokenIn = EVM.addressFromString(tokenInHex)
33 let tokenOut = EVM.addressFromString(tokenOutHex)
34 // Pack as tuple matching struct order: tokenIn, tokenOut, amountIn, fee, sqrtPriceLimitX96
35 let params: [AnyStruct] = [tokenIn, tokenOut, amountIn, UInt256(fee), sqrtPriceLimitX96]
36 return EVM.encodeABIWithSignature(signature, [params])
37 }
38
39 /// Encode V3 exactInputSingle for standard SwapRouter02
40 /// struct ExactInputSingleParams {
41 /// address tokenIn;
42 /// address tokenOut;
43 /// uint24 fee;
44 /// address recipient;
45 /// uint256 amountIn;
46 /// uint256 amountOutMinimum;
47 /// uint160 sqrtPriceLimitX96;
48 /// }
49 /// If recipientHex is empty, coaAddress is used as recipient
50 access(all) fun encodeExactInputSingle(
51 tokenInHex: String,
52 tokenOutHex: String,
53 fee: UInt32,
54 recipientHex: String,
55 coaAddress: EVM.EVMAddress,
56 amountIn: UInt256,
57 amountOutMin: UInt256
58 ): [UInt8] {
59 let signature = "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))"
60 let tokenIn = EVM.addressFromString(tokenInHex)
61 let tokenOut = EVM.addressFromString(tokenOutHex)
62 // Use COA address if recipientHex is empty
63 let recipient = recipientHex.length > 0
64 ? EVM.addressFromString(recipientHex)
65 : coaAddress
66 let sqrtPriceLimitX96: UInt256 = 0
67
68 return EVM.encodeABIWithSignature(signature, [
69 tokenIn,
70 tokenOut,
71 UInt256(fee),
72 recipient,
73 amountIn,
74 amountOutMin,
75 sqrtPriceLimitX96
76 ])
77 }
78
79 /// Encode getAmountsOut for V2-style simulation
80 access(all) fun encodeGetAmountsOut(
81 amountIn: UInt256,
82 tokenInHex: String,
83 tokenOutHex: String
84 ): [UInt8] {
85 let signature = "getAmountsOut(uint256,address[])"
86 let path: [EVM.EVMAddress] = [
87 EVM.addressFromString(tokenInHex),
88 EVM.addressFromString(tokenOutHex)
89 ]
90 return EVM.encodeABIWithSignature(signature, [amountIn, path])
91 }
92
93 /// Encode V2 swap for direct pool interaction
94 /// V2 swap(uint amount0Out, uint amount1Out, address to, bytes data)
95 access(all) fun encodeV2Swap(
96 amount0Out: UInt256,
97 amount1Out: UInt256,
98 toAddress: EVM.EVMAddress
99 ): [UInt8] {
100 let signature = "swap(uint256,uint256,address,bytes)"
101 let emptyBytes: [UInt8] = []
102 return EVM.encodeABIWithSignature(signature, [amount0Out, amount1Out, toAddress, emptyBytes])
103 }
104
105 // ============================================
106 // CONFIG STRUCTS
107 // ============================================
108
109 /// Pool type (V2 vs V3)
110 access(all) enum PoolType: UInt8 {
111 access(all) case V2 // Uniswap V2 style (uses getReserves, swap)
112 access(all) case V3 // Uniswap V3 style (uses quoter, exactInputSingle)
113 }
114
115 /// Trade direction
116 access(all) enum Direction: UInt8 {
117 access(all) case AtoB // WBTC -> WETH (sell BTC)
118 access(all) case BtoA // WETH -> WBTC (buy BTC)
119 }
120
121 /// Arb analysis result (for testing and monitoring)
122 access(all) struct ArbAnalysis {
123 access(all) let oraclePrice: UFix64 // B per A from oracle
124 access(all) let poolPrice: UFix64 // B per A from pool
125 access(all) let priceDiffBps: Int64 // Difference in basis points
126 access(all) let direction: Direction? // nil = no arb
127 access(all) let shouldArb: Bool // Profit exceeds min threshold
128 access(all) let tradeAmountIn: UFix64 // OPTIMAL trade amount
129 access(all) let expectedOut: UFix64 // Expected output from trade
130 access(all) let expectedProfit: UFix64 // Expected profit (in output token)
131 access(all) let balanceA: UFix64 // Current token A balance
132 access(all) let balanceB: UFix64 // Current token B balance
133
134 init(
135 oraclePrice: UFix64,
136 poolPrice: UFix64,
137 priceDiffBps: Int64,
138 direction: Direction?,
139 shouldArb: Bool,
140 tradeAmountIn: UFix64,
141 expectedOut: UFix64,
142 expectedProfit: UFix64,
143 balanceA: UFix64,
144 balanceB: UFix64
145 ) {
146 self.oraclePrice = oraclePrice
147 self.poolPrice = poolPrice
148 self.priceDiffBps = priceDiffBps
149 self.direction = direction
150 self.shouldArb = shouldArb
151 self.tradeAmountIn = tradeAmountIn
152 self.expectedOut = expectedOut
153 self.expectedProfit = expectedProfit
154 self.balanceA = balanceA
155 self.balanceB = balanceB
156 }
157 }
158
159 /// Main configuration
160 access(all) struct Config {
161 // Token A (WBTC)
162 access(all) let tokenAHex: String
163 access(all) let tokenADecimals: UInt8
164 access(all) let oracleSymbolA: String // "BTC"
165
166 // Token B (WETH)
167 access(all) let tokenBHex: String
168 access(all) let tokenBDecimals: UInt8
169 access(all) let oracleSymbolB: String // "ETH"
170
171 // DEX config
172 access(all) let poolHex: String
173 access(all) let routerHex: String // V3 SwapRouter02 (ignored for V2)
174 access(all) let quoterHex: String // V3 QuoterV2 (ignored for V2)
175 access(all) let quoteHelperHex: String // V3 QuoteHelper (ignored for V2)
176 access(all) let fee: UInt32 // V3 fee tier (V2 uses fixed 0.3%)
177
178 // Trade params
179 access(all) let recipientHex: String
180 access(all) let minProfitBps: UInt16 // Min profit to do full trade (e.g., 30 = 0.3%)
181 access(all) let maxTradeAmountA: UFix64 // Max WBTC per trade
182 access(all) let maxTradeAmountB: UFix64 // Max WETH per trade
183 access(all) let minTradeAmountA: UFix64 // Keep-alive amount WBTC
184 access(all) let minTradeAmountB: UFix64 // Keep-alive amount WETH
185 access(all) let evmGasLimit: UInt64
186
187 // Scheduling
188 access(all) let intervalSeconds: UFix64
189 access(all) let backupOffsetSeconds: UFix64
190
191 init(
192 tokenAHex: String,
193 tokenADecimals: UInt8,
194 oracleSymbolA: String,
195 tokenBHex: String,
196 tokenBDecimals: UInt8,
197 oracleSymbolB: String,
198 poolHex: String,
199 routerHex: String,
200 quoterHex: String,
201 quoteHelperHex: String,
202 fee: UInt32,
203 recipientHex: String,
204 minProfitBps: UInt16,
205 maxTradeAmountA: UFix64,
206 maxTradeAmountB: UFix64,
207 minTradeAmountA: UFix64,
208 minTradeAmountB: UFix64,
209 evmGasLimit: UInt64,
210 intervalSeconds: UFix64,
211 backupOffsetSeconds: UFix64
212 ) {
213 self.tokenAHex = tokenAHex
214 self.tokenADecimals = tokenADecimals
215 self.oracleSymbolA = oracleSymbolA
216 self.tokenBHex = tokenBHex
217 self.tokenBDecimals = tokenBDecimals
218 self.oracleSymbolB = oracleSymbolB
219 self.poolHex = poolHex
220 self.routerHex = routerHex
221 self.quoterHex = quoterHex
222 self.quoteHelperHex = quoteHelperHex
223 self.fee = fee
224 self.recipientHex = recipientHex
225 self.minProfitBps = minProfitBps
226 self.maxTradeAmountA = maxTradeAmountA
227 self.maxTradeAmountB = maxTradeAmountB
228 self.minTradeAmountA = minTradeAmountA
229 self.minTradeAmountB = minTradeAmountB
230 self.evmGasLimit = evmGasLimit
231 self.intervalSeconds = intervalSeconds
232 self.backupOffsetSeconds = backupOffsetSeconds
233 }
234 }
235
236 /// Pending transaction params
237 access(all) struct PendingTxParams {
238 access(all) let isPrimary: Bool
239
240 init(isPrimary: Bool) {
241 self.isPrimary = isPrimary
242 }
243 }
244
245 // ============================================
246 // HANDLER RESOURCE
247 // ============================================
248
249 access(all) resource Handler: FlowTransactionScheduler.TransactionHandler {
250 access(self) var config: Config
251
252 /// Public getter for config (allows reading current settings)
253 access(all) fun getConfig(): Config {
254 return self.config
255 }
256
257 /// Get pool type - V2 if quoteHelperHex is empty, otherwise V3
258 /// This is a heuristic: V2 pools don't use quoter, so empty quoteHelper means V2
259 access(all) fun getPoolType(): PoolType {
260 // If quoteHelperHex is empty or a zero address, assume V2
261 if self.config.quoteHelperHex.length == 0 ||
262 self.config.quoteHelperHex == "0x0000000000000000000000000000000000000000" ||
263 self.config.quoteHelperHex == "0000000000000000000000000000000000000000" {
264 return PoolType.V2
265 }
266 return PoolType.V3
267 }
268
269 access(self) let vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>
270 access(self) let handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>
271 access(self) let evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
272
273 access(all) var primaryReceipts: @{UInt64: FlowTransactionScheduler.ScheduledTransaction}
274 access(all) var recoveryReceipts: @{UInt64: FlowTransactionScheduler.ScheduledTransaction}
275 access(all) var farthestPrimaryTs: UFix64
276 access(all) var farthestRecoveryTs: UFix64
277
278 // Stats
279 access(all) var totalArbs: UInt64
280 access(all) var totalKeepAlives: UInt64
281
282 init(
283 config: Config,
284 vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>,
285 handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>,
286 evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
287 ) {
288 self.config = config
289 self.vaultCap = vaultCap
290 self.handlerCap = handlerCap
291 self.evmCap = evmCap
292 self.primaryReceipts <- {}
293 self.recoveryReceipts <- {}
294 self.farthestPrimaryTs = 0.0
295 self.farthestRecoveryTs = 0.0
296 self.totalArbs = 0
297 self.totalKeepAlives = 0
298 }
299
300 // ============================================
301 // ORACLE PRICE FUNCTIONS
302 // ============================================
303
304 /// Get oracle price ratio A/B (e.g., BTC/ETH)
305 /// Returns price as UFix64 (how many B tokens per 1 A token)
306 access(self) fun getOraclePriceRatio(): UFix64 {
307 // Create empty vault (fee is 0)
308 let payment <- FlowToken.createEmptyVault(vaultType: Type<@FlowToken.Vault>())
309
310 // Get A/B price directly from oracle
311 let refData = BandOracle.getReferenceData(
312 baseSymbol: self.config.oracleSymbolA,
313 quoteSymbol: self.config.oracleSymbolB,
314 payment: <- payment
315 )
316
317 return refData.fixedPointRate
318 }
319
320 // ============================================
321 // DEX SIMULATION FUNCTIONS
322 // ============================================
323
324 /// Simulate swap A->B (routes to V2 or V3 based on poolType)
325 access(self) fun simulateSwapAtoB(amountIn: UFix64): UFix64? {
326 switch self.getPoolType() {
327 case PoolType.V2:
328 return self.simulateV2SwapAtoB(amountIn: amountIn)
329 case PoolType.V3:
330 return self.simulateV3SwapAtoB(amountIn: amountIn)
331 }
332 return nil
333 }
334
335 /// Simulate swap B->A (routes to V2 or V3 based on poolType)
336 access(self) fun simulateSwapBtoA(amountIn: UFix64): UFix64? {
337 switch self.getPoolType() {
338 case PoolType.V2:
339 return self.simulateV2SwapBtoA(amountIn: amountIn)
340 case PoolType.V3:
341 return self.simulateV3SwapBtoA(amountIn: amountIn)
342 }
343 return nil
344 }
345
346 // ============================================
347 // V3 SIMULATION (QuoteHelper)
348 // ============================================
349
350 /// V3: Simulate swap A->B using QuoteHelper
351 access(self) fun simulateV3SwapAtoB(amountIn: UFix64): UFix64? {
352 let coa = self.evmCap.borrow() ?? panic("Invalid COA")
353
354 let amountInScaled = FlowEVMBridgeUtils.ufix64ToUInt256(
355 value: amountIn,
356 decimals: self.config.tokenADecimals
357 )
358
359 let tokenIn = EVM.addressFromString(self.config.tokenAHex)
360 let tokenOut = EVM.addressFromString(self.config.tokenBHex)
361 let calldata = EVM.encodeABIWithSignature(
362 "quote(address,address,uint256,uint24)",
363 [tokenIn, tokenOut, amountInScaled, UInt256(self.config.fee)]
364 )
365
366 let helper = EVM.addressFromString(self.config.quoteHelperHex)
367 let res = coa.call(
368 to: helper,
369 data: calldata,
370 gasLimit: 500000,
371 value: EVM.Balance(attoflow: 0)
372 )
373
374 if res.status != EVM.Status.successful || res.data.length == 0 {
375 return nil
376 }
377
378 let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: res.data)
379 let amountOut = decoded[0] as! UInt256
380
381 return FlowEVMBridgeUtils.uint256ToUFix64(
382 value: amountOut,
383 decimals: self.config.tokenBDecimals
384 )
385 }
386
387 /// V3: Simulate swap B->A using QuoteHelper
388 access(self) fun simulateV3SwapBtoA(amountIn: UFix64): UFix64? {
389 let coa = self.evmCap.borrow() ?? panic("Invalid COA")
390
391 let amountInScaled = FlowEVMBridgeUtils.ufix64ToUInt256(
392 value: amountIn,
393 decimals: self.config.tokenBDecimals
394 )
395
396 let tokenIn = EVM.addressFromString(self.config.tokenBHex)
397 let tokenOut = EVM.addressFromString(self.config.tokenAHex)
398 let calldata = EVM.encodeABIWithSignature(
399 "quote(address,address,uint256,uint24)",
400 [tokenIn, tokenOut, amountInScaled, UInt256(self.config.fee)]
401 )
402
403 let helper = EVM.addressFromString(self.config.quoteHelperHex)
404 let res = coa.call(
405 to: helper,
406 data: calldata,
407 gasLimit: 500000,
408 value: EVM.Balance(attoflow: 0)
409 )
410
411 if res.status != EVM.Status.successful || res.data.length == 0 {
412 return nil
413 }
414
415 let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: res.data)
416 let amountOut = decoded[0] as! UInt256
417
418 return FlowEVMBridgeUtils.uint256ToUFix64(
419 value: amountOut,
420 decimals: self.config.tokenADecimals
421 )
422 }
423
424 // ============================================
425 // V2 SIMULATION (getReserves + constant product)
426 // ============================================
427
428 /// V2: Get reserves from pool (returns {reserveA, reserveB} or nil)
429 access(self) fun getV2Reserves(): {String: UFix64}? {
430 let coa = self.evmCap.borrow() ?? panic("Invalid COA")
431 let pool = EVM.addressFromString(self.config.poolHex)
432
433 let calldata = EVM.encodeABIWithSignature("getReserves()", [] as [AnyStruct])
434 let res = coa.call(
435 to: pool,
436 data: calldata,
437 gasLimit: 100000,
438 value: EVM.Balance(attoflow: 0)
439 )
440
441 if res.status != EVM.Status.successful || res.data.length < 64 {
442 return nil
443 }
444
445 // getReserves returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)
446 let decoded = EVM.decodeABI(types: [Type<UInt256>(), Type<UInt256>(), Type<UInt256>()], data: res.data)
447 let reserve0 = decoded[0] as! UInt256
448 let reserve1 = decoded[1] as! UInt256
449
450 // Get token0 from pool to determine order
451 let token0Call = EVM.encodeABIWithSignature("token0()", [] as [AnyStruct])
452 let token0Res = coa.call(to: pool, data: token0Call, gasLimit: 100000, value: EVM.Balance(attoflow: 0))
453
454 if token0Res.status != EVM.Status.successful {
455 return nil
456 }
457
458 let token0Decoded = EVM.decodeABI(types: [Type<EVM.EVMAddress>()], data: token0Res.data)
459 let token0 = token0Decoded[0] as! EVM.EVMAddress
460
461 // Check if tokenA is token0 (strip 0x prefix for comparison)
462 let tokenANormalized = self.config.tokenAHex.toLower().slice(from: 2, upTo: self.config.tokenAHex.length)
463 let tokenAIsToken0 = token0.toString().toLower() == tokenANormalized
464
465 var reserveA: UFix64 = 0.0
466 var reserveB: UFix64 = 0.0
467
468 if tokenAIsToken0 {
469 reserveA = FlowEVMBridgeUtils.uint256ToUFix64(value: reserve0, decimals: self.config.tokenADecimals)
470 reserveB = FlowEVMBridgeUtils.uint256ToUFix64(value: reserve1, decimals: self.config.tokenBDecimals)
471 } else {
472 reserveA = FlowEVMBridgeUtils.uint256ToUFix64(value: reserve1, decimals: self.config.tokenADecimals)
473 reserveB = FlowEVMBridgeUtils.uint256ToUFix64(value: reserve0, decimals: self.config.tokenBDecimals)
474 }
475
476 return {"reserveA": reserveA, "reserveB": reserveB}
477 }
478
479 /// V2: Calculate output using constant product formula (x * y = k)
480 /// amountOut = (amountIn * 997 * reserveOut) / (reserveIn * 1000 + amountIn * 997)
481 access(self) fun calculateV2AmountOut(amountIn: UFix64, reserveIn: UFix64, reserveOut: UFix64): UFix64 {
482 if reserveIn == 0.0 || reserveOut == 0.0 {
483 return 0.0
484 }
485 // V2 has 0.3% fee (997/1000)
486 let amountInWithFee = amountIn * 0.997
487 let numerator = amountInWithFee * reserveOut
488 let denominator = reserveIn + amountInWithFee
489 return numerator / denominator
490 }
491
492 /// V2: Simulate swap A->B using reserves
493 access(self) fun simulateV2SwapAtoB(amountIn: UFix64): UFix64? {
494 if let reserves = self.getV2Reserves() {
495 let reserveA = reserves["reserveA"]!
496 let reserveB = reserves["reserveB"]!
497 return self.calculateV2AmountOut(amountIn: amountIn, reserveIn: reserveA, reserveOut: reserveB)
498 }
499 return nil
500 }
501
502 /// V2: Simulate swap B->A using reserves
503 access(self) fun simulateV2SwapBtoA(amountIn: UFix64): UFix64? {
504 if let reserves = self.getV2Reserves() {
505 let reserveA = reserves["reserveA"]!
506 let reserveB = reserves["reserveB"]!
507 return self.calculateV2AmountOut(amountIn: amountIn, reserveIn: reserveB, reserveOut: reserveA)
508 }
509 return nil
510 }
511
512 // ============================================
513 // BALANCE FUNCTIONS
514 // ============================================
515
516 /// Get COA's token balance
517 access(self) fun getTokenBalance(tokenHex: String, decimals: UInt8): UFix64 {
518 let coa = self.evmCap.borrow() ?? panic("Invalid COA")
519 let token = EVM.addressFromString(tokenHex)
520 let coaAddr = coa.address()
521
522 let calldata = EVM.encodeABIWithSignature("balanceOf(address)", [coaAddr])
523 let res = coa.call(
524 to: token,
525 data: calldata,
526 gasLimit: 100000,
527 value: EVM.Balance(attoflow: 0)
528 )
529
530 if res.status != EVM.Status.successful || res.data.length == 0 {
531 return 0.0
532 }
533
534 let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: res.data)
535 let balance = decoded[0] as! UInt256
536
537 return FlowEVMBridgeUtils.uint256ToUFix64(value: balance, decimals: decimals)
538 }
539
540 /// Get token A (WBTC) balance
541 access(all) fun getBalanceA(): UFix64 {
542 return self.getTokenBalance(tokenHex: self.config.tokenAHex, decimals: self.config.tokenADecimals)
543 }
544
545 /// Get token B (WETH) balance
546 access(all) fun getBalanceB(): UFix64 {
547 return self.getTokenBalance(tokenHex: self.config.tokenBHex, decimals: self.config.tokenBDecimals)
548 }
549
550 // ============================================
551 // SWAP EXECUTION (V2 + V3 unified)
552 // ============================================
553
554 /// Execute swap A->B using appropriate method for pool type
555 /// Returns true if successful
556 access(self) fun executeSwapAtoB(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, amountIn: UInt256): Bool {
557 switch self.getPoolType() {
558 case PoolType.V2:
559 return self.executeV2SwapAtoB(coa: coa, amountIn: amountIn)
560 case PoolType.V3:
561 return self.executeV3SwapAtoB(coa: coa, amountIn: amountIn)
562 }
563 return false
564 }
565
566 /// Execute swap B->A using appropriate method for pool type
567 /// Returns true if successful
568 access(self) fun executeSwapBtoA(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, amountIn: UInt256): Bool {
569 switch self.getPoolType() {
570 case PoolType.V2:
571 return self.executeV2SwapBtoA(coa: coa, amountIn: amountIn)
572 case PoolType.V3:
573 return self.executeV3SwapBtoA(coa: coa, amountIn: amountIn)
574 }
575 return false
576 }
577
578 /// V3: Execute swap A->B via router
579 access(self) fun executeV3SwapAtoB(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, amountIn: UInt256): Bool {
580 let router = EVM.addressFromString(self.config.routerHex)
581 let tokenIn = EVM.addressFromString(self.config.tokenAHex)
582
583 self.ensureAllowance(coa: coa, token: tokenIn, spender: router, amount: amountIn)
584
585 let payload = OracleArbHandler.encodeExactInputSingle(
586 tokenInHex: self.config.tokenAHex,
587 tokenOutHex: self.config.tokenBHex,
588 fee: self.config.fee,
589 recipientHex: self.config.recipientHex,
590 coaAddress: coa.address(),
591 amountIn: amountIn,
592 amountOutMin: 0
593 )
594
595 let res = coa.call(to: router, data: payload, gasLimit: self.config.evmGasLimit, value: EVM.Balance(attoflow: 0))
596 return res.status == EVM.Status.successful
597 }
598
599 /// V3: Execute swap B->A via router
600 access(self) fun executeV3SwapBtoA(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, amountIn: UInt256): Bool {
601 let router = EVM.addressFromString(self.config.routerHex)
602 let tokenIn = EVM.addressFromString(self.config.tokenBHex)
603
604 self.ensureAllowance(coa: coa, token: tokenIn, spender: router, amount: amountIn)
605
606 let payload = OracleArbHandler.encodeExactInputSingle(
607 tokenInHex: self.config.tokenBHex,
608 tokenOutHex: self.config.tokenAHex,
609 fee: self.config.fee,
610 recipientHex: self.config.recipientHex,
611 coaAddress: coa.address(),
612 amountIn: amountIn,
613 amountOutMin: 0
614 )
615
616 let res = coa.call(to: router, data: payload, gasLimit: self.config.evmGasLimit, value: EVM.Balance(attoflow: 0))
617 return res.status == EVM.Status.successful
618 }
619
620 /// V2: Execute swap A->B by transferring to pool then calling swap()
621 access(self) fun executeV2SwapAtoB(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, amountIn: UInt256): Bool {
622 let pool = EVM.addressFromString(self.config.poolHex)
623 let tokenIn = EVM.addressFromString(self.config.tokenAHex)
624
625 // Calculate expected output
626 let amountInUFix = FlowEVMBridgeUtils.uint256ToUFix64(value: amountIn, decimals: self.config.tokenADecimals)
627 let expectedOut = self.simulateV2SwapAtoB(amountIn: amountInUFix)
628 if expectedOut == nil || expectedOut! == 0.0 {
629 return false
630 }
631
632 let amountOutScaled = FlowEVMBridgeUtils.ufix64ToUInt256(value: expectedOut!, decimals: self.config.tokenBDecimals)
633
634 // 1. Transfer input tokens to pool
635 let transferCall = EVM.encodeABIWithSignature("transfer(address,uint256)", [pool, amountIn])
636 let transferRes = coa.call(to: tokenIn, data: transferCall, gasLimit: 100000, value: EVM.Balance(attoflow: 0))
637 if transferRes.status != EVM.Status.successful {
638 return false
639 }
640
641 // 2. Determine amount0Out and amount1Out based on token order
642 let token0Call = EVM.encodeABIWithSignature("token0()", [] as [AnyStruct])
643 let token0Res = coa.call(to: pool, data: token0Call, gasLimit: 100000, value: EVM.Balance(attoflow: 0))
644 if token0Res.status != EVM.Status.successful {
645 return false
646 }
647 let token0Decoded = EVM.decodeABI(types: [Type<EVM.EVMAddress>()], data: token0Res.data)
648 let token0 = token0Decoded[0] as! EVM.EVMAddress
649
650 // Strip 0x prefix for comparison
651 let tokenANormalized = self.config.tokenAHex.toLower().slice(from: 2, upTo: self.config.tokenAHex.length)
652 let tokenAIsToken0 = token0.toString().toLower() == tokenANormalized
653
654 // A->B means we're outputting B
655 // If A is token0, then B is token1, so amount0Out=0, amount1Out=expectedOut
656 // If A is token1, then B is token0, so amount0Out=expectedOut, amount1Out=0
657 var amount0Out: UInt256 = 0
658 var amount1Out: UInt256 = 0
659 if tokenAIsToken0 {
660 amount1Out = amountOutScaled // Output B (token1)
661 } else {
662 amount0Out = amountOutScaled // Output B (token0)
663 }
664
665 // 3. Call swap
666 let swapCall = OracleArbHandler.encodeV2Swap(amount0Out: amount0Out, amount1Out: amount1Out, toAddress: coa.address())
667 let swapRes = coa.call(to: pool, data: swapCall, gasLimit: self.config.evmGasLimit, value: EVM.Balance(attoflow: 0))
668
669 return swapRes.status == EVM.Status.successful
670 }
671
672 /// V2: Execute swap B->A by transferring to pool then calling swap()
673 access(self) fun executeV2SwapBtoA(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, amountIn: UInt256): Bool {
674 let pool = EVM.addressFromString(self.config.poolHex)
675 let tokenIn = EVM.addressFromString(self.config.tokenBHex)
676
677 // Calculate expected output
678 let amountInUFix = FlowEVMBridgeUtils.uint256ToUFix64(value: amountIn, decimals: self.config.tokenBDecimals)
679 let expectedOut = self.simulateV2SwapBtoA(amountIn: amountInUFix)
680 if expectedOut == nil || expectedOut! == 0.0 {
681 return false
682 }
683
684 let amountOutScaled = FlowEVMBridgeUtils.ufix64ToUInt256(value: expectedOut!, decimals: self.config.tokenADecimals)
685
686 // 1. Transfer input tokens to pool
687 let transferCall = EVM.encodeABIWithSignature("transfer(address,uint256)", [pool, amountIn])
688 let transferRes = coa.call(to: tokenIn, data: transferCall, gasLimit: 100000, value: EVM.Balance(attoflow: 0))
689 if transferRes.status != EVM.Status.successful {
690 return false
691 }
692
693 // 2. Determine amount0Out and amount1Out based on token order
694 let token0Call = EVM.encodeABIWithSignature("token0()", [] as [AnyStruct])
695 let token0Res = coa.call(to: pool, data: token0Call, gasLimit: 100000, value: EVM.Balance(attoflow: 0))
696 if token0Res.status != EVM.Status.successful {
697 return false
698 }
699 let token0Decoded = EVM.decodeABI(types: [Type<EVM.EVMAddress>()], data: token0Res.data)
700 let token0 = token0Decoded[0] as! EVM.EVMAddress
701
702 // Strip 0x prefix for comparison
703 let tokenANormalized = self.config.tokenAHex.toLower().slice(from: 2, upTo: self.config.tokenAHex.length)
704 let tokenAIsToken0 = token0.toString().toLower() == tokenANormalized
705
706 // B->A means we're outputting A
707 // If A is token0, then amount0Out=expectedOut, amount1Out=0
708 // If A is token1, then amount0Out=0, amount1Out=expectedOut
709 var amount0Out: UInt256 = 0
710 var amount1Out: UInt256 = 0
711 if tokenAIsToken0 {
712 amount0Out = amountOutScaled // Output A (token0)
713 } else {
714 amount1Out = amountOutScaled // Output A (token1)
715 }
716
717 // 3. Call swap
718 let swapCall = OracleArbHandler.encodeV2Swap(amount0Out: amount0Out, amount1Out: amount1Out, toAddress: coa.address())
719 let swapRes = coa.call(to: pool, data: swapCall, gasLimit: self.config.evmGasLimit, value: EVM.Balance(attoflow: 0))
720
721 return swapRes.status == EVM.Status.successful
722 }
723
724 // ============================================
725 // OPTIMAL TRADE SIZE CALCULATION
726 // ============================================
727
728 /// Calculate profit for selling amountA at current prices
729 /// Returns (expectedOutput, profit, effectivePrice) or nil if simulation fails
730 access(self) fun calculateProfitForSellA(amountA: UFix64, oraclePrice: UFix64): {String: UFix64}? {
731 if amountA == 0.0 { return nil }
732
733 let expectedB = self.simulateSwapAtoB(amountIn: amountA)
734 if expectedB == nil { return nil }
735
736 // Fair value in B = amountA * oraclePrice (what oracle says A is worth in B)
737 let fairValueB = amountA * oraclePrice
738
739 // Profit = what we get - fair value
740 // If expectedB > fairValueB, we profit
741 var profitB: UFix64 = 0.0
742 if expectedB! > fairValueB {
743 profitB = expectedB! - fairValueB
744 }
745
746 // Effective price = expectedB / amountA
747 let effectivePrice = expectedB! / amountA
748
749 return {
750 "expectedB": expectedB!,
751 "profitB": profitB,
752 "effectivePrice": effectivePrice,
753 "fairValueB": fairValueB
754 }
755 }
756
757 /// Calculate profit for buying A with amountB at current prices
758 access(self) fun calculateProfitForBuyA(amountB: UFix64, oraclePrice: UFix64): {String: UFix64}? {
759 if amountB == 0.0 { return nil }
760
761 let expectedA = self.simulateSwapBtoA(amountIn: amountB)
762 if expectedA == nil { return nil }
763
764 // Fair value: amountB should buy amountB/oraclePrice of A
765 let fairValueA = amountB / oraclePrice
766
767 // Profit = what we get - fair value
768 var profitA: UFix64 = 0.0
769 if expectedA! > fairValueA {
770 profitA = expectedA! - fairValueA
771 }
772
773 return {
774 "expectedA": expectedA!,
775 "profitA": profitA,
776 "fairValueA": fairValueA
777 }
778 }
779
780 /// Find optimal trade size via binary search (for SELL direction)
781 /// Returns (optimalAmount, expectedProfit, effectivePrice)
782 access(self) fun findOptimalSellSize(
783 maxAmount: UFix64,
784 oraclePrice: UFix64,
785 steps: Int
786 ): {String: UFix64} {
787 var low: UFix64 = self.config.minTradeAmountA
788 var high: UFix64 = maxAmount
789 var bestAmount: UFix64 = low
790 var bestProfit: UFix64 = 0.0
791 var bestPrice: UFix64 = 0.0
792
793 // Binary search for optimal - check if profit increases or decreases
794 var i = 0
795 while i < steps {
796 let mid = (low + high) / 2.0
797 let midPlus = mid * 1.1 // 10% higher
798
799 let profitMid = self.calculateProfitForSellA(amountA: mid, oraclePrice: oraclePrice)
800 let profitMidPlus = self.calculateProfitForSellA(amountA: midPlus < high ? midPlus : high, oraclePrice: oraclePrice)
801
802 if profitMid == nil {
803 high = mid
804 i = i + 1
805 continue
806 }
807
808 let pMid = profitMid!["profitB"]!
809
810 // Track best
811 if pMid > bestProfit {
812 bestProfit = pMid
813 bestAmount = mid
814 bestPrice = profitMid!["effectivePrice"]!
815 }
816
817 // If increasing profit, search higher; else search lower
818 if profitMidPlus != nil && profitMidPlus!["profitB"]! > pMid {
819 low = mid
820 } else {
821 high = mid
822 }
823
824 i = i + 1
825 }
826
827 return {
828 "optimalAmount": bestAmount,
829 "expectedProfit": bestProfit,
830 "effectivePrice": bestPrice
831 }
832 }
833
834 /// Find optimal trade size for BUY direction
835 access(self) fun findOptimalBuySize(
836 maxAmount: UFix64,
837 oraclePrice: UFix64,
838 steps: Int
839 ): {String: UFix64} {
840 var low: UFix64 = self.config.minTradeAmountB
841 var high: UFix64 = maxAmount
842 var bestAmount: UFix64 = low
843 var bestProfit: UFix64 = 0.0
844
845 var i = 0
846 while i < steps {
847 let mid = (low + high) / 2.0
848 let midPlus = mid * 1.1
849
850 let profitMid = self.calculateProfitForBuyA(amountB: mid, oraclePrice: oraclePrice)
851 let profitMidPlus = self.calculateProfitForBuyA(amountB: midPlus < high ? midPlus : high, oraclePrice: oraclePrice)
852
853 if profitMid == nil {
854 high = mid
855 i = i + 1
856 continue
857 }
858
859 let pMid = profitMid!["profitA"]!
860
861 if pMid > bestProfit {
862 bestProfit = pMid
863 bestAmount = mid
864 }
865
866 if profitMidPlus != nil && profitMidPlus!["profitA"]! > pMid {
867 low = mid
868 } else {
869 high = mid
870 }
871
872 i = i + 1
873 }
874
875 return {
876 "optimalAmount": bestAmount,
877 "expectedProfit": bestProfit
878 }
879 }
880
881 // ============================================
882 // PUBLIC ANALYSIS (for testing/monitoring)
883 // ============================================
884
885 /// Analyze arb opportunity without executing - returns full analysis
886 access(all) fun analyzeOpportunity(): ArbAnalysis {
887 // Get balances
888 let balanceA = self.getBalanceA()
889 let balanceB = self.getBalanceB()
890
891 // Get oracle price
892 let oraclePrice = self.getOraclePriceRatio()
893
894 // Simulate to get pool price
895 let testAmountA: UFix64 = 0.001
896 let poolOutputB = self.simulateSwapAtoB(amountIn: testAmountA)
897
898 if poolOutputB == nil {
899 // Simulation failed
900 return ArbAnalysis(
901 oraclePrice: oraclePrice,
902 poolPrice: 0.0,
903 priceDiffBps: 0,
904 direction: nil,
905 shouldArb: false,
906 tradeAmountIn: 0.0,
907 expectedOut: 0.0,
908 expectedProfit: 0.0,
909 balanceA: balanceA,
910 balanceB: balanceB
911 )
912 }
913
914 let poolPrice = poolOutputB! / testAmountA
915
916 // Calculate price diff carefully to avoid UFix64 underflow
917 var priceDiffBps: Int64 = 0
918 if poolPrice >= oraclePrice {
919 priceDiffBps = Int64((poolPrice / oraclePrice - 1.0) * 10000.0)
920 } else {
921 priceDiffBps = -Int64((1.0 - poolPrice / oraclePrice) * 10000.0)
922 }
923 let minProfitBps = Int64(self.config.minProfitBps)
924
925 // Determine direction and OPTIMAL trade amount
926 var direction: Direction? = nil
927 var shouldArb = false
928 var tradeAmountIn: UFix64 = 0.0
929 var expectedOut: UFix64 = 0.0
930 var expectedProfit: UFix64 = 0.0
931
932 if priceDiffBps > minProfitBps {
933 // Pool overvalues A - SELL A for B
934 direction = Direction.AtoB
935 shouldArb = true
936
937 // Find OPTIMAL trade size (not just max)
938 let maxTrade = balanceA < self.config.maxTradeAmountA ? balanceA : self.config.maxTradeAmountA
939 if maxTrade >= self.config.minTradeAmountA {
940 let optimal = self.findOptimalSellSize(maxAmount: maxTrade, oraclePrice: oraclePrice, steps: 5)
941 tradeAmountIn = optimal["optimalAmount"]!
942 expectedProfit = optimal["expectedProfit"]!
943 expectedOut = self.simulateSwapAtoB(amountIn: tradeAmountIn) ?? 0.0
944 }
945 } else if priceDiffBps < -minProfitBps {
946 // Pool undervalues A - BUY A with B
947 direction = Direction.BtoA
948 shouldArb = true
949
950 // Find OPTIMAL trade size
951 let maxTrade = balanceB < self.config.maxTradeAmountB ? balanceB : self.config.maxTradeAmountB
952 if maxTrade >= self.config.minTradeAmountB {
953 let optimal = self.findOptimalBuySize(maxAmount: maxTrade, oraclePrice: oraclePrice, steps: 5)
954 tradeAmountIn = optimal["optimalAmount"]!
955 expectedProfit = optimal["expectedProfit"]!
956 expectedOut = self.simulateSwapBtoA(amountIn: tradeAmountIn) ?? 0.0
957 }
958 } else {
959 // Keep-alive - use minTradeAmountA
960 direction = Direction.AtoB
961 tradeAmountIn = balanceA < self.config.minTradeAmountA ? balanceA : self.config.minTradeAmountA
962 if tradeAmountIn > 0.0 {
963 expectedOut = self.simulateSwapAtoB(amountIn: tradeAmountIn) ?? 0.0
964 }
965 }
966
967 return ArbAnalysis(
968 oraclePrice: oraclePrice,
969 poolPrice: poolPrice,
970 priceDiffBps: priceDiffBps,
971 direction: direction,
972 shouldArb: shouldArb,
973 tradeAmountIn: tradeAmountIn,
974 expectedOut: expectedOut,
975 expectedProfit: expectedProfit,
976 balanceA: balanceA,
977 balanceB: balanceB
978 )
979 }
980
981 // ============================================
982 // ARB LOGIC
983 // ============================================
984
985 /// Analyze arb opportunity and execute
986 access(self) fun analyzeAndExecute() {
987 let coa = self.evmCap.borrow() ?? panic("Invalid COA")
988
989 // Get oracle price (A per B, e.g., how many ETH per BTC)
990 let oraclePrice = self.getOraclePriceRatio()
991
992 // Simulate selling 1 unit of A for B (to get pool's A/B price)
993 let testAmountA: UFix64 = 0.001 // Small test amount
994 let poolOutputB = self.simulateSwapAtoB(amountIn: testAmountA)
995
996 if poolOutputB == nil {
997 // Simulation failed, do minimal keep-alive (no direction preference)
998 self.executeKeepAlive(coa: coa, preferredDirection: nil)
999 return
1000 }
1001
1002 // Calculate pool's effective price (B per A)
1003 let poolPrice = poolOutputB! / testAmountA
1004
1005 // Compare prices to find opportunity
1006 // If poolPrice > oraclePrice: Pool gives more B per A than oracle says it's worth
1007 // -> Pool overvalues A -> SELL A for B (Direction.AtoB)
1008 // If poolPrice < oraclePrice: Pool gives less B per A than oracle says
1009 // -> Pool undervalues A -> BUY A with B (Direction.BtoA)
1010
1011 // Calculate price diff carefully to avoid UFix64 underflow
1012 var priceDiffBps: Int64 = 0
1013 if poolPrice >= oraclePrice {
1014 priceDiffBps = Int64((poolPrice / oraclePrice - 1.0) * 10000.0)
1015 } else {
1016 priceDiffBps = -Int64((1.0 - poolPrice / oraclePrice) * 10000.0)
1017 }
1018 let minProfitBps = Int64(self.config.minProfitBps)
1019
1020 if priceDiffBps > minProfitBps {
1021 // Pool overvalues A - SELL A for B with OPTIMAL size
1022 self.executeArb(coa: coa, direction: Direction.AtoB, oraclePrice: oraclePrice)
1023 } else if priceDiffBps < -minProfitBps {
1024 // Pool undervalues A - BUY A with B with OPTIMAL size
1025 self.executeArb(coa: coa, direction: Direction.BtoA, oraclePrice: oraclePrice)
1026 } else {
1027 // No profitable arb - do minimal keep-alive IN THE RIGHT DIRECTION
1028 // Even tiny spreads should nudge the pool toward oracle price
1029 if priceDiffBps > 0 {
1030 // Pool overvalues A (but below profit threshold) → sell A for B
1031 self.executeKeepAlive(coa: coa, preferredDirection: Direction.AtoB)
1032 } else if priceDiffBps < 0 {
1033 // Pool undervalues A (but below profit threshold) → buy A with B
1034 self.executeKeepAlive(coa: coa, preferredDirection: Direction.BtoA)
1035 } else {
1036 // Exactly zero spread (rare) - no preference, default to A→B
1037 self.executeKeepAlive(coa: coa, preferredDirection: nil)
1038 }
1039 }
1040 }
1041
1042 /// Execute profitable arb trade with OPTIMAL size calculation
1043 /// Falls back to keep-alive if balance insufficient or not profitable
1044 access(self) fun executeArb(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, direction: Direction, oraclePrice: UFix64) {
1045 switch direction {
1046 case Direction.AtoB:
1047 // Sell A for B - find OPTIMAL trade size
1048 let balanceA = self.getBalanceA()
1049 let maxTrade = balanceA < self.config.maxTradeAmountA ? balanceA : self.config.maxTradeAmountA
1050
1051 if maxTrade < self.config.minTradeAmountA {
1052 log("OracleArbHandler: Insufficient A balance for arb, doing keep-alive")
1053 self.executeKeepAlive(coa: coa, preferredDirection: Direction.AtoB)
1054 return
1055 }
1056
1057 // Find optimal size via binary search
1058 let optimal = self.findOptimalSellSize(maxAmount: maxTrade, oraclePrice: oraclePrice, steps: 5)
1059 let expectedProfit = optimal["expectedProfit"]!
1060
1061 if expectedProfit == 0.0 {
1062 log("OracleArbHandler: Tiny profit AtoB, doing directional keep-alive")
1063 self.executeKeepAlive(coa: coa, preferredDirection: Direction.AtoB)
1064 return
1065 }
1066
1067 let amountIn = optimal["optimalAmount"]!
1068 let amountInScaled = FlowEVMBridgeUtils.ufix64ToUInt256(
1069 value: amountIn,
1070 decimals: self.config.tokenADecimals
1071 )
1072
1073 // Execute swap using unified helper (handles V2 and V3)
1074 if self.executeSwapAtoB(coa: coa, amountIn: amountInScaled) {
1075 self.totalArbs = self.totalArbs + 1
1076 }
1077
1078 case Direction.BtoA:
1079 // Buy A with B - find OPTIMAL trade size
1080 let balanceB = self.getBalanceB()
1081 let maxTrade = balanceB < self.config.maxTradeAmountB ? balanceB : self.config.maxTradeAmountB
1082
1083 if maxTrade < self.config.minTradeAmountB {
1084 log("OracleArbHandler: Insufficient B balance for arb, doing keep-alive")
1085 self.executeKeepAlive(coa: coa, preferredDirection: Direction.BtoA)
1086 return
1087 }
1088
1089 // Find optimal size via binary search
1090 let optimal = self.findOptimalBuySize(maxAmount: maxTrade, oraclePrice: oraclePrice, steps: 5)
1091 let expectedProfit = optimal["expectedProfit"]!
1092
1093 if expectedProfit == 0.0 {
1094 log("OracleArbHandler: Tiny profit BtoA, doing directional keep-alive")
1095 self.executeKeepAlive(coa: coa, preferredDirection: Direction.BtoA)
1096 return
1097 }
1098
1099 let amountIn = optimal["optimalAmount"]!
1100 let amountInScaled = FlowEVMBridgeUtils.ufix64ToUInt256(
1101 value: amountIn,
1102 decimals: self.config.tokenBDecimals
1103 )
1104
1105 // Execute swap using unified helper (handles V2 and V3)
1106 if self.executeSwapBtoA(coa: coa, amountIn: amountInScaled) {
1107 self.totalArbs = self.totalArbs + 1
1108 }
1109 }
1110 }
1111
1112 /// Execute minimal keep-alive trade (when no arb opportunity or profit too small)
1113 /// If preferredDirection is provided, tries that direction first (for tiny-profit arbs)
1114 /// Otherwise tries A→B first, falls back to B→A if no A balance
1115 access(self) fun executeKeepAlive(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, preferredDirection: Direction?) {
1116 // Determine order based on preferred direction
1117 let tryBtoAFirst = preferredDirection == Direction.BtoA
1118
1119 if tryBtoAFirst {
1120 // Preferred: B→A (buy A with B)
1121 if self.tryKeepAliveBtoA(coa: coa) { return }
1122 if self.tryKeepAliveAtoB(coa: coa) { return }
1123 } else {
1124 // Default: A→B first
1125 if self.tryKeepAliveAtoB(coa: coa) { return }
1126 if self.tryKeepAliveBtoA(coa: coa) { return }
1127 }
1128
1129 log("OracleArbHandler: No balance for keep-alive trade")
1130 }
1131
1132 /// Try A→B keep-alive, returns true if successful
1133 access(self) fun tryKeepAliveAtoB(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount): Bool {
1134 let balanceA = self.getBalanceA()
1135 let amountInA = balanceA < self.config.minTradeAmountA ? balanceA : self.config.minTradeAmountA
1136
1137 if amountInA > 0.0 {
1138 let amountInScaled = FlowEVMBridgeUtils.ufix64ToUInt256(
1139 value: amountInA,
1140 decimals: self.config.tokenADecimals
1141 )
1142
1143 // Use unified swap helper (handles V2 and V3)
1144 if self.executeSwapAtoB(coa: coa, amountIn: amountInScaled) {
1145 self.totalKeepAlives = self.totalKeepAlives + 1
1146 return true
1147 }
1148 }
1149 return false
1150 }
1151
1152 /// Try B→A keep-alive, returns true if successful
1153 access(self) fun tryKeepAliveBtoA(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount): Bool {
1154 let balanceB = self.getBalanceB()
1155 let amountInB = balanceB < self.config.minTradeAmountB ? balanceB : self.config.minTradeAmountB
1156
1157 if amountInB > 0.0 {
1158 let amountInScaled = FlowEVMBridgeUtils.ufix64ToUInt256(
1159 value: amountInB,
1160 decimals: self.config.tokenBDecimals
1161 )
1162
1163 // Use unified swap helper (handles V2 and V3)
1164 if self.executeSwapBtoA(coa: coa, amountIn: amountInScaled) {
1165 self.totalKeepAlives = self.totalKeepAlives + 1
1166 return true
1167 }
1168 }
1169 return false
1170 }
1171
1172 /// Ensure token has sufficient allowance for spender
1173 access(self) fun ensureAllowance(
1174 coa: auth(EVM.Call) &EVM.CadenceOwnedAccount,
1175 token: EVM.EVMAddress,
1176 spender: EVM.EVMAddress,
1177 amount: UInt256
1178 ) {
1179 // Check current allowance
1180 let allowanceCalldata = EVM.encodeABIWithSignature("allowance(address,address)", [coa.address(), spender])
1181 let allowanceRes = coa.call(
1182 to: token,
1183 data: allowanceCalldata,
1184 gasLimit: 100000,
1185 value: EVM.Balance(attoflow: 0)
1186 )
1187
1188 if allowanceRes.status == EVM.Status.successful {
1189 let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: allowanceRes.data)
1190 let currentAllowance = decoded[0] as! UInt256
1191
1192 if currentAllowance < amount {
1193 // Approve max
1194 let maxApproval: UInt256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935
1195 let approveCalldata = EVM.encodeABIWithSignature("approve(address,uint256)", [spender, maxApproval])
1196 let approveRes = coa.call(
1197 to: token,
1198 data: approveCalldata,
1199 gasLimit: 100000,
1200 value: EVM.Balance(attoflow: 0)
1201 )
1202
1203 if approveRes.status != EVM.Status.successful {
1204 panic("OracleArbHandler: token approval failed")
1205 }
1206 }
1207 }
1208 }
1209
1210 // ============================================
1211 // SCHEDULED TRANSACTION INTERFACE
1212 // ============================================
1213
1214 access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) {
1215 let currentTs = getCurrentBlock().timestamp
1216 let params = data as! PendingTxParams
1217
1218 if params.isPrimary {
1219 // Guard: only execute if receipt exists (prevent double execution)
1220 if let receipt <- self.primaryReceipts.remove(key: id) {
1221 destroy receipt
1222
1223 // Clean up at most one dead primary (spread compute across primaries)
1224 self.cleanupOneDeadPrimary()
1225
1226 // Primary: analyze and execute arb/keep-alive
1227 self.analyzeAndExecute()
1228 self.scheduleOnePrimaryIfNeeded(nowTs: currentTs, systemBehind: false)
1229 }
1230 // else: receipt already consumed (double call) - skip silently
1231 } else {
1232 // Guard: only execute if receipt exists
1233 if let receipt <- self.recoveryReceipts.remove(key: id) {
1234 destroy receipt
1235
1236 // Clean up at most one dead primary
1237 self.cleanupOneDeadPrimary()
1238
1239 let systemBehind = self.primaryReceipts.keys.length < 5
1240 self.scheduleOnePrimaryIfNeeded(nowTs: currentTs, systemBehind: systemBehind)
1241 self.scheduleRecoveryIfNeeded(nowTs: currentTs)
1242 }
1243 }
1244 }
1245
1246 /// Remove at most one dead primary receipt (transaction no longer exists in scheduler)
1247 /// Called by both primary and recovery to spread cleanup compute across executions
1248 access(self) fun cleanupOneDeadPrimary() {
1249 for id in self.primaryReceipts.keys {
1250 let status = FlowTransactionScheduler.getStatus(id: id)
1251 // Clean up if status is nil OR if status is Executed (rawValue 2)
1252 let isDead = status == nil || status!.rawValue == 2
1253 if isDead {
1254 if let receipt <- self.primaryReceipts.remove(key: id) {
1255 destroy receipt
1256 log("OracleArbHandler: Cleaned up dead primary ".concat(id.toString()))
1257 }
1258 return // Only clean up one per execution
1259 }
1260 }
1261 }
1262
1263 access(self) fun scheduleOnePrimaryIfNeeded(nowTs: UFix64, systemBehind: Bool) {
1264 let primaryCount = self.primaryReceipts.keys.length
1265 if primaryCount >= 10 {
1266 return
1267 }
1268
1269 let useMedium = systemBehind && primaryCount == 0
1270 let priority = useMedium
1271 ? FlowTransactionScheduler.Priority.Medium
1272 : FlowTransactionScheduler.Priority.Low
1273 // Compute budget: oracle queries + binary search + swap + schedule
1274 // V2 pools use 1000, V3 pools use 750
1275 let effort: UInt64 = self.getPoolType() == PoolType.V2 ? 2000 : 750
1276
1277 let baseTs = self.farthestPrimaryTs > nowTs ? self.farthestPrimaryTs : nowTs
1278 let ts = baseTs + self.config.intervalSeconds
1279
1280 self.scheduleTransaction(timestamp: ts, isPrimary: true, priority: priority, effort: effort)
1281 }
1282
1283 access(self) fun scheduleRecoveryIfNeeded(nowTs: UFix64) {
1284 if self.recoveryReceipts.keys.length > 0 {
1285 return
1286 }
1287
1288 let priority = FlowTransactionScheduler.Priority.Medium
1289 let effort: UInt64 = 300 // Recovery just reschedules, no arb work
1290
1291 // Recovery runs at 1/10 frequency of primary (slower backup for liveness)
1292 let recoveryInterval = self.config.intervalSeconds * 10.0
1293 let baseTs = self.farthestRecoveryTs > nowTs ? self.farthestRecoveryTs : nowTs
1294 let ts = baseTs + recoveryInterval
1295
1296 self.scheduleTransaction(timestamp: ts, isPrimary: false, priority: priority, effort: effort)
1297 }
1298
1299 access(self) fun scheduleTransaction(
1300 timestamp: UFix64,
1301 isPrimary: Bool,
1302 priority: FlowTransactionScheduler.Priority,
1303 effort: UInt64
1304 ) {
1305 let vaultRef = self.vaultCap.borrow()
1306 ?? panic("OracleArbHandler: invalid vault capability")
1307
1308 let txParams = PendingTxParams(isPrimary: isPrimary)
1309
1310 var ts = timestamp
1311 var step: UFix64 = 1.0
1312 var attempts: UInt64 = 0
1313 let maxAttempts: UInt64 = 20
1314
1315 while attempts < maxAttempts {
1316 let est = FlowTransactionScheduler.estimate(
1317 data: txParams,
1318 timestamp: ts,
1319 priority: priority,
1320 executionEffort: effort
1321 )
1322
1323 if est.timestamp != nil {
1324 let fees <- vaultRef.withdraw(amount: est.flowFee ?? 0.0) as! @FlowToken.Vault
1325 let receipt <- FlowTransactionScheduler.schedule(
1326 handlerCap: self.handlerCap,
1327 data: txParams,
1328 timestamp: ts,
1329 priority: priority,
1330 executionEffort: effort,
1331 fees: <-fees
1332 )
1333
1334 let txnId = receipt.id
1335
1336 if isPrimary {
1337 let old <- self.primaryReceipts.insert(key: txnId, <-receipt)
1338 destroy old
1339 if ts > self.farthestPrimaryTs {
1340 self.farthestPrimaryTs = ts
1341 }
1342 } else {
1343 let old <- self.recoveryReceipts.insert(key: txnId, <-receipt)
1344 destroy old
1345 if ts > self.farthestRecoveryTs {
1346 self.farthestRecoveryTs = ts
1347 }
1348 }
1349
1350 return
1351 }
1352
1353 ts = ts + step
1354 step = step * 2.0
1355 attempts = attempts + 1
1356 }
1357
1358 panic("OracleArbHandler: Failed to schedule after ".concat(maxAttempts.toString()).concat(" attempts"))
1359 }
1360
1361 // ============================================
1362 // VIEW FUNCTIONS
1363 // ============================================
1364
1365 access(all) view fun getViews(): [Type] {
1366 return []
1367 }
1368
1369 access(all) fun resolveView(_ view: Type): AnyStruct? {
1370 return nil
1371 }
1372
1373 access(all) view fun getPrimaryCount(): Int {
1374 return self.primaryReceipts.keys.length
1375 }
1376
1377 access(all) view fun getRecoveryCount(): Int {
1378 return self.recoveryReceipts.keys.length
1379 }
1380
1381 access(all) view fun getStats(): {String: UInt64} {
1382 return {
1383 "totalArbs": self.totalArbs,
1384 "totalKeepAlives": self.totalKeepAlives
1385 }
1386 }
1387
1388 // ============================================
1389 // ADMIN FUNCTIONS
1390 // ============================================
1391
1392 /// Execute analyzeAndExecute directly (bypasses scheduler receipt guard)
1393 /// Use for testing or manual intervention
1394 access(Admin) fun executeNow() {
1395 let nowTs = getCurrentBlock().timestamp
1396
1397 // Clean up dead primaries first
1398 self.cleanupOneDeadPrimary()
1399
1400 // Execute the arb/keepalive
1401 self.analyzeAndExecute()
1402
1403 // Schedule new transactions
1404 self.scheduleOnePrimaryIfNeeded(nowTs: nowTs, systemBehind: true)
1405 }
1406
1407 access(Admin) fun scheduleBatch(count: UInt64, escalateFirst: Bool) {
1408 let nowTs = getCurrentBlock().timestamp
1409 var i: UInt64 = 0
1410
1411 while i < count && self.primaryReceipts.keys.length < 10 {
1412 let escalated = (i == 0 && escalateFirst)
1413 self.scheduleOnePrimaryIfNeeded(nowTs: nowTs, systemBehind: escalated)
1414 i = i + 1
1415 }
1416
1417 self.scheduleRecoveryIfNeeded(nowTs: nowTs)
1418 }
1419
1420 /// Clean up ALL dead primaries at once (for stuck handlers)
1421 access(Admin) fun cleanupAllDeadPrimaries() {
1422 let keys = self.primaryReceipts.keys
1423 for id in keys {
1424 let status = FlowTransactionScheduler.getStatus(id: id)
1425 let isDead = status == nil || status!.rawValue == 2
1426 if isDead {
1427 if let receipt <- self.primaryReceipts.remove(key: id) {
1428 destroy receipt
1429 log("Cleaned up dead primary ".concat(id.toString()))
1430 }
1431 }
1432 }
1433 }
1434
1435 access(Admin) fun cancelAll() {
1436 let primaryKeys = self.primaryReceipts.keys
1437 for id in primaryKeys {
1438 if let receipt <- self.primaryReceipts.remove(key: id) {
1439 let status = FlowTransactionScheduler.getStatus(id: id)
1440 // Only cancel if transaction is Scheduled (rawValue 1)
1441 // If Executed (2) or Cancelled (3), just destroy the receipt
1442 if status != nil && status!.rawValue == 1 {
1443 destroy FlowTransactionScheduler.cancel(scheduledTx: <-receipt)
1444 } else {
1445 destroy receipt
1446 }
1447 }
1448 }
1449
1450 let recoveryKeys = self.recoveryReceipts.keys
1451 for id in recoveryKeys {
1452 if let receipt <- self.recoveryReceipts.remove(key: id) {
1453 let status = FlowTransactionScheduler.getStatus(id: id)
1454 // Only cancel if transaction is Scheduled (rawValue 1)
1455 if status != nil && status!.rawValue == 1 {
1456 destroy FlowTransactionScheduler.cancel(scheduledTx: <-receipt)
1457 } else {
1458 destroy receipt
1459 }
1460 }
1461 }
1462
1463 self.farthestPrimaryTs = 0.0
1464 self.farthestRecoveryTs = 0.0
1465 }
1466
1467 access(Admin) fun setConfig(_ newConfig: Config) {
1468 self.config = newConfig
1469 }
1470
1471 /// Manual execution for testing - triggers the same logic as scheduled execution
1472 access(Admin) fun manualExecute() {
1473 self.analyzeAndExecute()
1474 }
1475 }
1476
1477 // ============================================
1478 // FACTORY
1479 // ============================================
1480
1481 access(all) fun createHandler(
1482 config: Config,
1483 vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>,
1484 handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>,
1485 evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
1486 ): @Handler {
1487 return <- create Handler(
1488 config: config,
1489 vaultCap: vaultCap,
1490 handlerCap: handlerCap,
1491 evmCap: evmCap
1492 )
1493 }
1494
1495 access(all) init() {
1496 self.WFLOW_ADDRESS = "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e"
1497 }
1498}
1499
1500