Smart Contract

RebalanceHandler

A.b9973a32e6c52813.RebalanceHandler

Valid From

137,232,431

Deployed

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

Dependents

9 imports
1import FlowToken from 0x1654653399040a61
2import FungibleToken from 0xf233dcee88fe0abe
3import EVM from 0xe467b9dd11fa00df
4import FlowTransactionScheduler from 0xe467b9dd11fa00df
5import BandOracle from 0x6801a6222ebf784a
6
7/// RebalanceHandler: Maintains target portfolio allocations across a COA
8/// 
9/// Strategy: Each token has a targetPercent (0-100). When a token's share
10/// deviates from target by more than toleranceBps, swap from overweight to underweight.
11///
12/// Uses same scheduling pattern as OracleArbHandler (primaries + recovery)
13access(all) contract RebalanceHandler {
14
15    // ============================================
16    // ENTITLEMENTS
17    // ============================================
18    
19    access(all) entitlement Admin
20
21    // ============================================
22    // HELPER FUNCTIONS (inline to avoid bridge deps)
23    // ============================================
24
25    /// Convert UInt256 to UFix64 with decimals
26    access(all) fun uint256ToUFix64(value: UInt256, decimals: UInt8): UFix64 {
27        // UFix64 has 8 decimal places
28        if decimals <= 8 {
29            // Need to multiply to get 8 decimals
30            let factor = self.pow10(8 - decimals)
31            let scaled = value * UInt256(factor)
32            return UFix64(scaled) / 100000000.0
33        } else {
34            // Need to divide
35            let factor = self.pow10(decimals - 8)
36            let scaled = value / UInt256(factor)
37            return UFix64(scaled) / 100000000.0
38        }
39    }
40
41    /// Convert UFix64 to UInt256 with decimals
42    access(all) fun ufix64ToUInt256(value: UFix64, decimals: UInt8): UInt256 {
43        // UFix64 has 8 decimal places internally
44        let raw = UInt256(value * 100000000.0)
45        if decimals <= 8 {
46            let factor = self.pow10(8 - decimals)
47            return raw / UInt256(factor)
48        } else {
49            let factor = self.pow10(decimals - 8)
50            return raw * UInt256(factor)
51        }
52    }
53
54    access(self) fun pow10(_ exp: UInt8): UInt64 {
55        var result: UInt64 = 1
56        var i: UInt8 = 0
57        while i < exp {
58            result = result * 10
59            i = i + 1
60        }
61        return result
62    }
63
64    // ============================================
65    // CONFIG
66    // ============================================
67
68    access(all) struct TokenConfig {
69        access(all) let tokenHex: String
70        access(all) let decimals: UInt8
71        access(all) let oracleSymbol: String  // e.g., "ETH", "WBTC", "PYUSD"
72        access(all) let targetPercent: UFix64  // Target % of portfolio (0-100, must sum to 100)
73        
74        init(
75            tokenHex: String,
76            decimals: UInt8,
77            oracleSymbol: String,
78            targetPercent: UFix64
79        ) {
80            self.tokenHex = tokenHex
81            self.decimals = decimals
82            self.oracleSymbol = oracleSymbol
83            self.targetPercent = targetPercent
84        }
85    }
86
87    // Known token addresses for routing (hardcoded for storage compatibility)
88    access(all) fun getWethHex(): String { return "0x2F6F07CDcf3588944Bf4C42aC74ff24bF56e7590" }
89    access(all) fun getWbtcHex(): String { return "0x717dae2baf7656be9a9b01dee31d571a9d4c9579" }
90    access(all) fun getUsdfHex(): String { return "0x2aabea2058b5ac2d339b163c6ab6f2b6d53aabed" }
91    // PunchSwap V3 SwapRouter for USDF (deprecated DEX, only place USDF exists)
92    access(all) fun getPunchSwapRouterHex(): String { return "0x31A4C5F5a3aa5C35aF2668eB1B28dDe1e72fb6fe" }
93
94    access(all) struct Config {
95        access(all) let tokens: [TokenConfig]
96        access(all) let toleranceBps: UInt16    // Deviation tolerance before rebalance (e.g., 500 = 5%)
97        access(all) let wflowHex: String        // WFLOW for routing
98        access(all) let routerHex: String       // FlowSwap V3 Router
99        access(all) let evmGasLimit: UInt64
100        access(all) let intervalSeconds: UFix64
101        access(all) let backupOffsetSeconds: UFix64
102        
103        init(
104            tokens: [TokenConfig],
105            toleranceBps: UInt16,
106            wflowHex: String,
107            routerHex: String,
108            evmGasLimit: UInt64,
109            intervalSeconds: UFix64,
110            backupOffsetSeconds: UFix64
111        ) {
112            self.tokens = tokens
113            self.toleranceBps = toleranceBps
114            self.wflowHex = wflowHex
115            self.routerHex = routerHex
116            self.evmGasLimit = evmGasLimit
117            self.intervalSeconds = intervalSeconds
118            self.backupOffsetSeconds = backupOffsetSeconds
119        }
120    }
121
122    access(all) struct PendingTxParams {
123        access(all) let isPrimary: Bool
124        
125        init(isPrimary: Bool) {
126            self.isPrimary = isPrimary
127        }
128    }
129
130    access(all) struct TokenState {
131        access(all) let tokenHex: String
132        access(all) let symbol: String
133        access(all) let balance: UFix64
134        access(all) let usdPrice: UFix64
135        access(all) let usdValue: UFix64
136        access(all) let targetPercent: UFix64   // Target % of portfolio
137        access(all) let currentPercent: UFix64  // Actual % of portfolio
138        access(all) let deviationBps: Int64     // Deviation in basis points (positive = overweight)
139        
140        init(
141            tokenHex: String,
142            symbol: String,
143            balance: UFix64,
144            usdPrice: UFix64,
145            usdValue: UFix64,
146            targetPercent: UFix64,
147            totalPortfolioUsd: UFix64
148        ) {
149            self.tokenHex = tokenHex
150            self.symbol = symbol
151            self.balance = balance
152            self.usdPrice = usdPrice
153            self.usdValue = usdValue
154            self.targetPercent = targetPercent
155            self.currentPercent = totalPortfolioUsd > 0.0 ? (usdValue / totalPortfolioUsd) * 100.0 : 0.0
156            // Positive = overweight, Negative = underweight
157            if self.currentPercent >= targetPercent {
158                self.deviationBps = Int64((self.currentPercent - targetPercent) * 100.0)
159            } else {
160                self.deviationBps = -Int64((targetPercent - self.currentPercent) * 100.0)
161            }
162        }
163    }
164
165    /// Represents a token's USD imbalance for rebalancing
166    access(all) struct TokenDelta {
167        access(all) let tokenHex: String
168        access(all) let symbol: String
169        access(all) let deltaUSD: UFix64      // Absolute value of imbalance
170        access(all) let isOverweight: Bool    // true = sell, false = buy
171        access(all) let balance: UFix64
172        access(all) let usdPrice: UFix64
173        access(all) let decimals: UInt8
174        
175        init(tokenHex: String, symbol: String, deltaUSD: UFix64, isOverweight: Bool, balance: UFix64, usdPrice: UFix64, decimals: UInt8) {
176            self.tokenHex = tokenHex
177            self.symbol = symbol
178            self.deltaUSD = deltaUSD
179            self.isOverweight = isOverweight
180            self.balance = balance
181            self.usdPrice = usdPrice
182            self.decimals = decimals
183        }
184    }
185
186    // ============================================
187    // HANDLER RESOURCE
188    // ============================================
189
190    access(all) resource Handler: FlowTransactionScheduler.TransactionHandler {
191        access(self) var config: Config
192        access(self) let vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>
193        access(self) let handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>
194        access(self) let evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
195        
196        access(self) var primaryReceipts: @{UInt64: FlowTransactionScheduler.ScheduledTransaction}
197        access(self) var recoveryReceipts: @{UInt64: FlowTransactionScheduler.ScheduledTransaction}
198        access(self) var farthestPrimaryTs: UFix64
199        access(self) var farthestRecoveryTs: UFix64
200        
201        access(self) var totalRebalances: UInt64
202        access(self) var totalSkips: UInt64
203
204        init(
205            config: Config,
206            vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>,
207            handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>,
208            evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
209        ) {
210            self.config = config
211            self.vaultCap = vaultCap
212            self.handlerCap = handlerCap
213            self.evmCap = evmCap
214            self.primaryReceipts <- {}
215            self.recoveryReceipts <- {}
216            self.farthestPrimaryTs = 0.0
217            self.farthestRecoveryTs = 0.0
218            self.totalRebalances = 0
219            self.totalSkips = 0
220        }
221
222        // ============================================
223        // BALANCE & PRICE FUNCTIONS
224        // ============================================
225
226        access(self) fun getTokenBalance(tokenHex: String, decimals: UInt8): UFix64 {
227            let coa = self.evmCap.borrow() ?? panic("Invalid COA")
228            let token = EVM.addressFromString(tokenHex)
229            
230            let calldata = EVM.encodeABIWithSignature("balanceOf(address)", [coa.address()])
231            let result = coa.call(
232                to: token,
233                data: calldata,
234                gasLimit: 100000,
235                value: EVM.Balance(attoflow: 0)
236            )
237            
238            if result.status != EVM.Status.successful {
239                return 0.0
240            }
241            
242            let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: result.data)
243            let balance = decoded[0] as! UInt256
244            
245            return RebalanceHandler.uint256ToUFix64(value: balance, decimals: decimals)
246        }
247
248        access(self) fun getUsdPrice(symbol: String): UFix64 {
249            let payment <- FlowToken.createEmptyVault(vaultType: Type<@FlowToken.Vault>())
250            let data = BandOracle.getReferenceData(
251                baseSymbol: symbol,
252                quoteSymbol: "USD",
253                payment: <- payment
254            )
255            return data.fixedPointRate
256        }
257
258        access(self) fun getTokenStates(): [TokenState] {
259            // First pass: calculate total portfolio value
260            var totalPortfolioUsd: UFix64 = 0.0
261            var rawData: [{String: AnyStruct}] = []
262            
263            for tokenConfig in self.config.tokens {
264                let balance = self.getTokenBalance(
265                    tokenHex: tokenConfig.tokenHex,
266                    decimals: tokenConfig.decimals
267                )
268                let usdPrice = self.getUsdPrice(symbol: tokenConfig.oracleSymbol)
269                let usdValue = balance * usdPrice
270                totalPortfolioUsd = totalPortfolioUsd + usdValue
271                
272                rawData.append({
273                    "tokenHex": tokenConfig.tokenHex,
274                    "symbol": tokenConfig.oracleSymbol,
275                    "balance": balance,
276                    "usdPrice": usdPrice,
277                    "usdValue": usdValue,
278                    "targetPercent": tokenConfig.targetPercent
279                })
280            }
281            
282            // Second pass: create states with percentage info
283            var states: [TokenState] = []
284            for data in rawData {
285                states.append(TokenState(
286                    tokenHex: data["tokenHex"]! as! String,
287                    symbol: data["symbol"]! as! String,
288                    balance: data["balance"]! as! UFix64,
289                    usdPrice: data["usdPrice"]! as! UFix64,
290                    usdValue: data["usdValue"]! as! UFix64,
291                    targetPercent: data["targetPercent"]! as! UFix64,
292                    totalPortfolioUsd: totalPortfolioUsd
293                ))
294            }
295            
296            return states
297        }
298
299        // ============================================
300        // WRAP NATIVE FLOW TO WFLOW
301        // ============================================
302
303        /// Wraps any native FLOW in the COA to WFLOW
304        /// This ensures we don't leave unwrapped FLOW sitting around
305        access(self) fun wrapNativeFlowIfNeeded(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount) {
306            let nativeBalance = coa.balance()
307            let nativeFlowAmount = nativeBalance.inFLOW()
308            
309            // Only wrap if we have more than 0.01 FLOW (to cover gas)
310            if nativeFlowAmount < 0.01 {
311                return
312            }
313            
314            // Keep 0.01 FLOW for gas, wrap the rest
315            let amountToWrap = nativeFlowAmount - 0.01
316            if amountToWrap < 0.001 {
317                return
318            }
319            
320            let wflow = EVM.addressFromString(self.config.wflowHex)
321            let depositCalldata = EVM.encodeABIWithSignature("deposit()", [] as [AnyStruct])
322            
323            // Create balance for the wrap amount
324            let wrapBalance = EVM.Balance(attoflow: 0)
325            wrapBalance.setFLOW(flow: amountToWrap)
326            
327            let result = coa.call(
328                to: wflow,
329                data: depositCalldata,
330                gasLimit: 100000,
331                value: wrapBalance
332            )
333            
334            if result.status == EVM.Status.successful {
335                log("RebalanceHandler: Wrapped ".concat(amountToWrap.toString()).concat(" native FLOW to WFLOW"))
336            }
337        }
338
339        // ============================================
340        // REBALANCE LOGIC (One-Pass Redistribution)
341        // ============================================
342        // 
343        // Algorithm: Modified "coin change" approach
344        // 1. Calculate deltaUSD = currentUSD - targetUSD for each token
345        // 2. Split into overweight (positive delta) and underweight (negative delta)
346        // 3. Two-pointer sweep: pair overweight with underweight, swap min(|delta1|, |delta2|)
347        // 4. Complexity: O(n log n) for sorting, O(n) swaps
348        //
349
350        access(self) fun analyzeAndRebalance() {
351            let coa = self.evmCap.borrow() ?? panic("Invalid COA")
352            
353            // First, wrap any native FLOW to WFLOW
354            self.wrapNativeFlowIfNeeded(coa: coa)
355            
356            let states = self.getTokenStates()
357            
358            // Edge case: empty portfolio
359            if states.length == 0 {
360                log("RebalanceHandler: No tokens configured, skipping")
361                self.totalSkips = self.totalSkips + 1
362                return
363            }
364            
365            // Calculate total portfolio value
366            var totalUSD: UFix64 = 0.0
367            for state in states {
368                totalUSD = totalUSD + state.usdValue
369            }
370            
371            if totalUSD < 0.01 {
372                log("RebalanceHandler: Portfolio too small ($".concat(totalUSD.toString()).concat("), skipping"))
373                self.totalSkips = self.totalSkips + 1
374                return
375            }
376            
377            // Build delta lists: overweight and underweight tokens
378            var overweight: [RebalanceHandler.TokenDelta] = []
379            var underweight: [RebalanceHandler.TokenDelta] = []
380            let toleranceBps = Int64(self.config.toleranceBps)
381            
382            for state in states {
383                let targetUSD = totalUSD * (state.targetPercent / 100.0)
384                
385                if state.usdValue > targetUSD {
386                    // Overweight: needs to sell
387                    let delta = state.usdValue - targetUSD
388                    let deviationBps = Int64((delta / totalUSD) * 10000.0)
389                    
390                    // Only include if exceeds tolerance
391                    if deviationBps > toleranceBps {
392                        overweight.append(RebalanceHandler.TokenDelta(
393                            tokenHex: state.tokenHex,
394                            symbol: state.symbol,
395                            deltaUSD: delta,
396                            isOverweight: true,
397                            balance: state.balance,
398                            usdPrice: state.usdPrice,
399                            decimals: self.getDecimals(tokenHex: state.tokenHex)
400                        ))
401                    }
402                } else {
403                    // Underweight: needs to buy
404                    let delta = targetUSD - state.usdValue
405                    let deviationBps = Int64((delta / totalUSD) * 10000.0)
406                    
407                    // Only include if exceeds tolerance
408                    if deviationBps > toleranceBps {
409                        underweight.append(RebalanceHandler.TokenDelta(
410                            tokenHex: state.tokenHex,
411                            symbol: state.symbol,
412                            deltaUSD: delta,
413                            isOverweight: false,
414                            balance: state.balance,
415                            usdPrice: state.usdPrice,
416                            decimals: self.getDecimals(tokenHex: state.tokenHex)
417                        ))
418                    }
419                }
420            }
421            
422            // If nothing to rebalance, skip
423            if overweight.length == 0 || underweight.length == 0 {
424                log("RebalanceHandler: Portfolio balanced within ".concat(toleranceBps.toString()).concat(" bps tolerance"))
425                self.totalSkips = self.totalSkips + 1
426                return
427            }
428            
429            log("RebalanceHandler: Rebalancing ".concat(overweight.length.toString())
430                .concat(" overweight -> ").concat(underweight.length.toString()).concat(" underweight tokens"))
431            
432            // Two-pointer sweep: pair overweight with underweight
433            var overIdx = 0
434            var underIdx = 0
435            var overRemaining = overweight[0].deltaUSD
436            var underRemaining = underweight[0].deltaUSD
437            var swapsExecuted = 0
438            
439            while overIdx < overweight.length && underIdx < underweight.length {
440                let fromToken = overweight[overIdx]
441                let toToken = underweight[underIdx]
442                
443                // Swap the minimum of what we need to move
444                let swapUSD = overRemaining < underRemaining ? overRemaining : underRemaining
445                
446                // Skip dust swaps (< $0.001)
447                if swapUSD < 0.001 {
448                    if overRemaining <= underRemaining {
449                        overIdx = overIdx + 1
450                        if overIdx < overweight.length {
451                            overRemaining = overweight[overIdx].deltaUSD
452                        }
453                    } else {
454                        underIdx = underIdx + 1
455                        if underIdx < underweight.length {
456                            underRemaining = underweight[underIdx].deltaUSD
457                        }
458                    }
459                    continue
460                }
461                
462                // Calculate token amount from USD
463                if fromToken.usdPrice < 0.00000001 {
464                    log("RebalanceHandler: Invalid price for ".concat(fromToken.symbol))
465                    overIdx = overIdx + 1
466                    if overIdx < overweight.length {
467                        overRemaining = overweight[overIdx].deltaUSD
468                    }
469                    continue
470                }
471                
472                var swapAmount = swapUSD / fromToken.usdPrice
473                
474                // Cap at 95% of available balance to avoid edge cases
475                let maxSwap = fromToken.balance * 0.95
476                if swapAmount > maxSwap {
477                    swapAmount = maxSwap
478                }
479                
480                // Execute swap
481                log("RebalanceHandler: Swap $".concat(swapUSD.toString()).concat(" worth of ")
482                    .concat(fromToken.symbol).concat(" (").concat(swapAmount.toString()).concat(") -> ")
483                    .concat(toToken.symbol))
484                
485                let success = self.executeSwap(
486                    coa: coa,
487                    fromToken: fromToken.tokenHex,
488                    toToken: toToken.tokenHex,
489                    amountIn: swapAmount,
490                    fromDecimals: fromToken.decimals,
491                    toDecimals: toToken.decimals
492                )
493                
494                if success {
495                    swapsExecuted = swapsExecuted + 1
496                }
497                
498                // Update remaining deltas
499                overRemaining = overRemaining - swapUSD
500                underRemaining = underRemaining - swapUSD
501                
502                // Move pointers when a bucket is emptied
503                if overRemaining < 0.001 {
504                    overIdx = overIdx + 1
505                    if overIdx < overweight.length {
506                        overRemaining = overweight[overIdx].deltaUSD
507                    }
508                }
509                if underRemaining < 0.001 {
510                    underIdx = underIdx + 1
511                    if underIdx < underweight.length {
512                        underRemaining = underweight[underIdx].deltaUSD
513                    }
514                }
515            }
516            
517            if swapsExecuted > 0 {
518                log("RebalanceHandler: Executed ".concat(swapsExecuted.toString()).concat(" swaps"))
519                self.totalRebalances = self.totalRebalances + 1
520            } else {
521                log("RebalanceHandler: No swaps executed")
522                self.totalSkips = self.totalSkips + 1
523            }
524        }
525
526        access(self) fun getDecimals(tokenHex: String): UInt8 {
527            for tokenConfig in self.config.tokens {
528                if tokenConfig.tokenHex.toLower() == tokenHex.toLower() {
529                    return tokenConfig.decimals
530                }
531            }
532            return 18  // Default
533        }
534
535        /// Execute swap using V3 with smart routing
536        /// Supports direct swaps and multi-hop through WETH or WFLOW
537        /// USDF routes via WFLOW on FlowSwap (same as PYUSD)
538        access(self) fun executeSwap(
539            coa: auth(EVM.Call) &EVM.CadenceOwnedAccount,
540            fromToken: String,
541            toToken: String,
542            amountIn: UFix64,
543            fromDecimals: UInt8,
544            toDecimals: UInt8
545        ): Bool {
546            let from = EVM.addressFromString(fromToken)
547            let router = EVM.addressFromString(self.config.routerHex)
548            
549            let amountInScaled = RebalanceHandler.ufix64ToUInt256(value: amountIn, decimals: fromDecimals)
550            
551            // Ensure allowance for V3 router
552            self.ensureAllowance(coa: coa, token: from, spender: router, amount: amountInScaled)
553            
554            // Determine routing path
555            let wethHex = RebalanceHandler.getWethHex()
556            let wbtcHex = RebalanceHandler.getWbtcHex()
557            let wflowHex = self.config.wflowHex
558            
559            let pyusdHex = "0x99aF3EeA856556646C98c8B9b2548Fe815240750"
560            let usdfHex = RebalanceHandler.getUsdfHex()
561            
562            let isFromWbtc = fromToken.toLower() == wbtcHex.toLower()
563            let isToWbtc = toToken.toLower() == wbtcHex.toLower()
564            let isFromWeth = fromToken.toLower() == wethHex.toLower()
565            let isToWeth = toToken.toLower() == wethHex.toLower()
566            let isFromWflow = fromToken.toLower() == wflowHex.toLower()
567            let isToWflow = toToken.toLower() == wflowHex.toLower()
568            let isFromPyusd = fromToken.toLower() == pyusdHex.toLower()
569            let isToPyusd = toToken.toLower() == pyusdHex.toLower()
570            let isFromUsdf = fromToken.toLower() == usdfHex.toLower()
571            let isToUsdf = toToken.toLower() == usdfHex.toLower()
572            
573            // Routing rules based on available V3 pools:
574            // - WETH ↔ WFLOW: direct
575            // - WBTC ↔ WETH: direct  
576            // - PYUSD ↔ WFLOW: direct
577            // - USDF ↔ WFLOW: direct
578            // - WBTC → WFLOW: via WETH
579            // - WETH → PYUSD/USDF: via WFLOW
580            // - PYUSD ↔ USDF: via WFLOW (stablecoin-to-stablecoin)
581            // - WBTC → PYUSD: via WETH then WFLOW
582            
583            var needsMultiHop = false
584            var intermediateHex = ""
585            
586            // Stablecoin-to-stablecoin: route via WFLOW
587            if (isFromPyusd && isToUsdf) || (isFromUsdf && isToPyusd) {
588                needsMultiHop = true
589                intermediateHex = wflowHex
590            } else if isFromWbtc && !isToWeth {
591                // WBTC → anything except WETH: route via WETH
592                needsMultiHop = true
593                intermediateHex = wethHex
594            } else if isToWbtc && !isFromWeth {
595                // anything → WBTC except WETH: route via WETH
596                needsMultiHop = true
597                intermediateHex = wethHex
598            } else if isFromWeth && !isToWflow && !isToWbtc {
599                // WETH → PYUSD or other: route via WFLOW
600                needsMultiHop = true
601                intermediateHex = wflowHex
602            } else if isToWeth && !isFromWflow && !isFromWbtc {
603                // PYUSD or other → WETH: route via WFLOW
604                needsMultiHop = true
605                intermediateHex = wflowHex
606            }
607            
608            if needsMultiHop {
609                return self.executeMultiHopSwap(
610                    coa: coa,
611                    fromToken: fromToken,
612                    intermediateToken: intermediateHex,
613                    toToken: toToken,
614                    amountInScaled: amountInScaled
615                )
616            } else {
617                return self.executeDirectSwap(
618                    coa: coa,
619                    fromToken: fromToken,
620                    toToken: toToken,
621                    amountInScaled: amountInScaled
622                )
623            }
624        }
625        
626        /// Special swap handling for USDF using PunchSwap V3 router
627        /// USDF only exists on PunchSwap (deprecated DEX)
628        access(self) fun executeUsdfSwap(
629            coa: auth(EVM.Call) &EVM.CadenceOwnedAccount,
630            fromToken: String,
631            toToken: String,
632            amountInScaled: UInt256,
633            isFromUsdf: Bool
634        ): Bool {
635            let punchRouter = EVM.addressFromString(RebalanceHandler.getPunchSwapRouterHex())
636            let from = EVM.addressFromString(fromToken)
637            let to = EVM.addressFromString(toToken)
638            let wflowHex = self.config.wflowHex
639            let wflow = EVM.addressFromString(wflowHex)
640            
641            // Ensure allowance for PunchSwap router
642            self.ensureAllowance(coa: coa, token: from, spender: punchRouter, amount: amountInScaled)
643            
644            let fee = UInt256(3000)
645            
646            // Check if this is a direct USDF ↔ WFLOW swap
647            let isFromWflow = fromToken.toLower() == wflowHex.toLower()
648            let isToWflow = toToken.toLower() == wflowHex.toLower()
649            
650            if isFromUsdf && isToWflow {
651                // USDF → WFLOW: direct on PunchSwap
652                log("RebalanceHandler: USDF → WFLOW via PunchSwap")
653                return self.executePunchSwapDirect(coa: coa, from: from, to: to, amountIn: amountInScaled, fee: fee)
654            } else if isFromWflow && !isFromUsdf {
655                // WFLOW → USDF: direct on PunchSwap
656                log("RebalanceHandler: WFLOW → USDF via PunchSwap")
657                return self.executePunchSwapDirect(coa: coa, from: from, to: to, amountIn: amountInScaled, fee: fee)
658            } else if isFromUsdf {
659                // USDF → other (not WFLOW): USDF → WFLOW on PunchSwap, then WFLOW → other on FlowSwap
660                log("RebalanceHandler: USDF → ".concat(toToken).concat(" via WFLOW (multi-router)"))
661                
662                // Step 1: USDF → WFLOW on PunchSwap
663                let step1Success = self.executePunchSwapDirect(coa: coa, from: from, to: wflow, amountIn: amountInScaled, fee: fee)
664                if !step1Success { return false }
665                
666                // Step 2: Get WFLOW balance and swap to target on FlowSwap
667                let wflowBalance = self.getRawTokenBalance(coa: coa, tokenHex: wflowHex)
668                if wflowBalance == 0 { return false }
669                
670                // Use 99% of WFLOW balance to avoid dust
671                let step2Amount = wflowBalance * 99 / 100
672                let flowSwapRouter = EVM.addressFromString(self.config.routerHex)
673                self.ensureAllowance(coa: coa, token: wflow, spender: flowSwapRouter, amount: step2Amount)
674                
675                return self.executeDirectSwapWithRouter(
676                    coa: coa,
677                    router: flowSwapRouter,
678                    from: wflow,
679                    to: to,
680                    amountIn: step2Amount,
681                    fee: fee
682                )
683            } else {
684                // other → USDF: other → WFLOW on FlowSwap, then WFLOW → USDF on PunchSwap
685                log("RebalanceHandler: ".concat(fromToken).concat(" → USDF via WFLOW (multi-router)"))
686                
687                // Step 1: other → WFLOW on FlowSwap
688                let flowSwapRouter = EVM.addressFromString(self.config.routerHex)
689                self.ensureAllowance(coa: coa, token: from, spender: flowSwapRouter, amount: amountInScaled)
690                
691                let step1Success = self.executeDirectSwapWithRouter(
692                    coa: coa,
693                    router: flowSwapRouter,
694                    from: from,
695                    to: wflow,
696                    amountIn: amountInScaled,
697                    fee: fee
698                )
699                if !step1Success { return false }
700                
701                // Step 2: WFLOW → USDF on PunchSwap
702                let wflowBalance = self.getRawTokenBalance(coa: coa, tokenHex: wflowHex)
703                if wflowBalance == 0 { return false }
704                
705                let step2Amount = wflowBalance * 99 / 100
706                self.ensureAllowance(coa: coa, token: wflow, spender: punchRouter, amount: step2Amount)
707                
708                let usdf = EVM.addressFromString(RebalanceHandler.getUsdfHex())
709                return self.executePunchSwapDirect(coa: coa, from: wflow, to: usdf, amountIn: step2Amount, fee: fee)
710            }
711        }
712        
713        /// Execute direct swap on PunchSwap V3
714        access(self) fun executePunchSwapDirect(
715            coa: auth(EVM.Call) &EVM.CadenceOwnedAccount,
716            from: EVM.EVMAddress,
717            to: EVM.EVMAddress,
718            amountIn: UInt256,
719            fee: UInt256
720        ): Bool {
721            let punchRouter = EVM.addressFromString(RebalanceHandler.getPunchSwapRouterHex())
722            
723            // PunchSwap uses standard Uniswap V3 exactInputSingle with deadline
724            // exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))
725            let deadline = UInt256(getCurrentBlock().timestamp) + 600  // 10 min deadline
726            
727            let calldata = EVM.encodeABIWithSignature(
728                "exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))",
729                [from, to, fee, coa.address(), deadline, amountIn, UInt256(0), UInt256(0)]
730            )
731            
732            let result = coa.call(to: punchRouter, data: calldata, gasLimit: self.config.evmGasLimit, value: EVM.Balance(attoflow: 0))
733            
734            if result.status != EVM.Status.successful {
735                log("RebalanceHandler: PunchSwap swap failed")
736                return false
737            }
738            
739            if result.data.length > 0 {
740                let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: result.data)
741                if decoded.length > 0 {
742                    let amountOut = decoded[0] as! UInt256
743                    log("RebalanceHandler: PunchSwap swap success, out: ".concat(amountOut.toString()))
744                }
745            }
746            return true
747        }
748        
749        /// Execute direct swap with a specific router
750        access(self) fun executeDirectSwapWithRouter(
751            coa: auth(EVM.Call) &EVM.CadenceOwnedAccount,
752            router: EVM.EVMAddress,
753            from: EVM.EVMAddress,
754            to: EVM.EVMAddress,
755            amountIn: UInt256,
756            fee: UInt256
757        ): Bool {
758            // IncrementFi/FlowSwap style (no deadline)
759            let calldata = EVM.encodeABIWithSignature(
760                "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))",
761                [from, to, fee, coa.address(), amountIn, UInt256(0), UInt256(0)]
762            )
763            
764            let result = coa.call(to: router, data: calldata, gasLimit: self.config.evmGasLimit, value: EVM.Balance(attoflow: 0))
765            
766            if result.status != EVM.Status.successful {
767                log("RebalanceHandler: Direct swap with router failed")
768                return false
769            }
770            
771            if result.data.length > 0 {
772                let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: result.data)
773                if decoded.length > 0 {
774                    let amountOut = decoded[0] as! UInt256
775                    log("RebalanceHandler: Direct swap success, out: ".concat(amountOut.toString()))
776                }
777            }
778            return true
779        }
780        
781        /// Get raw token balance (UInt256)
782        access(self) fun getRawTokenBalance(coa: auth(EVM.Call) &EVM.CadenceOwnedAccount, tokenHex: String): UInt256 {
783            let token = EVM.addressFromString(tokenHex)
784            let balanceCall = EVM.encodeABIWithSignature("balanceOf(address)", [coa.address()])
785            let result = coa.call(to: token, data: balanceCall, gasLimit: 100000, value: EVM.Balance(attoflow: 0))
786            
787            if result.status == EVM.Status.successful && result.data.length >= 32 {
788                let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: result.data)
789                return decoded[0] as! UInt256
790            }
791            return 0
792        }
793        
794        /// Direct V3 swap using exactInputSingle
795        access(self) fun executeDirectSwap(
796            coa: auth(EVM.Call) &EVM.CadenceOwnedAccount,
797            fromToken: String,
798            toToken: String,
799            amountInScaled: UInt256
800        ): Bool {
801            let router = EVM.addressFromString(self.config.routerHex)
802            let from = EVM.addressFromString(fromToken)
803            let to = EVM.addressFromString(toToken)
804            let fee = UInt256(3000)
805            
806            log("RebalanceHandler: Direct V3 Swap ".concat(fromToken).concat(" -> ").concat(toToken))
807            
808            let calldata = EVM.encodeABIWithSignature(
809                "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))",
810                [from, to, fee, coa.address(), amountInScaled, UInt256(0), UInt256(0)]
811            )
812            
813            let result = coa.call(to: router, data: calldata, gasLimit: self.config.evmGasLimit, value: EVM.Balance(attoflow: 0))
814            
815            if result.status != EVM.Status.successful {
816                log("RebalanceHandler: Direct swap failed")
817                return false
818            }
819            
820            if result.data.length > 0 {
821                let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: result.data)
822                if decoded.length > 0 {
823                    let amountOut = decoded[0] as! UInt256
824                    log("RebalanceHandler: Direct swap success, out: ".concat(amountOut.toString()))
825                }
826            }
827            return true
828        }
829        
830        /// Multi-hop swap using chained direct swaps (more reliable than exactInput)
831        access(self) fun executeMultiHopSwap(
832            coa: auth(EVM.Call) &EVM.CadenceOwnedAccount,
833            fromToken: String,
834            intermediateToken: String,
835            toToken: String,
836            amountInScaled: UInt256
837        ): Bool {
838            log("RebalanceHandler: Chained swap ".concat(fromToken).concat(" -> ").concat(intermediateToken).concat(" -> ").concat(toToken))
839            
840            // First hop: from -> intermediate
841            let router = EVM.addressFromString(self.config.routerHex)
842            let from = EVM.addressFromString(fromToken)
843            let intermediate = EVM.addressFromString(intermediateToken)
844            let toFinal = EVM.addressFromString(toToken)
845            let fee = UInt256(3000)
846            
847            // Ensure allowance for first hop
848            self.ensureAllowance(coa: coa, token: from, spender: router, amount: amountInScaled)
849            
850            // First swap
851            let calldata1 = EVM.encodeABIWithSignature(
852                "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))",
853                [from, intermediate, fee, coa.address(), amountInScaled, UInt256(0), UInt256(0)]
854            )
855            
856            let result1 = coa.call(to: router, data: calldata1, gasLimit: self.config.evmGasLimit, value: EVM.Balance(attoflow: 0))
857            
858            if result1.status != EVM.Status.successful {
859                log("RebalanceHandler: First hop failed")
860                return false
861            }
862            
863            // Get intermediate amount from result
864            var intermediateAmount: UInt256 = 0
865            if result1.data.length > 0 {
866                let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: result1.data)
867                if decoded.length > 0 {
868                    intermediateAmount = decoded[0] as! UInt256
869                    log("RebalanceHandler: First hop got: ".concat(intermediateAmount.toString()))
870                }
871            }
872            
873            if intermediateAmount == 0 {
874                log("RebalanceHandler: First hop returned 0")
875                return false
876            }
877            
878            // Ensure allowance for second hop
879            self.ensureAllowance(coa: coa, token: intermediate, spender: router, amount: intermediateAmount)
880            
881            // Second swap
882            let calldata2 = EVM.encodeABIWithSignature(
883                "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))",
884                [intermediate, toFinal, fee, coa.address(), intermediateAmount, UInt256(0), UInt256(0)]
885            )
886            
887            let result2 = coa.call(to: router, data: calldata2, gasLimit: self.config.evmGasLimit, value: EVM.Balance(attoflow: 0))
888            
889            if result2.status != EVM.Status.successful {
890                log("RebalanceHandler: Second hop failed")
891                return false
892            }
893            
894            if result2.data.length > 0 {
895                let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: result2.data)
896                if decoded.length > 0 {
897                    let finalAmount = decoded[0] as! UInt256
898                    log("RebalanceHandler: Chained swap success, final: ".concat(finalAmount.toString()))
899                }
900            }
901            
902            return true
903        }
904
905        access(self) fun ensureAllowance(
906            coa: auth(EVM.Call) &EVM.CadenceOwnedAccount,
907            token: EVM.EVMAddress,
908            spender: EVM.EVMAddress,
909            amount: UInt256
910        ) {
911            let allowanceCalldata = EVM.encodeABIWithSignature("allowance(address,address)", [coa.address(), spender])
912            let allowanceRes = coa.call(
913                to: token,
914                data: allowanceCalldata,
915                gasLimit: 100000,
916                value: EVM.Balance(attoflow: 0)
917            )
918            
919            if allowanceRes.status == EVM.Status.successful {
920                let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: allowanceRes.data)
921                let currentAllowance = decoded[0] as! UInt256
922                
923                if currentAllowance < amount {
924                    let maxApproval: UInt256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935
925                    let approveCalldata = EVM.encodeABIWithSignature("approve(address,uint256)", [spender, maxApproval])
926                    let _ = coa.call(
927                        to: token,
928                        data: approveCalldata,
929                        gasLimit: 100000,
930                        value: EVM.Balance(attoflow: 0)
931                    )
932                }
933            }
934        }
935
936        // ============================================
937        // SCHEDULED TRANSACTION INTERFACE
938        // ============================================
939
940        access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) {
941            // Clean up one dead primary per execution (distributes compute load)
942            self.cleanupOneDeadPrimary()
943            
944            let currentTs = getCurrentBlock().timestamp
945            let params = data as! PendingTxParams
946            
947            if params.isPrimary {
948                if let receipt <- self.primaryReceipts.remove(key: id) {
949                    destroy receipt
950                    self.analyzeAndRebalance()
951                    self.scheduleOnePrimaryIfNeeded(nowTs: currentTs, systemBehind: false)
952                }
953            } else {
954                if let receipt <- self.recoveryReceipts.remove(key: id) {
955                    destroy receipt
956                    let systemBehind = self.primaryReceipts.keys.length < 5
957                    self.scheduleOnePrimaryIfNeeded(nowTs: currentTs, systemBehind: systemBehind)
958                    self.scheduleRecoveryIfNeeded(nowTs: currentTs)
959                }
960            }
961        }
962        
963        /// Clean up one dead primary receipt per call
964        /// Called at start of both primary and recovery executions
965        access(self) fun cleanupOneDeadPrimary() {
966            for id in self.primaryReceipts.keys {
967                if FlowTransactionScheduler.getStatus(id: id) == nil {
968                    if let receipt <- self.primaryReceipts.remove(key: id) {
969                        destroy receipt
970                        log("RebalanceHandler: Cleaned up dead primary ".concat(id.toString()))
971                    }
972                    return  // Only clean up one per execution
973                }
974            }
975        }
976
977        // ============================================
978        // SCHEDULING FUNCTIONS (same pattern as OracleArbHandler)
979        // ============================================
980
981        access(self) fun scheduleOnePrimaryIfNeeded(nowTs: UFix64, systemBehind: Bool) {
982            let primaryCount = self.primaryReceipts.keys.length
983            if primaryCount >= 10 {
984                return
985            }
986            
987            let useMedium = systemBehind && primaryCount == 0
988            let priority = useMedium 
989                ? FlowTransactionScheduler.Priority.Medium 
990                : FlowTransactionScheduler.Priority.Low
991            let effort: UInt64 = 1000  // Reduced from 2000
992            
993            let baseTs = self.farthestPrimaryTs > nowTs ? self.farthestPrimaryTs : nowTs
994            let ts = baseTs + self.config.intervalSeconds
995            
996            self.scheduleTransaction(timestamp: ts, isPrimary: true, priority: priority, effort: effort)
997        }
998        
999        access(self) fun scheduleRecoveryIfNeeded(nowTs: UFix64) {
1000            if self.recoveryReceipts.keys.length > 0 {
1001                return
1002            }
1003            
1004            let recoveryInterval = self.config.intervalSeconds * 10.0
1005            let baseTs = self.farthestRecoveryTs > nowTs ? self.farthestRecoveryTs : nowTs
1006            let ts = baseTs + recoveryInterval
1007            
1008            self.scheduleTransaction(timestamp: ts, isPrimary: false, priority: FlowTransactionScheduler.Priority.Low, effort: 300)  // Recovery is just cleanup + scheduling
1009        }
1010
1011        access(self) fun scheduleTransaction(timestamp: UFix64, isPrimary: Bool, priority: FlowTransactionScheduler.Priority, effort: UInt64) {
1012            let vaultRef = self.vaultCap.borrow() ?? panic("Invalid vault")
1013            let txParams = PendingTxParams(isPrimary: isPrimary)
1014            
1015            var ts = timestamp
1016            var step: UFix64 = 1.0
1017            var attempts: UInt64 = 0
1018            let maxAttempts: UInt64 = 20
1019
1020            while attempts < maxAttempts {
1021                let est = FlowTransactionScheduler.estimate(
1022                    data: txParams,
1023                    timestamp: ts,
1024                    priority: priority,
1025                    executionEffort: effort
1026                )
1027                
1028                if est.timestamp != nil {
1029                    let fees <- vaultRef.withdraw(amount: est.flowFee ?? 0.0) as! @FlowToken.Vault
1030                    let receipt <- FlowTransactionScheduler.schedule(
1031                        handlerCap: self.handlerCap,
1032                        data: txParams,
1033                        timestamp: ts,
1034                        priority: priority,
1035                        executionEffort: effort,
1036                        fees: <-fees
1037                    )
1038                    
1039                    let txnId = receipt.id
1040                    
1041                    if isPrimary {
1042                        let old <- self.primaryReceipts.insert(key: txnId, <-receipt)
1043                        destroy old
1044                        if ts > self.farthestPrimaryTs {
1045                            self.farthestPrimaryTs = ts
1046                        }
1047                    } else {
1048                        let old <- self.recoveryReceipts.insert(key: txnId, <-receipt)
1049                        destroy old
1050                        if ts > self.farthestRecoveryTs {
1051                            self.farthestRecoveryTs = ts
1052                        }
1053                    }
1054                    
1055                    return
1056                }
1057                
1058                ts = ts + step
1059                step = step * 2.0
1060                attempts = attempts + 1
1061            }
1062        }
1063
1064        // ============================================
1065        // ADMIN FUNCTIONS
1066        // ============================================
1067
1068        access(Admin) fun executeNow() {
1069            self.analyzeAndRebalance()
1070        }
1071
1072        access(Admin) fun scheduleBatch(count: UInt64, escalateFirst: Bool) {
1073            let nowTs = getCurrentBlock().timestamp
1074            var i: UInt64 = 0
1075            
1076            while i < count && self.primaryReceipts.keys.length < 10 {
1077                let escalated = (i == 0 && escalateFirst)
1078                self.scheduleOnePrimaryIfNeeded(nowTs: nowTs, systemBehind: escalated)
1079                i = i + 1
1080            }
1081            
1082            self.scheduleRecoveryIfNeeded(nowTs: nowTs)
1083        }
1084
1085        access(Admin) fun setConfig(_ newConfig: Config) {
1086            self.config = newConfig
1087        }
1088
1089        // ============================================
1090        // VIEW FUNCTIONS
1091        // ============================================
1092
1093        access(all) view fun getConfig(): Config {
1094            return self.config
1095        }
1096
1097        access(all) fun getTokenStatesView(): [TokenState] {
1098            return self.getTokenStates()
1099        }
1100
1101        access(all) view fun getStats(): {String: UInt64} {
1102            return {
1103                "totalRebalances": self.totalRebalances,
1104                "totalSkips": self.totalSkips,
1105                "primaryCount": UInt64(self.primaryReceipts.keys.length),
1106                "recoveryCount": UInt64(self.recoveryReceipts.keys.length)
1107            }
1108        }
1109    }
1110
1111    // ============================================
1112    // CONTRACT FUNCTIONS
1113    // ============================================
1114
1115    access(all) fun createHandler(
1116        config: Config,
1117        vaultCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>,
1118        handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>,
1119        evmCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>
1120    ): @Handler {
1121        return <- create Handler(
1122            config: config,
1123            vaultCap: vaultCap,
1124            handlerCap: handlerCap,
1125            evmCap: evmCap
1126        )
1127    }
1128}
1129
1130