Smart Contract

OracleArbHandler

A.b9973a32e6c52813.OracleArbHandler

Valid From

137,308,263

Deployed

1w ago
Feb 15, 2026, 01:09:40 PM UTC

Dependents

30 imports
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