Smart Contract
RebalanceHandler
A.b9973a32e6c52813.RebalanceHandler
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