wayfinder-paths 0.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of wayfinder-paths might be problematic. Click here for more details.

Files changed (149) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +399 -0
  2. wayfinder_paths/__init__.py +22 -0
  3. wayfinder_paths/abis/generic/erc20.json +383 -0
  4. wayfinder_paths/adapters/__init__.py +0 -0
  5. wayfinder_paths/adapters/balance_adapter/README.md +94 -0
  6. wayfinder_paths/adapters/balance_adapter/adapter.py +238 -0
  7. wayfinder_paths/adapters/balance_adapter/examples.json +6 -0
  8. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  9. wayfinder_paths/adapters/balance_adapter/test_adapter.py +59 -0
  10. wayfinder_paths/adapters/brap_adapter/README.md +249 -0
  11. wayfinder_paths/adapters/brap_adapter/__init__.py +7 -0
  12. wayfinder_paths/adapters/brap_adapter/adapter.py +726 -0
  13. wayfinder_paths/adapters/brap_adapter/examples.json +175 -0
  14. wayfinder_paths/adapters/brap_adapter/manifest.yaml +11 -0
  15. wayfinder_paths/adapters/brap_adapter/test_adapter.py +286 -0
  16. wayfinder_paths/adapters/hyperlend_adapter/__init__.py +7 -0
  17. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +305 -0
  18. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +10 -0
  19. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +274 -0
  20. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
  21. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
  22. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
  23. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
  24. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
  25. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
  26. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
  27. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
  28. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
  29. wayfinder_paths/adapters/ledger_adapter/README.md +145 -0
  30. wayfinder_paths/adapters/ledger_adapter/__init__.py +7 -0
  31. wayfinder_paths/adapters/ledger_adapter/adapter.py +289 -0
  32. wayfinder_paths/adapters/ledger_adapter/examples.json +137 -0
  33. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +11 -0
  34. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +205 -0
  35. wayfinder_paths/adapters/pool_adapter/README.md +206 -0
  36. wayfinder_paths/adapters/pool_adapter/__init__.py +7 -0
  37. wayfinder_paths/adapters/pool_adapter/adapter.py +282 -0
  38. wayfinder_paths/adapters/pool_adapter/examples.json +143 -0
  39. wayfinder_paths/adapters/pool_adapter/manifest.yaml +10 -0
  40. wayfinder_paths/adapters/pool_adapter/test_adapter.py +220 -0
  41. wayfinder_paths/adapters/token_adapter/README.md +101 -0
  42. wayfinder_paths/adapters/token_adapter/__init__.py +3 -0
  43. wayfinder_paths/adapters/token_adapter/adapter.py +96 -0
  44. wayfinder_paths/adapters/token_adapter/examples.json +26 -0
  45. wayfinder_paths/adapters/token_adapter/manifest.yaml +6 -0
  46. wayfinder_paths/adapters/token_adapter/test_adapter.py +125 -0
  47. wayfinder_paths/config.example.json +22 -0
  48. wayfinder_paths/conftest.py +31 -0
  49. wayfinder_paths/core/__init__.py +18 -0
  50. wayfinder_paths/core/adapters/BaseAdapter.py +65 -0
  51. wayfinder_paths/core/adapters/__init__.py +5 -0
  52. wayfinder_paths/core/adapters/base.py +5 -0
  53. wayfinder_paths/core/adapters/models.py +46 -0
  54. wayfinder_paths/core/analytics/__init__.py +11 -0
  55. wayfinder_paths/core/analytics/bootstrap.py +57 -0
  56. wayfinder_paths/core/analytics/stats.py +48 -0
  57. wayfinder_paths/core/analytics/test_analytics.py +170 -0
  58. wayfinder_paths/core/clients/AuthClient.py +83 -0
  59. wayfinder_paths/core/clients/BRAPClient.py +109 -0
  60. wayfinder_paths/core/clients/ClientManager.py +210 -0
  61. wayfinder_paths/core/clients/HyperlendClient.py +192 -0
  62. wayfinder_paths/core/clients/LedgerClient.py +443 -0
  63. wayfinder_paths/core/clients/PoolClient.py +128 -0
  64. wayfinder_paths/core/clients/SimulationClient.py +192 -0
  65. wayfinder_paths/core/clients/TokenClient.py +89 -0
  66. wayfinder_paths/core/clients/TransactionClient.py +63 -0
  67. wayfinder_paths/core/clients/WalletClient.py +94 -0
  68. wayfinder_paths/core/clients/WayfinderClient.py +269 -0
  69. wayfinder_paths/core/clients/__init__.py +48 -0
  70. wayfinder_paths/core/clients/protocols.py +392 -0
  71. wayfinder_paths/core/clients/sdk_example.py +110 -0
  72. wayfinder_paths/core/config.py +458 -0
  73. wayfinder_paths/core/constants/__init__.py +26 -0
  74. wayfinder_paths/core/constants/base.py +42 -0
  75. wayfinder_paths/core/constants/erc20_abi.py +118 -0
  76. wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
  77. wayfinder_paths/core/engine/StrategyJob.py +188 -0
  78. wayfinder_paths/core/engine/__init__.py +5 -0
  79. wayfinder_paths/core/engine/manifest.py +97 -0
  80. wayfinder_paths/core/services/__init__.py +0 -0
  81. wayfinder_paths/core/services/base.py +179 -0
  82. wayfinder_paths/core/services/local_evm_txn.py +430 -0
  83. wayfinder_paths/core/services/local_token_txn.py +231 -0
  84. wayfinder_paths/core/services/web3_service.py +45 -0
  85. wayfinder_paths/core/settings.py +61 -0
  86. wayfinder_paths/core/strategies/Strategy.py +280 -0
  87. wayfinder_paths/core/strategies/__init__.py +5 -0
  88. wayfinder_paths/core/strategies/base.py +7 -0
  89. wayfinder_paths/core/strategies/descriptors.py +81 -0
  90. wayfinder_paths/core/utils/__init__.py +1 -0
  91. wayfinder_paths/core/utils/evm_helpers.py +206 -0
  92. wayfinder_paths/core/utils/wallets.py +77 -0
  93. wayfinder_paths/core/wallets/README.md +91 -0
  94. wayfinder_paths/core/wallets/WalletManager.py +56 -0
  95. wayfinder_paths/core/wallets/__init__.py +7 -0
  96. wayfinder_paths/policies/enso.py +17 -0
  97. wayfinder_paths/policies/erc20.py +34 -0
  98. wayfinder_paths/policies/evm.py +21 -0
  99. wayfinder_paths/policies/hyper_evm.py +19 -0
  100. wayfinder_paths/policies/hyperlend.py +12 -0
  101. wayfinder_paths/policies/hyperliquid.py +30 -0
  102. wayfinder_paths/policies/moonwell.py +54 -0
  103. wayfinder_paths/policies/prjx.py +30 -0
  104. wayfinder_paths/policies/util.py +27 -0
  105. wayfinder_paths/run_strategy.py +411 -0
  106. wayfinder_paths/scripts/__init__.py +0 -0
  107. wayfinder_paths/scripts/create_strategy.py +181 -0
  108. wayfinder_paths/scripts/make_wallets.py +169 -0
  109. wayfinder_paths/scripts/run_strategy.py +124 -0
  110. wayfinder_paths/scripts/validate_manifests.py +213 -0
  111. wayfinder_paths/strategies/__init__.py +0 -0
  112. wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
  113. wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
  114. wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
  115. wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
  116. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
  117. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
  118. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
  119. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
  120. wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
  121. wayfinder_paths/strategies/config.py +85 -0
  122. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +100 -0
  123. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +8 -0
  124. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
  125. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2270 -0
  126. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +352 -0
  127. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +96 -0
  128. wayfinder_paths/strategies/stablecoin_yield_strategy/examples.json +17 -0
  129. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
  130. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1810 -0
  131. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +520 -0
  132. wayfinder_paths/templates/adapter/README.md +105 -0
  133. wayfinder_paths/templates/adapter/adapter.py +26 -0
  134. wayfinder_paths/templates/adapter/examples.json +8 -0
  135. wayfinder_paths/templates/adapter/manifest.yaml +6 -0
  136. wayfinder_paths/templates/adapter/test_adapter.py +49 -0
  137. wayfinder_paths/templates/strategy/README.md +153 -0
  138. wayfinder_paths/templates/strategy/examples.json +11 -0
  139. wayfinder_paths/templates/strategy/manifest.yaml +8 -0
  140. wayfinder_paths/templates/strategy/strategy.py +57 -0
  141. wayfinder_paths/templates/strategy/test_strategy.py +197 -0
  142. wayfinder_paths/tests/__init__.py +0 -0
  143. wayfinder_paths/tests/test_smoke_manifest.py +48 -0
  144. wayfinder_paths/tests/test_test_coverage.py +212 -0
  145. wayfinder_paths/tests/test_utils.py +64 -0
  146. wayfinder_paths-0.1.7.dist-info/LICENSE +21 -0
  147. wayfinder_paths-0.1.7.dist-info/METADATA +777 -0
  148. wayfinder_paths-0.1.7.dist-info/RECORD +149 -0
  149. wayfinder_paths-0.1.7.dist-info/WHEEL +4 -0
@@ -0,0 +1,1810 @@
1
+ import asyncio
2
+ import math
3
+ import time
4
+ from datetime import UTC, datetime, timedelta
5
+ from typing import Any
6
+
7
+ from loguru import logger
8
+
9
+ from wayfinder_paths.adapters.balance_adapter.adapter import BalanceAdapter
10
+ from wayfinder_paths.adapters.brap_adapter.adapter import BRAPAdapter
11
+ from wayfinder_paths.adapters.ledger_adapter.adapter import LedgerAdapter
12
+ from wayfinder_paths.adapters.pool_adapter.adapter import PoolAdapter
13
+ from wayfinder_paths.adapters.token_adapter.adapter import TokenAdapter
14
+ from wayfinder_paths.core.constants.base import DEFAULT_SLIPPAGE
15
+ from wayfinder_paths.core.services.local_token_txn import (
16
+ LocalTokenTxnService,
17
+ )
18
+ from wayfinder_paths.core.services.web3_service import DefaultWeb3Service
19
+ from wayfinder_paths.core.strategies.descriptors import (
20
+ Complexity,
21
+ Directionality,
22
+ Frequency,
23
+ StratDescriptor,
24
+ TokenExposure,
25
+ Volatility,
26
+ )
27
+ from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
28
+ from wayfinder_paths.core.wallets.WalletManager import WalletManager
29
+
30
+
31
+ class StablecoinYieldStrategy(Strategy):
32
+ name = "Stablecoin Yield Strategy"
33
+
34
+ # Strategy parameters
35
+ MIN_AMOUNT_USDC = 2
36
+ MINIMUM_DAYS_UNTIL_PROFIT = 7
37
+ MIN_TVL = 1_000_000
38
+ DUST_APY = 0.01
39
+ MIN_GAS = 10e-4 # ethereum float
40
+ SEARCH_DEPTH = 10
41
+ SUPPORTED_NETWORK_CODES = {"base"}
42
+ ROTATION_MIN_INTERVAL = timedelta(days=14)
43
+ MINIMUM_APY_IMPROVEMENT = 0.01
44
+ GAS_MAXIMUM = 10e-4 # ethereum float
45
+ GAS_SAFETY_FRACTION = 1 / 3
46
+
47
+ INFO = StratDescriptor(
48
+ description=(
49
+ "An automated yield optimization strategy that maximizes returns on USDC deposits on Base.\n\n"
50
+ "What it does: Continuously scans and evaluates yield opportunities across Base-based DeFi protocols to find the "
51
+ "highest-yielding, low-risk positions for USDC. Automatically rebalances positions when better opportunities "
52
+ "emerge to maintain optimal yield generation.\n\n"
53
+ "Exposure type: Stable USD-denominated exposure with minimal impermanent loss risk. Focuses exclusively on USDC "
54
+ "and operations on the Base network to preserve capital and maximize yield.\n\n"
55
+ "Chains: Operates solely on the Base network.\n\n"
56
+ f"Deposit/Withdrawal: Accepts deposits only in USDC on Base with a minimum of {MIN_AMOUNT_USDC} USDC. Gas: Requires Base ETH "
57
+ "for gas fees during position entry, rebalancing, and exit (~0.001-0.02 ETH per rebalance cycle). Strategy automatically "
58
+ "deploys funds to an optimal yield farming position on Base. Withdrawals exit current positions and return USDC to the "
59
+ "user wallet.\n\n"
60
+ f"Risks: Primary risks include smart contract vulnerabilities in underlying Base DeFi protocols, temporary yield fluctuations, "
61
+ f"gas costs during rebalancing, and potential brief capital lock-up during protocol transitions. Strategy filters for a minimum TVL of ${MIN_TVL:,}."
62
+ ),
63
+ summary=(
64
+ "Automated stablecoin yield farming across DeFi protocols on Base. "
65
+ f"Continuously optimizes positions for maximum stable yield while avoiding impermanent loss. "
66
+ f"Min: {MIN_AMOUNT_USDC} USDC + ETH gas. Filters for ${MIN_TVL:,}+ TVL protocols."
67
+ ),
68
+ gas_token_symbol="ETH",
69
+ gas_token_id="ethereum-base",
70
+ deposit_token_id="usd-coin-base",
71
+ minimum_net_deposit=50,
72
+ gas_maximum=GAS_MAXIMUM,
73
+ # Anything below this level triggers a gas top-up
74
+ gas_threshold=GAS_MAXIMUM * GAS_SAFETY_FRACTION,
75
+ # risk indicators
76
+ volatility=Volatility.LOW,
77
+ volatility_description_short=(
78
+ "Capital sits in Base stablecoin lending pools, so price swings are minimal."
79
+ ),
80
+ directionality=Directionality.MARKET_NEUTRAL,
81
+ directionality_description=(
82
+ "Fully USD-denominated yield farming with no directional crypto beta."
83
+ ),
84
+ complexity=Complexity.LOW,
85
+ complexity_description="Agent handles optimal pool finding and rebalancing",
86
+ token_exposure=TokenExposure.STABLECOINS,
87
+ token_exposure_description=(
88
+ "Only Base USDC (and occasional stable swaps) with no volatile assets."
89
+ ),
90
+ frequency=Frequency.LOW,
91
+ frequency_description=(
92
+ "Updates every 2 hours; rebalances infrequent (bi-weekly cooldowns)."
93
+ ),
94
+ return_drivers=["pool yield"],
95
+ # config metadata for UIs/agents
96
+ config={
97
+ "deposit": {
98
+ "parameters": {
99
+ "main_token_amount": {
100
+ "type": "float",
101
+ "description": "amount of Base USDC (token id: usd-coin-base) to deposit",
102
+ },
103
+ "gas_token_amount": {
104
+ "type": "float",
105
+ "description": "amount of Base ETH (token id: ethereum-base) to deposit for gas fees",
106
+ "minimum": 0,
107
+ "maximum": GAS_MAXIMUM,
108
+ },
109
+ },
110
+ "process": "Deposits USDC on Base and searches for the highest yield opportunities among Base-based DeFi protocols",
111
+ "requirements": [
112
+ "Sufficient USDC balance on Base",
113
+ "Base ETH available for gas",
114
+ ],
115
+ "result": "Funds deployed to a yield farming position on Base",
116
+ },
117
+ "withdraw": {
118
+ "parameters": {},
119
+ "process": "Exits yield positions on Base and returns USDC to the user wallet",
120
+ "requirements": [
121
+ "Active positions to exit",
122
+ "Gas for transactions on Base",
123
+ ],
124
+ "result": "USDC returned to wallet and positions closed on Base",
125
+ },
126
+ "update": {
127
+ "parameters": {},
128
+ "process": "Scans for better yield opportunities on Base and rebalances positions automatically",
129
+ "frequency": "Call daily or when significant yield changes occur",
130
+ "requirements": [
131
+ "Active strategy positions on Base",
132
+ "Sufficient Base gas for rebalancing",
133
+ ],
134
+ "result": "Positions optimized for maximum yield on Base",
135
+ },
136
+ "technical_details": {
137
+ "wallet_structure": "Uses strategy subwallet for isolation",
138
+ "chains": ["Base"],
139
+ "protocols": ["Various Base DeFi yield protocols"],
140
+ "tokens": ["USDC"],
141
+ "gas_requirements": "~0.001-0.02 ETH per rebalance on Base",
142
+ "search_depth": SEARCH_DEPTH,
143
+ "minimum_tvl": MIN_TVL,
144
+ "dust_apy_threshold": DUST_APY,
145
+ "minimum_apy_edge": MINIMUM_APY_IMPROVEMENT,
146
+ "rotation_cooldown_days": ROTATION_MIN_INTERVAL.days,
147
+ "profit_horizon_days": MINIMUM_DAYS_UNTIL_PROFIT,
148
+ },
149
+ },
150
+ )
151
+
152
+ def __init__(
153
+ self,
154
+ config: dict[str, Any] | None = None,
155
+ *,
156
+ main_wallet: dict[str, Any] | None = None,
157
+ strategy_wallet: dict[str, Any] | None = None,
158
+ simulation: bool = False,
159
+ web3_service=None,
160
+ api_key: str | None = None,
161
+ ):
162
+ super().__init__(api_key=api_key)
163
+ merged_config: dict[str, Any] = dict(config or {})
164
+ if main_wallet is not None:
165
+ merged_config["main_wallet"] = main_wallet
166
+ if strategy_wallet is not None:
167
+ merged_config["strategy_wallet"] = strategy_wallet
168
+
169
+ self.config = merged_config
170
+ self.simulation = simulation
171
+ self.deposited_amount = 0
172
+ self.current_pool = None
173
+ self.current_apy = 0
174
+ self.balance_adapter = None
175
+ self.tx_adapter = None
176
+ self.web3_service = web3_service
177
+ self.token_adapter = None
178
+ self.ledger_adapter = None
179
+ self.pool_adapter = None
180
+ self.brap_adapter = None
181
+
182
+ # State tracking for deterministic token management
183
+ self.tracked_token_ids: set[str] = set() # All tokens strategy might hold
184
+ self.tracked_balances: dict[str, int] = {} # token_id -> balance in wei
185
+
186
+ try:
187
+ main_wallet_cfg = self.config.get("main_wallet")
188
+ strategy_wallet_cfg = self.config.get("strategy_wallet")
189
+
190
+ adapter_config = {
191
+ "main_wallet": main_wallet_cfg or None,
192
+ "strategy_wallet": strategy_wallet_cfg or None,
193
+ "strategy": self.config,
194
+ }
195
+
196
+ if self.web3_service is None:
197
+ wallet_provider = WalletManager.get_provider(adapter_config)
198
+ tx_adapter = LocalTokenTxnService(
199
+ adapter_config,
200
+ wallet_provider=wallet_provider,
201
+ simulation=self.simulation,
202
+ )
203
+ web3_service = DefaultWeb3Service(
204
+ wallet_provider=wallet_provider, evm_transactions=tx_adapter
205
+ )
206
+ else:
207
+ web3_service = self.web3_service
208
+ tx_adapter = web3_service.token_transactions
209
+ balance = BalanceAdapter(adapter_config, web3_service=web3_service)
210
+ token_adapter = TokenAdapter()
211
+ ledger_adapter = LedgerAdapter()
212
+ pool_adapter = PoolAdapter()
213
+ brap_adapter = BRAPAdapter(
214
+ web3_service=web3_service, simulation=self.simulation
215
+ )
216
+
217
+ self.register_adapters(
218
+ [
219
+ balance,
220
+ token_adapter,
221
+ ledger_adapter,
222
+ pool_adapter,
223
+ brap_adapter,
224
+ tx_adapter,
225
+ ]
226
+ )
227
+ self.balance_adapter = balance
228
+ self.tx_adapter = tx_adapter
229
+ self.web3_service = web3_service
230
+ self.token_adapter = token_adapter
231
+ self.ledger_adapter = ledger_adapter
232
+ self.pool_adapter = pool_adapter
233
+ self.brap_adapter = brap_adapter
234
+
235
+ except Exception:
236
+ pass
237
+
238
+ def _get_strategy_wallet_address(self) -> str:
239
+ """Get strategy wallet address with validation."""
240
+ strategy_wallet = self.config.get("strategy_wallet")
241
+ if not strategy_wallet or not isinstance(strategy_wallet, dict):
242
+ raise ValueError("strategy_wallet not configured in strategy config")
243
+ address = strategy_wallet.get("address")
244
+ if not address:
245
+ raise ValueError("strategy_wallet address not found in config")
246
+ return str(address)
247
+
248
+ def _get_main_wallet_address(self) -> str:
249
+ """Get main wallet address with validation."""
250
+ main_wallet = self.config.get("main_wallet")
251
+ if not main_wallet or not isinstance(main_wallet, dict):
252
+ raise ValueError("main_wallet not configured in strategy config")
253
+ address = main_wallet.get("address")
254
+ if not address:
255
+ raise ValueError("main_wallet address not found in config")
256
+ return str(address)
257
+
258
+ def _track_token(self, token_id: str, balance_wei: int = 0):
259
+ """Track a token that the strategy holds or might hold."""
260
+ if token_id:
261
+ self.tracked_token_ids.add(token_id)
262
+ if balance_wei > 0:
263
+ self.tracked_balances[token_id] = balance_wei
264
+
265
+ def _update_balance(self, token_id: str, balance_wei: int):
266
+ """Update the tracked balance for a token."""
267
+ if token_id:
268
+ self.tracked_balances[token_id] = balance_wei
269
+ if balance_wei > 0:
270
+ self.tracked_token_ids.add(token_id)
271
+
272
+ async def _refresh_tracked_balances(self):
273
+ """Refresh balances for all tracked tokens from on-chain data."""
274
+ strategy_address = self._get_strategy_wallet_address()
275
+ for token_id in self.tracked_token_ids:
276
+ try:
277
+ success, balance_wei = await self.balance_adapter.get_balance(
278
+ token_id=token_id,
279
+ wallet_address=strategy_address,
280
+ )
281
+ if success and balance_wei:
282
+ self.tracked_balances[token_id] = int(balance_wei)
283
+ else:
284
+ self.tracked_balances[token_id] = 0
285
+ except Exception as e:
286
+ logger.warning(f"Failed to refresh balance for {token_id}: {e}")
287
+ self.tracked_balances[token_id] = 0
288
+
289
+ def _get_non_zero_tracked_tokens(self) -> list[tuple[str, int]]:
290
+ """Get list of (token_id, balance_wei) for tokens with non-zero balances."""
291
+ return [
292
+ (token_id, balance)
293
+ for token_id, balance in self.tracked_balances.items()
294
+ if balance > 0
295
+ ]
296
+
297
+ async def setup(self):
298
+ logger.info("Starting StablecoinYieldStrategy setup")
299
+ start_time = time.time()
300
+
301
+ await super().setup()
302
+ self.current_combined_apy_pct = 0.0
303
+
304
+ # Get strategy net deposit
305
+ try:
306
+ logger.info("Fetching strategy net deposit from ledger")
307
+ strategy_address = self._get_strategy_wallet_address()
308
+ success, deposit_data = await self.ledger_adapter.get_strategy_net_deposit(
309
+ wallet_address=strategy_address,
310
+ )
311
+ if success:
312
+ self.DEPOSIT_USDC = deposit_data.get("net_deposit", 0)
313
+ logger.info(f"Strategy net deposit: {self.DEPOSIT_USDC} USDC")
314
+ else:
315
+ logger.error(f"Failed to fetch strategy net deposit: {deposit_data}")
316
+ self.DEPOSIT_USDC = 0
317
+ except Exception as e:
318
+ logger.error(f"Failed to fetch strategy net deposit: {e}")
319
+ self.DEPOSIT_USDC = 0
320
+
321
+ # Get USDC token info
322
+ try:
323
+ logger.info("Fetching USDC token information")
324
+ success, self.usdc_token_info = await self.token_adapter.get_token(
325
+ "usd-coin-base"
326
+ )
327
+ if not success:
328
+ logger.warning("Failed to fetch USDC token info, using empty dict")
329
+ self.usdc_token_info = {}
330
+ else:
331
+ logger.info(
332
+ f"USDC token info loaded: {self.usdc_token_info.get('symbol', 'Unknown')} on {self.usdc_token_info.get('chain', {}).get('name', 'Unknown')}"
333
+ )
334
+ except Exception as e:
335
+ logger.error(f"Error fetching USDC token info: {e}")
336
+ self.usdc_token_info = {}
337
+
338
+ # Always track USDC as baseline token
339
+ if self.usdc_token_info.get("token_id"):
340
+ self._track_token(self.usdc_token_info.get("token_id"))
341
+
342
+ self.current_pool = {
343
+ "token_id": self.usdc_token_info.get("token_id"),
344
+ "name": self.usdc_token_info.get("name"),
345
+ "symbol": self.usdc_token_info.get("symbol"),
346
+ "decimals": self.usdc_token_info.get("decimals", 18),
347
+ "address": self.usdc_token_info.get("address"),
348
+ "chain": self.usdc_token_info.get("chain", {"code": "base", "id": 8453}),
349
+ }
350
+
351
+ self.current_pool_data = None
352
+
353
+ chain_code = "base" # Default to base
354
+ if self.current_pool and self.current_pool.get("chain"):
355
+ chain_code = self.current_pool.get("chain").get("code", "base")
356
+
357
+ # Get gas token info
358
+ try:
359
+ logger.info(f"Fetching gas token for chain: {chain_code}")
360
+ success, gas_token_data = await self.token_adapter.get_gas_token(chain_code)
361
+ if success:
362
+ self.gas_token = gas_token_data
363
+ logger.info(
364
+ f"Gas token loaded: {gas_token_data.get('symbol', 'Unknown')}"
365
+ )
366
+ # Track gas token (but don't count it as a strategy asset)
367
+ if self.gas_token.get("id"):
368
+ self._track_token(self.gas_token.get("id"))
369
+ else:
370
+ logger.warning("Failed to fetch gas token info, using empty dict")
371
+ self.gas_token = {}
372
+ except Exception as e:
373
+ logger.error(f"Error fetching gas token info: {e}")
374
+ self.gas_token = {}
375
+
376
+ if not self.DEPOSIT_USDC:
377
+ logger.info("No deposits found, setting current pool balance to 0")
378
+ self.current_pool_balance = 0
379
+ return
380
+
381
+ # Get strategy transactions to determine current position and build tracked token set
382
+ try:
383
+ logger.info("Fetching strategy transaction history to build state")
384
+ success, txns_data = await self.ledger_adapter.get_strategy_transactions(
385
+ wallet_address=self._get_strategy_wallet_address(),
386
+ )
387
+ if success:
388
+ txns = [
389
+ txn
390
+ for txn in txns_data.get("transactions", [])
391
+ if txn.get("operation") != "DEPOSIT"
392
+ ]
393
+ logger.info(f"Found {len(txns)} non-deposit transactions")
394
+
395
+ # Build tracked token set from transaction history
396
+ for txn in txns:
397
+ op_data = txn.get("data", {}).get("op_data", {})
398
+ # Track any token that was swapped TO
399
+ if op_data.get("to_token_id"):
400
+ self._track_token(op_data.get("to_token_id"))
401
+ # Track any token that was swapped FROM
402
+ if op_data.get("from_token_id"):
403
+ self._track_token(op_data.get("from_token_id"))
404
+
405
+ logger.info(
406
+ f"Tracking {len(self.tracked_token_ids)} tokens from history"
407
+ )
408
+ else:
409
+ logger.error(f"Failed to fetch strategy transactions: {txns_data}")
410
+ txns = []
411
+ except Exception as e:
412
+ logger.error(f"Failed to fetch strategy transactions: {e}")
413
+ txns = []
414
+
415
+ if txns and txns[-1].get("operation") != "WITHDRAW":
416
+ pos = txns[-1].get("data").get("op_data")
417
+ success, token_info = await self.token_adapter.get_token(
418
+ pos.get("to_token_id")
419
+ )
420
+ if not success:
421
+ token_info = {}
422
+ self.current_pool = {
423
+ "token_id": token_info.get("token_id"),
424
+ "name": token_info.get("name"),
425
+ "symbol": token_info.get("symbol"),
426
+ "decimals": token_info.get("decimals"),
427
+ "address": token_info.get("address"),
428
+ "chain": token_info.get("chain"),
429
+ }
430
+ # Track the current pool token
431
+ if token_info.get("token_id"):
432
+ self._track_token(token_info.get("token_id"))
433
+
434
+ success, reports = await self.pool_adapter.get_pools_by_ids(
435
+ pool_ids=[self.current_pool.get("token_id")],
436
+ merge_external=False,
437
+ )
438
+ if success and reports.get("pools"):
439
+ self.current_pool_data = reports.get("pools", [])[0]
440
+
441
+ identifiers = []
442
+ pool_id = self.current_pool.get("token_id", None)
443
+ if isinstance(pool_id, str):
444
+ identifiers.append(pool_id)
445
+
446
+ pool_address = self.current_pool.get("address", None)
447
+ pool_chain = self.current_pool.get("chain", None)
448
+ chain_code = ((pool_chain or {}).get("code")) or None
449
+ if isinstance(pool_address, str) and isinstance(chain_code, str):
450
+ identifiers.append(f"{chain_code.lower()}_{pool_address.lower()}")
451
+
452
+ llama_report = None
453
+ if identifiers:
454
+ success, llama_reports = await self.pool_adapter.get_llama_reports(
455
+ identifiers=identifiers
456
+ )
457
+ if success:
458
+ for identifier in identifiers:
459
+ if not isinstance(identifier, str):
460
+ continue
461
+ report = llama_reports.get(identifier.lower(), None)
462
+ if report:
463
+ llama_report = report
464
+ break
465
+
466
+ if self.current_pool_data is None and llama_report:
467
+ self.current_pool_data = {
468
+ **self.current_pool_data,
469
+ "llama_report": llama_report,
470
+ }
471
+
472
+ if llama_report and llama_report.get("llama_combined_apy_pct") is not None:
473
+ self.current_combined_apy_pct = (
474
+ llama_report.get("llama_combined_apy_pct", 0) / 100
475
+ )
476
+ elif llama_report and llama_report.get("llama_apy_pct") is not None:
477
+ self.current_combined_apy_pct = llama_report.get("llama_apy_pct", 0) / 100
478
+ elif self.current_pool_data:
479
+ self.current_combined_apy_pct = self.current_pool_data.get("apy", 0)
480
+
481
+ pool_address = self.current_pool.get("address")
482
+ chain_id = self.current_pool.get("chain", {}).get("id")
483
+ user_address = self._get_strategy_wallet_address()
484
+ if (
485
+ pool_address
486
+ and chain_id
487
+ and user_address
488
+ and pool_address != self.usdc_token_info.get("address")
489
+ ):
490
+ try:
491
+ (
492
+ success,
493
+ current_pool_balance_raw,
494
+ ) = await self.balance_adapter.get_pool_balance(
495
+ pool_address=pool_address,
496
+ chain_id=chain_id,
497
+ user_address=user_address,
498
+ )
499
+ self.current_pool_balance = current_pool_balance_raw if success else 0
500
+ except Exception as e:
501
+ print(f"Warning: Failed to get pool balance: {e}")
502
+ self.current_pool_balance = 0
503
+ else:
504
+ self.current_pool_balance = 0
505
+
506
+ baseline_token = (
507
+ self.usdc_token_info
508
+ if self.usdc_token_info.get("chain", {}).get("id")
509
+ == self.current_pool.get("chain").get("id")
510
+ else None
511
+ )
512
+ # Refresh all tracked balances from blockchain
513
+ await self._refresh_tracked_balances()
514
+ logger.info(
515
+ f"Refreshed balances for {len(self.tracked_balances)} tracked tokens"
516
+ )
517
+
518
+ if (
519
+ baseline_token
520
+ and self.current_pool.get("token_id") != baseline_token.get("token_id")
521
+ and self.current_pool_balance
522
+ ):
523
+ return
524
+
525
+ # Fallback: Try to infer active pool from tracked tokens with balances
526
+ inferred = await self._infer_active_pool_from_tracked_tokens()
527
+ if inferred is not None:
528
+ inferred_token, inferred_balance, inferred_entry = inferred
529
+ self.current_pool = inferred_token
530
+ self.current_pool_balance = inferred_balance
531
+ if inferred_entry:
532
+ self.current_pool_data = inferred_entry
533
+ llama_combined = inferred_entry.get("llama_combined_apy_pct")
534
+ llama_apy = inferred_entry.get("llama_apy_pct")
535
+ if llama_combined is not None:
536
+ self.current_combined_apy_pct = float(llama_combined) / 100
537
+ elif llama_apy is not None:
538
+ self.current_combined_apy_pct = float(llama_apy) / 100
539
+ return
540
+
541
+ if self.usdc_token_info:
542
+ status, raw_balance = await self.balance_adapter.get_balance(
543
+ token_id=self.usdc_token_info.get("token_id"),
544
+ wallet_address=self._get_strategy_wallet_address(),
545
+ )
546
+ if not status or not raw_balance:
547
+ return
548
+ try:
549
+ balance_wei = int(raw_balance)
550
+ except (TypeError, ValueError):
551
+ return
552
+ if balance_wei <= 0:
553
+ return
554
+
555
+ self.current_pool = self.usdc_token_info
556
+ self.current_pool_balance = balance_wei
557
+ self.current_combined_apy_pct = 0.0
558
+ self.current_pool_data = None
559
+
560
+ return
561
+
562
+ elapsed_time = time.time() - start_time
563
+ logger.info(
564
+ f"StablecoinYieldStrategy setup completed in {elapsed_time:.2f} seconds"
565
+ )
566
+
567
+ def _sum_non_gas_balance_usd(self, balances: list[dict[str, Any]] | None) -> float:
568
+ total_usd = 0.0
569
+ for bal in balances or []:
570
+ if self._is_gas_balance_entry(bal):
571
+ continue
572
+ usd_value = bal.get("balanceUSD")
573
+ try:
574
+ total_usd += float(usd_value or 0.0)
575
+ except (TypeError, ValueError):
576
+ continue
577
+ return total_usd
578
+
579
+ async def _infer_active_pool_from_tracked_tokens(self):
580
+ """Infer the active pool from tracked tokens with non-zero balances."""
581
+ try:
582
+ # Refresh balances for tracked tokens
583
+ await self._refresh_tracked_balances()
584
+
585
+ usdc_token_id = self.usdc_token_info.get("token_id")
586
+ gas_token_id = self.gas_token.get("id") if self.gas_token else None
587
+
588
+ best_token_id = None
589
+ best_balance_wei = 0
590
+
591
+ # Find the non-gas, non-USDC token with the largest balance
592
+ for token_id, balance_wei in self.tracked_balances.items():
593
+ if balance_wei <= 0:
594
+ continue
595
+ if token_id == gas_token_id:
596
+ continue
597
+ if token_id == usdc_token_id:
598
+ continue
599
+
600
+ # Prefer tokens with larger balances
601
+ if balance_wei > best_balance_wei:
602
+ best_token_id = token_id
603
+ best_balance_wei = balance_wei
604
+
605
+ if not best_token_id:
606
+ return None
607
+
608
+ # Fetch token info
609
+ success, token = await self.token_adapter.get_token(best_token_id)
610
+ if not success:
611
+ return None
612
+
613
+ # Get fresh on-chain balance
614
+ strategy_address = self._get_strategy_wallet_address()
615
+ try:
616
+ success, onchain_balance = await self.balance_adapter.get_balance(
617
+ token_id=token.get("token_id"),
618
+ wallet_address=strategy_address,
619
+ )
620
+ if success and onchain_balance:
621
+ best_balance_wei = int(onchain_balance)
622
+ except Exception:
623
+ pass
624
+
625
+ logger.info(
626
+ f"Inferred active pool: {token.get('symbol')} with balance {best_balance_wei}"
627
+ )
628
+ return token, best_balance_wei, None
629
+
630
+ except Exception as e:
631
+ logger.error(f"Failed to infer active pool from tracked tokens: {e}")
632
+ return None
633
+
634
+ def _is_gas_balance_entry(self, balance: dict[str, Any]) -> bool:
635
+ """Check if a balance entry represents a gas token."""
636
+ if not self.gas_token:
637
+ return False
638
+
639
+ # Check by token ID
640
+ token_id = balance.get("token_id")
641
+ if (
642
+ isinstance(token_id, str)
643
+ and token_id.lower() == self.gas_token.get("id", "").lower()
644
+ ):
645
+ return True
646
+
647
+ # Check by token address and network
648
+ token_address = balance.get("tokenAddress")
649
+ if isinstance(token_address, str):
650
+ if token_address.lower() == self.gas_token.get("address", "").lower():
651
+ return True
652
+
653
+ # Check address + network combination
654
+ network = (balance.get("network") or "").lower()
655
+ chain_code = self.current_pool.get("chain", {}).get("code", "").lower()
656
+ if (
657
+ token_address.lower() == self.gas_token.get("address", "").lower()
658
+ and network == chain_code
659
+ ):
660
+ return True
661
+
662
+ return False
663
+
664
+ async def deposit(
665
+ self, main_token_amount: float = 0.0, gas_token_amount: float = 0.0
666
+ ) -> StatusTuple:
667
+ if main_token_amount == 0.0 and gas_token_amount == 0.0:
668
+ return (
669
+ False,
670
+ "Either main_token_amount or gas_token_amount must be provided",
671
+ )
672
+
673
+ logger.info(
674
+ f"Starting deposit process for {main_token_amount} USDC and {gas_token_amount} gas"
675
+ )
676
+ start_time = time.time()
677
+
678
+ try:
679
+ token_info = self.usdc_token_info
680
+ self.current_pool = {
681
+ "token_id": token_info.get("token_id"),
682
+ "name": token_info.get("name"),
683
+ "symbol": token_info.get("symbol"),
684
+ "decimals": token_info.get("decimals"),
685
+ "address": token_info.get("address"),
686
+ "chain": token_info.get("chain"),
687
+ }
688
+ gas_token_id = self.gas_token.get("id")
689
+ logger.info(
690
+ f"Current pool set to: {token_info.get('symbol')} on {token_info.get('chain', {}).get('name')}"
691
+ )
692
+
693
+ # Check main wallet USDC balance if depositing main token
694
+ if main_token_amount > 0:
695
+ logger.info("Checking main wallet USDC balance")
696
+ (
697
+ main_usdc_status,
698
+ main_usdc_balance,
699
+ ) = await self.balance_adapter.get_balance(
700
+ token_id=token_info.get("token_id"),
701
+ wallet_address=self._get_main_wallet_address(),
702
+ )
703
+ if main_usdc_status and main_usdc_balance is not None:
704
+ try:
705
+ available_main_usdc = float(main_usdc_balance) / (
706
+ 10 ** self.current_pool.get("decimals")
707
+ )
708
+ logger.info(f"Main wallet USDC balance: {available_main_usdc}")
709
+ if available_main_usdc >= 0:
710
+ main_token_amount = min(
711
+ main_token_amount, available_main_usdc
712
+ )
713
+ logger.info(
714
+ f"Adjusted deposit amount to available balance: {main_token_amount}"
715
+ )
716
+ except Exception as e:
717
+ logger.warning(f"Error processing main wallet balance: {e}")
718
+ else:
719
+ logger.warning("Could not fetch main wallet USDC balance")
720
+
721
+ if main_token_amount < self.MIN_AMOUNT_USDC:
722
+ logger.warning(
723
+ f"Deposit amount {main_token_amount} below minimum {self.MIN_AMOUNT_USDC}"
724
+ )
725
+ return (
726
+ False,
727
+ f"Minimum deposit is {self.MIN_AMOUNT_USDC} USDC on Base. Received: {main_token_amount}",
728
+ )
729
+
730
+ # Check gas token amount if provided
731
+ if gas_token_amount > 0:
732
+ if gas_token_amount > self.GAS_MAXIMUM:
733
+ return (
734
+ False,
735
+ f"Gas token amount exceeds maximum configured gas buffer: {self.GAS_MAXIMUM}",
736
+ )
737
+
738
+ logger.info("Checking main wallet gas token balance")
739
+ gas_decimals = self.gas_token.get("decimals")
740
+ gas_symbol = self.gas_token.get("symbol")
741
+ (
742
+ _,
743
+ main_gas_raw,
744
+ ) = await self.balance_adapter.get_balance(
745
+ token_id=gas_token_id,
746
+ wallet_address=self._get_main_wallet_address(),
747
+ )
748
+ main_gas_int = (
749
+ int(main_gas_raw)
750
+ if isinstance(main_gas_raw, int)
751
+ else int(float(main_gas_raw or 0))
752
+ )
753
+ main_gas_native = float(main_gas_int) / (10**gas_decimals)
754
+
755
+ if main_gas_native < gas_token_amount:
756
+ return (
757
+ False,
758
+ f"Main wallet {gas_symbol} balance is less than the deposit amount: {main_gas_native} < {gas_token_amount}",
759
+ )
760
+
761
+ # Check gas balances for minimum requirement (only if depositing main token)
762
+ if main_token_amount > 0:
763
+ logger.info("Checking gas token balances for operations")
764
+ gas_decimals = self.gas_token.get("decimals")
765
+ gas_symbol = self.gas_token.get("symbol")
766
+ (
767
+ _,
768
+ main_gas_raw,
769
+ ) = await self.balance_adapter.get_balance(
770
+ token_id=gas_token_id,
771
+ wallet_address=self._get_main_wallet_address(),
772
+ )
773
+ (
774
+ _,
775
+ strategy_gas_raw,
776
+ ) = await self.balance_adapter.get_balance(
777
+ token_id=gas_token_id,
778
+ wallet_address=self._get_strategy_wallet_address(),
779
+ )
780
+ main_gas_int = (
781
+ int(main_gas_raw)
782
+ if isinstance(main_gas_raw, int)
783
+ else int(float(main_gas_raw or 0))
784
+ )
785
+ strategy_gas_int = (
786
+ int(strategy_gas_raw)
787
+ if isinstance(strategy_gas_raw, int)
788
+ else int(float(strategy_gas_raw or 0))
789
+ )
790
+ main_gas_native = float(main_gas_int) / (10**gas_decimals)
791
+ strategy_gas_native = float(strategy_gas_int) / (10**gas_decimals)
792
+ total_gas = main_gas_native + strategy_gas_native
793
+ logger.info(
794
+ f"Gas balances - Main: {main_gas_native} {gas_symbol}, Strategy: {strategy_gas_native} {gas_symbol}, Total: {total_gas} {gas_symbol}"
795
+ )
796
+
797
+ # Use provided gas_token_amount if available, otherwise ensure minimum
798
+ required_gas = (
799
+ gas_token_amount if gas_token_amount > 0 else self.MIN_GAS
800
+ )
801
+ if total_gas < required_gas:
802
+ logger.warning(
803
+ f"Insufficient gas: {total_gas} < {required_gas} {gas_symbol}"
804
+ )
805
+ return (
806
+ False,
807
+ f"Need at least {required_gas} {gas_symbol} on Base for gas. You have: {total_gas}",
808
+ )
809
+
810
+ # Transfer main token if provided
811
+ if main_token_amount > 0:
812
+ self.current_pool_balance = int(
813
+ main_token_amount * (10 ** self.current_pool.get("decimals"))
814
+ )
815
+ self.DEPOSIT_USDC = main_token_amount
816
+ logger.info(f"Set deposit amount to {main_token_amount} USDC")
817
+
818
+ # Transfer USDC from main to strategy wallet
819
+ logger.info("Initiating USDC transfer from main to strategy wallet")
820
+ (
821
+ success,
822
+ msg,
823
+ ) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
824
+ self.usdc_token_info.get("token_id"),
825
+ main_token_amount,
826
+ strategy_name=self.name,
827
+ )
828
+ if not success:
829
+ logger.error(f"USDC transfer failed: {msg}")
830
+ return (False, f"USDC transfer to strategy failed: {msg}")
831
+ logger.info("USDC transfer completed successfully")
832
+
833
+ # Update tracked state
834
+ self._track_token(self.usdc_token_info.get("token_id"))
835
+ self._update_balance(
836
+ self.usdc_token_info.get("token_id"),
837
+ int(main_token_amount * (10 ** self.current_pool.get("decimals"))),
838
+ )
839
+
840
+ # Transfer gas if provided or if strategy needs top-up
841
+ if gas_token_amount > 0:
842
+ # Get gas symbol if not already defined
843
+ if main_token_amount == 0:
844
+ gas_symbol = self.gas_token.get("symbol")
845
+
846
+ # Transfer the specified gas amount
847
+ logger.info(
848
+ f"Transferring {gas_token_amount} {gas_symbol} from main wallet to strategy"
849
+ )
850
+ (
851
+ success,
852
+ msg,
853
+ ) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
854
+ gas_token_id, gas_token_amount, strategy_name=self.name
855
+ )
856
+ if not success:
857
+ logger.error(f"Gas transfer failed: {msg}")
858
+ return (False, f"Gas transfer to strategy failed: {msg}")
859
+ logger.info("Gas transfer completed successfully")
860
+ elif main_token_amount > 0 and strategy_gas_native < self.MIN_GAS:
861
+ # Auto-top-up to minimum if no gas amount specified and depositing main token
862
+ top_up_amount = self.MIN_GAS - strategy_gas_native
863
+ logger.info(
864
+ f"Strategy gas insufficient, transferring {top_up_amount} {gas_symbol} from main wallet"
865
+ )
866
+ (
867
+ success,
868
+ msg,
869
+ ) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
870
+ gas_token_id, top_up_amount, strategy_name=self.name
871
+ )
872
+ if not success:
873
+ logger.error(f"Gas transfer failed: {msg}")
874
+ return (False, f"Gas transfer to strategy failed: {msg}")
875
+ logger.info("Gas transfer completed successfully")
876
+
877
+ elapsed_time = time.time() - start_time
878
+ logger.info(f"Deposit completed successfully in {elapsed_time:.2f} seconds")
879
+ return (
880
+ True,
881
+ "Deposit successful! Call update to open a position and start earning",
882
+ )
883
+ except Exception as e:
884
+ logger.error(f"Deposit process failed: {e}")
885
+ return (False, f"Deposit error: {e}")
886
+
887
+ async def withdraw(self, amount: float | None = None) -> StatusTuple:
888
+ logger.info(f"Starting withdrawal process for amount: {amount}")
889
+ start_time = time.time()
890
+
891
+ if not self.DEPOSIT_USDC:
892
+ logger.warning("No deposits found, nothing to withdraw")
893
+ return (
894
+ False,
895
+ "Nothing to withdraw from strategy, wallet should be empty already. If not, an error has happened please manually remove funds",
896
+ )
897
+ # Get current pool balance
898
+ logger.info("Fetching current pool balance")
899
+ try:
900
+ (
901
+ _,
902
+ self.current_pool_balance,
903
+ ) = await self.balance_adapter.get_pool_balance(
904
+ pool_address=self.current_pool.get("address"),
905
+ chain_id=self.current_pool.get("chain").get("id"),
906
+ user_address=self._get_strategy_wallet_address(),
907
+ )
908
+ logger.info(f"Current pool balance: {self.current_pool_balance}")
909
+ except Exception as e:
910
+ logger.error(f"Failed to fetch pool balance: {e}")
911
+ self.current_pool_balance = 0
912
+
913
+ # Check if we need to swap out of current position
914
+ if (
915
+ self.current_pool.get("token_id") != self.usdc_token_info.get("token_id")
916
+ and self.current_pool_balance
917
+ ):
918
+ logger.info(
919
+ f"Need to swap from {self.current_pool.get('symbol')} to USDC before withdrawal"
920
+ )
921
+ quotes = {}
922
+ for attempt in range(4):
923
+ logger.info(
924
+ f"Getting swap quote (attempt {attempt + 1}/4) with slippage: {DEFAULT_SLIPPAGE * (attempt + 1)}"
925
+ )
926
+ try:
927
+ success, quotes = await self.brap_adapter.get_swap_quote(
928
+ from_token_address=self.current_pool.get("address"),
929
+ to_token_address=self.usdc_token_info.get("address"),
930
+ from_chain_id=self.current_pool.get("chain").get("id"),
931
+ to_chain_id=self.usdc_token_info.get("chain").get("id"),
932
+ from_address=self._get_strategy_wallet_address(),
933
+ to_address=self._get_strategy_wallet_address(),
934
+ amount=str(self.current_pool_balance),
935
+ slippage=DEFAULT_SLIPPAGE * (attempt + 1),
936
+ )
937
+ if (
938
+ success
939
+ and quotes.get("quotes")
940
+ and quotes.get("quotes").get("best_quote")
941
+ ):
942
+ logger.info("Successfully obtained swap quote")
943
+ break
944
+ except Exception as e:
945
+ logger.warning(f"Quote attempt {attempt + 1} failed: {e}")
946
+ if attempt == 3: # Last attempt
947
+ logger.error("All quote attempts failed")
948
+
949
+ best_quote = quotes.get("quotes").get("best_quote")
950
+ if not best_quote:
951
+ return (
952
+ False,
953
+ "Could not swap tokens out due to market conditions (balances too small to move or slippage required is too high) please manually move funds out",
954
+ )
955
+
956
+ if not best_quote.get("output_amount") or not best_quote.get(
957
+ "input_amount"
958
+ ):
959
+ return (False, "Swap quote missing required fields")
960
+
961
+ if not best_quote.get("from_amount_usd"):
962
+ input_amount = int(best_quote.get("input_amount"))
963
+ if self.current_pool.get("token_id") == self.usdc_token_info.get(
964
+ "token_id"
965
+ ):
966
+ best_quote["from_amount_usd"] = float(input_amount) / (
967
+ 10 ** self.current_pool.get("decimals")
968
+ )
969
+ else:
970
+ best_quote["from_amount_usd"] = await self._get_pool_usd_value(
971
+ self.current_pool, input_amount
972
+ )
973
+
974
+ if not best_quote.get("to_amount_usd"):
975
+ output_amount = int(best_quote.get("output_amount"))
976
+ best_quote["to_amount_usd"] = float(
977
+ output_amount
978
+ ) / 10 ** self.usdc_token_info.get("decimals")
979
+
980
+ if not self.brap_adapter:
981
+ return (
982
+ False,
983
+ "BRAP adapter not initialized; cannot unwind position.",
984
+ )
985
+
986
+ success, swap_result = await self.brap_adapter.swap_from_quote(
987
+ self.current_pool,
988
+ self.usdc_token_info,
989
+ self._get_strategy_wallet_address(),
990
+ best_quote,
991
+ strategy_name=self.name,
992
+ )
993
+ if not success:
994
+ return (
995
+ False,
996
+ f"Failed to unwind position via swap: {swap_result}",
997
+ )
998
+
999
+ await self._sweep_wallet(self.usdc_token_info)
1000
+ withdrawn_breakdown = []
1001
+ withdrawn_token_ids = set()
1002
+
1003
+ if self.usdc_token_info.get("token_id") in withdrawn_token_ids:
1004
+ pass
1005
+ status, raw_balance = await self.balance_adapter.get_balance(
1006
+ token_id=self.usdc_token_info.get("token_id"),
1007
+ wallet_address=self._get_strategy_wallet_address(),
1008
+ )
1009
+ if not status or not raw_balance:
1010
+ pass
1011
+ amount = float(raw_balance) / 10 ** self.usdc_token_info.get("decimals")
1012
+ if amount > 0:
1013
+ (
1014
+ move_status,
1015
+ move_message,
1016
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
1017
+ self.usdc_token_info.get("token_id"),
1018
+ amount,
1019
+ strategy_name=self.name,
1020
+ )
1021
+ if not move_status:
1022
+ return (False, f"USDC return to main failed: {move_message}")
1023
+
1024
+ withdrawn_breakdown.append(
1025
+ (
1026
+ self.usdc_token_info.get("symbol"),
1027
+ self.usdc_token_info.get("chain").get("name"),
1028
+ float(amount),
1029
+ )
1030
+ )
1031
+ withdrawn_token_ids.add(self.usdc_token_info.get("token_id"))
1032
+
1033
+ if self.gas_token and self.gas_token.get("id") not in withdrawn_token_ids:
1034
+ status, raw_gas = await self.balance_adapter.get_balance(
1035
+ token_id=self.gas_token.get("id"),
1036
+ wallet_address=self._get_strategy_wallet_address(),
1037
+ )
1038
+ if status and raw_gas:
1039
+ gas_amount = (
1040
+ float(raw_gas) / 10 ** self.gas_token.get("decimals")
1041
+ ) * 0.9
1042
+ if gas_amount > 0:
1043
+ (
1044
+ move_gas_status,
1045
+ move_gas_message,
1046
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
1047
+ self.gas_token.get("id"),
1048
+ gas_amount,
1049
+ strategy_name=self.name,
1050
+ )
1051
+ if move_gas_status:
1052
+ withdrawn_breakdown.append(
1053
+ (
1054
+ self.gas_token.get("symbol"),
1055
+ self.gas_token.get("chain").get("name"),
1056
+ float(gas_amount),
1057
+ )
1058
+ )
1059
+ withdrawn_token_ids.add(self.gas_token.get("id"))
1060
+
1061
+ self.DEPOSIT_USDC = 0
1062
+ self.current_pool_balance = 0
1063
+
1064
+ if not withdrawn_breakdown:
1065
+ return (True, f"Successfully withdrew {amount} USDC from strategy")
1066
+
1067
+ breakdown_msg = ", ".join(
1068
+ f"{amount:.6f} {symbol} on {chain.capitalize()}"
1069
+ for symbol, chain, amount in withdrawn_breakdown
1070
+ )
1071
+
1072
+ elapsed_time = time.time() - start_time
1073
+ logger.info(f"Withdrawal completed successfully in {elapsed_time:.2f} seconds")
1074
+ return (
1075
+ True,
1076
+ f"Successfully withdrew {amount} USDC from strategy: {breakdown_msg}",
1077
+ )
1078
+
1079
+ async def _get_last_rotation_time(self, wallet_address: str) -> datetime | None:
1080
+ success, data = await self.ledger_adapter.get_strategy_latest_transactions(
1081
+ wallet_address=self._get_strategy_wallet_address(),
1082
+ )
1083
+ if success is False:
1084
+ return None
1085
+ for transaction in data.get("transactions", []):
1086
+ op_data = transaction.get("op_data", {})
1087
+ if op_data.get("type") == "SWAP" and op_data.get(
1088
+ "to_token_id"
1089
+ ).lower() not in [
1090
+ "usd-coin-base",
1091
+ "base_0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
1092
+ ]:
1093
+ created_str = transaction.get("created")
1094
+ if not created_str:
1095
+ continue
1096
+ try:
1097
+ dt = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
1098
+ if dt.tzinfo is None:
1099
+ dt = dt.replace(tzinfo=UTC)
1100
+ return dt
1101
+ except (ValueError, AttributeError):
1102
+ continue
1103
+ return None
1104
+
1105
+ async def update(self):
1106
+ logger.info("Starting strategy update process")
1107
+ start_time = time.time()
1108
+
1109
+ if not self.DEPOSIT_USDC:
1110
+ logger.warning("No deposits found, cannot update strategy")
1111
+ return [False, "Nothing has been deposited in this strategy, cannot update"]
1112
+
1113
+ logger.info("Getting non-gas balances")
1114
+ non_gas_balances = await self._get_non_gas_balances()
1115
+ current_target = self.current_pool
1116
+ if current_target is None:
1117
+ current_target = self.usdc_token_info
1118
+ logger.info("No current pool set, using USDC as target")
1119
+
1120
+ logger.info("Searching for best yield opportunities")
1121
+ should_deposit, pool_data = await self._find_best_pool()
1122
+ if not should_deposit:
1123
+ if (
1124
+ current_target
1125
+ and isinstance(current_target, dict)
1126
+ and await self._has_idle_assets(non_gas_balances, current_target)
1127
+ ):
1128
+ await self._sweep_wallet(current_target)
1129
+ await self._refresh_current_pool_balance()
1130
+ return (
1131
+ True,
1132
+ f"Consolidated assets into existing position {current_target.get('id')}",
1133
+ )
1134
+
1135
+ if isinstance(pool_data, dict):
1136
+ message = pool_data.get(
1137
+ "message", "No profitable pools found, staying in current pool"
1138
+ )
1139
+ else:
1140
+ message = (
1141
+ str(pool_data)
1142
+ if pool_data
1143
+ else "No profitable pools found, staying in current pool"
1144
+ )
1145
+ return False, message
1146
+
1147
+ if not isinstance(pool_data, dict):
1148
+ return [False, f"Invalid pool data format: {type(pool_data).__name__}"]
1149
+
1150
+ target_pool = pool_data.get("target_pool")
1151
+ target_pool_data = pool_data.get("target_pool_data")
1152
+ brap_quote = pool_data.get("brap_quote")
1153
+
1154
+ if not target_pool or not target_pool_data or not brap_quote:
1155
+ return [False, "Missing required pool data for rebalancing"]
1156
+
1157
+ gas_status, gas_message = await self._rebalance_gas(target_pool)
1158
+ if not gas_status:
1159
+ return [False, gas_message]
1160
+
1161
+ previous_pool = self.current_pool
1162
+
1163
+ last_rotation = await self._get_last_rotation_time(
1164
+ wallet_address=self._get_strategy_wallet_address(),
1165
+ )
1166
+ if (
1167
+ previous_pool
1168
+ and isinstance(previous_pool, dict)
1169
+ and previous_pool.get("token_id") != self.usdc_token_info.get("token_id")
1170
+ and last_rotation is not None
1171
+ ):
1172
+ now = datetime.now(UTC)
1173
+ if (now - last_rotation) < self.ROTATION_MIN_INTERVAL:
1174
+ elapsed = now - last_rotation
1175
+ remaining = self.ROTATION_MIN_INTERVAL - elapsed
1176
+ remaining_days_cooldown = max(0.0, remaining.total_seconds() / 86400)
1177
+ cooldown_notice = (
1178
+ "Within 7-day cooldown; existing {coin} position retained. "
1179
+ "≈{days:.1f} days until rotation window reopens."
1180
+ ).format(
1181
+ coin=(
1182
+ previous_pool.get("token_id", "unknown")
1183
+ if isinstance(previous_pool, dict)
1184
+ else "unknown"
1185
+ ),
1186
+ days=remaining_days_cooldown,
1187
+ )
1188
+ return (True, cooldown_notice, False)
1189
+
1190
+ await self.brap_adapter.swap_from_quote(
1191
+ previous_pool,
1192
+ target_pool,
1193
+ self._get_strategy_wallet_address(),
1194
+ brap_quote,
1195
+ strategy_name=self.name,
1196
+ )
1197
+
1198
+ # Track the new target pool token
1199
+ if target_pool and target_pool.get("token_id"):
1200
+ self._track_token(target_pool.get("token_id"))
1201
+
1202
+ self.current_pool = target_pool
1203
+ if self.current_pool and self.current_pool.get("token_id"):
1204
+ success, pool_reports = await self.pool_adapter.get_pools_by_ids(
1205
+ pool_ids=[self.current_pool.get("token_id")]
1206
+ )
1207
+ if success and pool_reports.get("pools"):
1208
+ self.current_pool_data = pool_reports.get("pools", [])[0]
1209
+ else:
1210
+ self.current_pool_data = None
1211
+ else:
1212
+ self.current_pool_data = None
1213
+ if self.current_pool_data:
1214
+ self.current_combined_apy_pct = self.current_pool_data.get("apy", 0)
1215
+ else:
1216
+ self.current_combined_apy_pct = (
1217
+ target_pool_data.get("llama_combined_apy_pct", 0) / 100
1218
+ if target_pool_data
1219
+ else 0
1220
+ )
1221
+ output_amount = (
1222
+ brap_quote.get("output_amount")
1223
+ if brap_quote and isinstance(brap_quote, dict)
1224
+ else None
1225
+ )
1226
+ self.current_pool_balance = (
1227
+ int(output_amount) if output_amount is not None else 0
1228
+ )
1229
+
1230
+ await asyncio.sleep(2)
1231
+ await self._sweep_wallet(target_pool)
1232
+ await self._refresh_current_pool_balance()
1233
+
1234
+ elapsed_time = time.time() - start_time
1235
+ logger.info(
1236
+ f"Strategy update completed successfully in {elapsed_time:.2f} seconds"
1237
+ )
1238
+ return [True, "Updated successfully"]
1239
+
1240
+ async def _refresh_current_pool_balance(self):
1241
+ pool = self.current_pool
1242
+ if not pool or pool.get("chain") is None:
1243
+ return
1244
+
1245
+ strategy_address = self._get_strategy_wallet_address()
1246
+ try:
1247
+ (
1248
+ _,
1249
+ refreshed_pool_balance,
1250
+ ) = await self.balance_adapter.get_pool_balance(
1251
+ pool_address=pool.get("address"),
1252
+ chain_id=pool.get("chain").get("id"),
1253
+ user_address=strategy_address,
1254
+ )
1255
+ self.current_pool_balance = int(refreshed_pool_balance)
1256
+ except Exception:
1257
+ pass
1258
+
1259
+ async def _sweep_wallet(self, target_token):
1260
+ """Sweep all tracked non-target tokens into the target token."""
1261
+ # Refresh tracked balances
1262
+ await self._refresh_tracked_balances()
1263
+
1264
+ target_token_id = target_token.get("token_id")
1265
+ target_chain = target_token.get("chain").get("code", "").lower()
1266
+ target_address = target_token.get("address", "").lower()
1267
+ gas_token_id = self.gas_token.get("id") if self.gas_token else None
1268
+
1269
+ # Swap all non-target, non-gas tokens to the target
1270
+ for token_id, balance_wei in list(self.tracked_balances.items()):
1271
+ # Skip if no balance
1272
+ if balance_wei <= 0:
1273
+ continue
1274
+
1275
+ # Skip gas token
1276
+ if token_id == gas_token_id:
1277
+ continue
1278
+
1279
+ # Skip if it's already the target token
1280
+ if token_id == target_token_id:
1281
+ continue
1282
+
1283
+ # Get fresh balance to ensure accuracy
1284
+ try:
1285
+ success, fresh_balance = await self.balance_adapter.get_balance(
1286
+ token_id=token_id,
1287
+ wallet_address=self._get_strategy_wallet_address(),
1288
+ )
1289
+ if not success or not fresh_balance or int(fresh_balance) <= 0:
1290
+ self._update_balance(token_id, 0)
1291
+ continue
1292
+
1293
+ balance_wei = int(fresh_balance)
1294
+ except Exception:
1295
+ continue
1296
+
1297
+ # Construct target token ID for swap
1298
+ target_token_id_for_swap = f"{target_chain}_{target_address}"
1299
+
1300
+ try:
1301
+ logger.info(
1302
+ f"Sweeping {token_id} (balance: {balance_wei}) to {target_token_id}"
1303
+ )
1304
+ success, msg = await self.brap_adapter.swap_from_token_ids(
1305
+ token_id,
1306
+ target_token_id_for_swap,
1307
+ self._get_strategy_wallet_address(),
1308
+ str(balance_wei),
1309
+ strategy_name=self.name,
1310
+ )
1311
+ if success:
1312
+ # Update tracked state: source token now has 0 balance
1313
+ self._update_balance(token_id, 0)
1314
+ logger.info(f"Successfully swept {token_id} to {target_token_id}")
1315
+ else:
1316
+ logger.warning(f"Failed to sweep {token_id}: {msg}")
1317
+ except Exception as e:
1318
+ logger.error(f"Error sweeping {token_id}: {e}")
1319
+ continue
1320
+
1321
+ # Track the target token
1322
+ self._track_token(target_token_id)
1323
+ # Refresh target token balance
1324
+ try:
1325
+ success, target_balance = await self.balance_adapter.get_balance(
1326
+ token_id=target_token_id,
1327
+ wallet_address=self._get_strategy_wallet_address(),
1328
+ )
1329
+ if success and target_balance:
1330
+ self._update_balance(target_token_id, int(target_balance))
1331
+ except Exception:
1332
+ pass
1333
+
1334
+ async def _rebalance_gas(self, target_pool) -> tuple[bool, str]:
1335
+ if self.gas_token.get("chain").get("id") != target_pool.get("chain").get("id"):
1336
+ return False, "Unsupported chain for gas management."
1337
+
1338
+ # TODO: do we need to categorize strategy wallet addresses?
1339
+ strategy_address = self._get_strategy_wallet_address()
1340
+
1341
+ required_gas = int(self.MIN_GAS * (10 ** self.gas_token.get("decimals")))
1342
+ _, current_gas = await self.balance_adapter.get_balance(
1343
+ token_id=self.gas_token.get("id"),
1344
+ wallet_address=strategy_address,
1345
+ )
1346
+ if current_gas >= required_gas:
1347
+ return True, "Enough gas balance found."
1348
+
1349
+ current_native = float(current_gas) / 10 ** self.gas_token.get("decimals")
1350
+ shortfall = max(self.MIN_GAS - current_native, 0)
1351
+
1352
+ return (
1353
+ False,
1354
+ f"Strategy wallet does not have enough gas. Shortfall: {shortfall} {self.gas_token.get('symbol')}",
1355
+ )
1356
+
1357
+ async def _has_idle_assets(self, balances, target_token) -> bool:
1358
+ for balance in balances:
1359
+ if self._balance_matches_token(balance, target_token):
1360
+ continue
1361
+ amount = balance.get("_amount_wei")
1362
+ if isinstance(amount, int) and amount > 0:
1363
+ return True
1364
+ return False
1365
+
1366
+ def _balance_matches_token(self, balance, token) -> bool:
1367
+ token_id = balance.get("token_id")
1368
+ if (
1369
+ isinstance(token_id, str)
1370
+ and token_id.lower() == token.get("token_id").lower()
1371
+ ):
1372
+ return True
1373
+
1374
+ token_address = balance.get("tokenAddress")
1375
+ if not isinstance(token_address, str):
1376
+ return False
1377
+
1378
+ network = (balance.get("network") or "").lower()
1379
+ chain_names = {
1380
+ getattr(token.get("chain"), "name", "").lower(),
1381
+ getattr(token.get("chain"), "code", "").lower(),
1382
+ }
1383
+
1384
+ return network in chain_names and token_address.lower() == token.address.lower()
1385
+
1386
+ async def _get_pool_usd_value(self, token, amount):
1387
+ chain_id = token.get("chain").get("id")
1388
+ if chain_id != self.usdc_token_info.get("chain").get("id"):
1389
+ return 0.0
1390
+
1391
+ success, exit_quotes = await self.brap_adapter.get_swap_quote(
1392
+ from_token_address=token.get("address"),
1393
+ to_token_address=self.usdc_token_info.get("address"),
1394
+ from_chain_id=chain_id,
1395
+ to_chain_id=self.usdc_token_info.get("chain").get("id"),
1396
+ from_address=self._get_strategy_wallet_address(),
1397
+ to_address=self._get_strategy_wallet_address(),
1398
+ amount=str(amount),
1399
+ )
1400
+ if not success:
1401
+ return 0.0
1402
+
1403
+ best_quote = exit_quotes.get("quotes").get("best_quote")
1404
+ current_pool_usd_value = best_quote.get("output_amount")
1405
+
1406
+ return float(
1407
+ float(current_pool_usd_value) / (10 ** self.usdc_token_info.get("decimals"))
1408
+ )
1409
+
1410
+ async def _get_non_gas_balances(self) -> list[dict[str, Any]]:
1411
+ """Get non-gas balances from tracked tokens."""
1412
+ # Refresh tracked balances
1413
+ await self._refresh_tracked_balances()
1414
+
1415
+ gas_token_id = self.gas_token.get("id") if self.gas_token else None
1416
+ results = []
1417
+
1418
+ for token_id, balance_wei in self.tracked_balances.items():
1419
+ # Skip gas token
1420
+ if token_id == gas_token_id:
1421
+ continue
1422
+
1423
+ # Skip zero balances
1424
+ if balance_wei <= 0:
1425
+ continue
1426
+
1427
+ # Fetch token info to get address and chain
1428
+ try:
1429
+ success, token_info = await self.token_adapter.get_token(token_id)
1430
+ if not success or not token_info:
1431
+ continue
1432
+
1433
+ results.append(
1434
+ {
1435
+ "token_id": token_id,
1436
+ "tokenAddress": token_info.get("address"),
1437
+ "network": token_info.get("chain", {}).get("code", "").upper(),
1438
+ "_amount_wei": balance_wei,
1439
+ }
1440
+ )
1441
+ except Exception as e:
1442
+ logger.warning(f"Failed to get token info for {token_id}: {e}")
1443
+ continue
1444
+
1445
+ return results
1446
+
1447
+ async def _find_best_pool(self) -> tuple[bool, dict[str, Any]]:
1448
+ success, llama_data = await self.pool_adapter.get_llama_matches()
1449
+ if not success:
1450
+ return False, {"message": f"Failed to fetch Llama data: {llama_data}"}
1451
+
1452
+ llama_pools = [
1453
+ pool
1454
+ for pool in llama_data.get("matches", [])
1455
+ if pool.get("llama_stablecoin")
1456
+ and pool.get("llama_il_risk") == "no"
1457
+ and pool.get("llama_tvl_usd") > self.MIN_TVL
1458
+ and pool.get("llama_apy_pct") > self.DUST_APY
1459
+ and pool.get("network", "").lower() in self.SUPPORTED_NETWORK_CODES
1460
+ ]
1461
+ llama_pools = sorted(
1462
+ llama_pools, key=lambda pool: pool.get("llama_apy_pct"), reverse=True
1463
+ )
1464
+ if not llama_pools:
1465
+ return False, {"message": "No suitable pools found."}
1466
+
1467
+ for candidate in llama_pools[: self.SEARCH_DEPTH]:
1468
+ if candidate.get("address") == self.current_pool.get("address"):
1469
+ return False, {"message": "Already in the best pool, no action needed."}
1470
+
1471
+ try:
1472
+ target_status, target_pool = await self.token_adapter.get_token(
1473
+ address=candidate.get("address")
1474
+ )
1475
+ if not target_status and candidate.get("token_id"):
1476
+ target_status, target_pool = await self.token_adapter.get_token(
1477
+ token_id=candidate.get("token_id")
1478
+ )
1479
+ if not target_status and candidate.get("pool_id"):
1480
+ target_status, target_pool = await self.token_adapter.get_token(
1481
+ token_id=candidate.get("pool_id")
1482
+ )
1483
+ if not target_status:
1484
+ continue
1485
+ except Exception:
1486
+ continue
1487
+
1488
+ brap_quote = await self._search(
1489
+ candidate,
1490
+ self.current_pool,
1491
+ target_pool,
1492
+ self.current_combined_apy_pct,
1493
+ int(
1494
+ self.current_pool_balance
1495
+ * (10 ** self.current_pool.get("decimals"))
1496
+ ),
1497
+ )
1498
+ if brap_quote:
1499
+ return True, {
1500
+ "target_pool": target_pool,
1501
+ "target_pool_data": candidate,
1502
+ "brap_quote": brap_quote,
1503
+ }
1504
+
1505
+ return False, {"message": "No suitable pools found after searching."}
1506
+
1507
+ async def _search(
1508
+ self,
1509
+ pool_data,
1510
+ current_token,
1511
+ token,
1512
+ current_combined_apy_pct,
1513
+ current_token_balance,
1514
+ ):
1515
+ if token is None or current_token is None:
1516
+ return None
1517
+ if token is None or token.get("chain") is None:
1518
+ return None
1519
+ if current_token is None or current_token.get("chain") is None:
1520
+ return None
1521
+
1522
+ try:
1523
+ combined_apy_pct = pool_data.get("llama_combined_apy_pct") / 100
1524
+ success, quotes = await self.brap_adapter.get_swap_quote(
1525
+ from_token_address=current_token.get("address"),
1526
+ to_token_address=token.get("address"),
1527
+ from_chain_id=current_token.get("chain").get("id"),
1528
+ to_chain_id=token.get("chain").get("id"),
1529
+ from_address=self._get_strategy_wallet_address(),
1530
+ to_address=self._get_strategy_wallet_address(),
1531
+ amount=str(current_token_balance),
1532
+ )
1533
+ if not success:
1534
+ return None
1535
+ quotes_data = quotes.get("quotes") if isinstance(quotes, dict) else None
1536
+ if not isinstance(quotes_data, dict):
1537
+ return None
1538
+ best_quote = quotes_data.get("best_quote")
1539
+ if not best_quote:
1540
+ return None
1541
+
1542
+ target_pool_usd_val = await self._get_pool_usd_value(
1543
+ token, best_quote.get("output_amount")
1544
+ )
1545
+
1546
+ if current_token.get("token_id") != self.usdc_token_info.get("token_id"):
1547
+ current_pool_usd_val = await self._get_pool_usd_value(
1548
+ current_token, best_quote.get("input_amount")
1549
+ )
1550
+ else:
1551
+ current_pool_usd_val = float(
1552
+ float(self.current_pool_balance)
1553
+ / (10 ** current_token.get("decimals"))
1554
+ )
1555
+
1556
+ gas_cost = await self._get_gas_value(best_quote.get("input_amount"))
1557
+ fee_cost = (current_pool_usd_val - target_pool_usd_val) + gas_cost
1558
+ delta_combined_apy_pct = combined_apy_pct - current_combined_apy_pct
1559
+
1560
+ if delta_combined_apy_pct < self.MINIMUM_APY_IMPROVEMENT:
1561
+ return None
1562
+
1563
+ estimated_profit = (
1564
+ self.MINIMUM_DAYS_UNTIL_PROFIT
1565
+ * ((delta_combined_apy_pct * current_pool_usd_val) / 365)
1566
+ - fee_cost
1567
+ )
1568
+
1569
+ if estimated_profit > 0:
1570
+ best_quote["from_amount_usd"] = current_pool_usd_val
1571
+ best_quote["to_amount_usd"] = target_pool_usd_val
1572
+ return best_quote
1573
+
1574
+ except Exception:
1575
+ return {}
1576
+
1577
+ async def _get_gas_value(self, amount):
1578
+ token = self.gas_token
1579
+ success, gas_price_data = await self.token_adapter.get_token_price(
1580
+ token.get("token_id")
1581
+ )
1582
+ if not success:
1583
+ return 0.0
1584
+ gas_price = gas_price_data.get("current_price", 0.0)
1585
+ return float(gas_price) * float(amount) / (10 ** token.get("decimals"))
1586
+
1587
+ async def _status(self) -> StatusDict:
1588
+ # Get ETH gas balance
1589
+ gas_success, gas_balance_wei = await self.balance_adapter.get_balance(
1590
+ token_id=self.gas_token.get("id"),
1591
+ wallet_address=self._get_strategy_wallet_address(),
1592
+ )
1593
+ gas_balance = (
1594
+ float(gas_balance_wei) / (10 ** self.gas_token.get("decimals"))
1595
+ if gas_success
1596
+ else 0.0
1597
+ )
1598
+
1599
+ if not self.DEPOSIT_USDC:
1600
+ # No deposits recorded - report minimal status
1601
+ status_payload = {
1602
+ "info": "No recorded strategy deposits.",
1603
+ "idle_usd": 0.0,
1604
+ }
1605
+
1606
+ return StatusDict(
1607
+ portfolio_value=0.0,
1608
+ net_deposit=0,
1609
+ strategy_status=status_payload,
1610
+ gas_available=gas_balance,
1611
+ gassed_up=gas_balance >= self.GAS_MAXIMUM * self.GAS_SAFETY_FRACTION,
1612
+ )
1613
+
1614
+ # Refresh tracked balances
1615
+ await self._refresh_tracked_balances()
1616
+
1617
+ # Calculate total value from tracked non-gas balances
1618
+ total_value = 0.0
1619
+ gas_token_id = self.gas_token.get("id") if self.gas_token else None
1620
+
1621
+ for token_id, balance_wei in self.tracked_balances.items():
1622
+ if token_id == gas_token_id:
1623
+ continue
1624
+ if balance_wei <= 0:
1625
+ continue
1626
+
1627
+ try:
1628
+ # Get token price to calculate USD value
1629
+ success, price_data = await self.token_adapter.get_token_price(token_id)
1630
+ if not success:
1631
+ continue
1632
+
1633
+ success, token_info = await self.token_adapter.get_token(token_id)
1634
+ if not success:
1635
+ continue
1636
+
1637
+ decimals = token_info.get("decimals", 18)
1638
+ price = price_data.get("current_price", 0.0)
1639
+ balance_usd = (float(balance_wei) / (10**decimals)) * price
1640
+ total_value += balance_usd
1641
+ except Exception as e:
1642
+ logger.warning(f"Failed to calculate value for {token_id}: {e}")
1643
+ continue
1644
+
1645
+ status_payload = (
1646
+ {
1647
+ "current_pool": self.current_pool.get("token_id"),
1648
+ "carrying_loss": None,
1649
+ "pool_balance": self.current_pool_balance
1650
+ / (10 ** self.current_pool.get("decimals")),
1651
+ "pool_apy": f"{self.current_combined_apy_pct * 100}%",
1652
+ "pool_tvl": (
1653
+ self.current_pool_data.get("tvl")
1654
+ if self.current_pool_data
1655
+ else None
1656
+ ),
1657
+ }
1658
+ if self.current_pool
1659
+ else {}
1660
+ )
1661
+
1662
+ return StatusDict(
1663
+ portfolio_value=total_value,
1664
+ net_deposit=self.DEPOSIT_USDC,
1665
+ strategy_status=status_payload,
1666
+ gas_available=gas_balance,
1667
+ gassed_up=gas_balance >= self.GAS_MAXIMUM * self.GAS_SAFETY_FRACTION,
1668
+ )
1669
+
1670
+ @staticmethod
1671
+ def policies() -> list[str]:
1672
+ enso_router = "0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf".lower()
1673
+ approve_enso = (
1674
+ "eth.tx.data[0..10] == '0x095ea7b3' && "
1675
+ f"eth.tx.data[34..74] == '{enso_router[2:]}'"
1676
+ )
1677
+ swap_enso = f"eth.tx.to == '{enso_router}'"
1678
+ wallet_id = "wallet.id == 'FORMAT_WALLET_ID'"
1679
+ return [f"({wallet_id}) && (({approve_enso}) || ({swap_enso})) "]
1680
+
1681
+ async def partial_liquidate(self, usd_value: float) -> StatusTuple:
1682
+ """Liquidate strategy assets to reach target USD value in USDC."""
1683
+ # Refresh tracked balances
1684
+ await self._refresh_tracked_balances()
1685
+
1686
+ usdc_token_id = self.usdc_token_info.get("token_id")
1687
+ usdc_decimals = self.usdc_token_info.get("decimals")
1688
+ gas_token_id = self.gas_token.get("id") if self.gas_token else None
1689
+
1690
+ # Check current USDC balance
1691
+ available_usdc_wei = self.tracked_balances.get(usdc_token_id, 0)
1692
+ available_usdc_usd = float(available_usdc_wei) / (10**usdc_decimals)
1693
+
1694
+ # Liquidate non-USDC, non-gas, non-current-pool tokens first
1695
+ for token_id, balance_wei in list(self.tracked_balances.items()):
1696
+ if available_usdc_usd >= usd_value:
1697
+ break
1698
+
1699
+ # Skip USDC, gas, and current pool
1700
+ if token_id == usdc_token_id:
1701
+ continue
1702
+ if token_id == gas_token_id:
1703
+ continue
1704
+ if self.current_pool and token_id == self.current_pool.get("token_id"):
1705
+ continue
1706
+
1707
+ # Skip zero balances
1708
+ if balance_wei <= 0:
1709
+ continue
1710
+
1711
+ # Get token info and price
1712
+ try:
1713
+ success, token_info = await self.token_adapter.get_token(token_id)
1714
+ if not success:
1715
+ continue
1716
+
1717
+ success, price_data = await self.token_adapter.get_token_price(token_id)
1718
+ if not success:
1719
+ continue
1720
+
1721
+ decimals = token_info.get("decimals", 18)
1722
+ price = price_data.get("current_price", 0.0)
1723
+ token_usd_value = price * float(balance_wei) / (10**decimals)
1724
+
1725
+ if token_usd_value > 1.0:
1726
+ needed_usd = usd_value - available_usdc_usd
1727
+ required_token_wei = int(
1728
+ math.ceil((needed_usd * (10**decimals)) / price)
1729
+ )
1730
+ amount_to_swap = min(required_token_wei, balance_wei)
1731
+
1732
+ logger.info(f"Liquidating {token_id} to USDC: {amount_to_swap} wei")
1733
+ success, msg = await self.brap_adapter.swap_from_token_ids(
1734
+ token_id,
1735
+ f"{self.usdc_token_info.get('chain').get('code')}_{self.usdc_token_info.get('address').lower()}",
1736
+ self._get_strategy_wallet_address(),
1737
+ str(amount_to_swap),
1738
+ strategy_name=self.name,
1739
+ )
1740
+ if success:
1741
+ swapped_usd = (amount_to_swap / (10**decimals)) * price
1742
+ available_usdc_usd += swapped_usd
1743
+ # Update tracked state
1744
+ self._update_balance(token_id, balance_wei - amount_to_swap)
1745
+ else:
1746
+ logger.warning(f"Failed to liquidate {token_id}: {msg}")
1747
+ except Exception as e:
1748
+ logger.error(f"Error liquidating {token_id}: {e}")
1749
+ continue
1750
+
1751
+ # Refresh USDC balance after swaps
1752
+ success, usdc_wei = await self.balance_adapter.get_balance(
1753
+ token_id=usdc_token_id,
1754
+ wallet_address=self._get_strategy_wallet_address(),
1755
+ )
1756
+ if success and usdc_wei:
1757
+ available_usdc_wei = int(usdc_wei)
1758
+ available_usdc_usd = float(available_usdc_wei) / (10**usdc_decimals)
1759
+ self._update_balance(usdc_token_id, available_usdc_wei)
1760
+
1761
+ # If still not enough, liquidate from current pool
1762
+ if (
1763
+ available_usdc_usd < usd_value
1764
+ and self.current_pool
1765
+ and self.current_pool.get("token_id") != usdc_token_id
1766
+ ):
1767
+ remaining_usd = usd_value - available_usdc_usd
1768
+ pool_balance_wei = self.tracked_balances.get(
1769
+ self.current_pool.get("token_id"), 0
1770
+ )
1771
+ pool_decimals = self.current_pool.get("decimals")
1772
+ amount_to_swap = min(
1773
+ pool_balance_wei, int(remaining_usd * (10**pool_decimals))
1774
+ )
1775
+
1776
+ if amount_to_swap > 0:
1777
+ try:
1778
+ logger.info(
1779
+ f"Liquidating from current pool {self.current_pool.get('token_id')}"
1780
+ )
1781
+ success, msg = await self.brap_adapter.swap_from_token_ids(
1782
+ self.current_pool.get("token_id"),
1783
+ f"{self.usdc_token_info.get('chain').get('code')}_{self.usdc_token_info.get('address').lower()}",
1784
+ self._get_strategy_wallet_address(),
1785
+ str(amount_to_swap),
1786
+ strategy_name=self.name,
1787
+ )
1788
+ if success:
1789
+ self._update_balance(
1790
+ self.current_pool.get("token_id"),
1791
+ pool_balance_wei - amount_to_swap,
1792
+ )
1793
+ except Exception as e:
1794
+ logger.error(f"Error swapping pool to USDC: {e}")
1795
+
1796
+ # Refresh USDC balance again
1797
+ success, usdc_wei = await self.balance_adapter.get_balance(
1798
+ token_id=usdc_token_id,
1799
+ wallet_address=self._get_strategy_wallet_address(),
1800
+ )
1801
+ if success and usdc_wei:
1802
+ available_usdc_wei = int(usdc_wei)
1803
+ self._update_balance(usdc_token_id, available_usdc_wei)
1804
+
1805
+ to_pay = min(available_usdc_wei, int(usd_value * (10**usdc_decimals)))
1806
+ to_pay_usd = float(to_pay) / (10**usdc_decimals)
1807
+ return (
1808
+ True,
1809
+ f"Partial liquidation completed. Available: {to_pay_usd:.2f} USDC",
1810
+ )