wayfinder-paths 0.1.9__py3-none-any.whl → 0.1.11__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 (54) hide show
  1. wayfinder_paths/adapters/balance_adapter/README.md +1 -2
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +4 -4
  3. wayfinder_paths/adapters/brap_adapter/adapter.py +139 -23
  4. wayfinder_paths/adapters/moonwell_adapter/README.md +174 -0
  5. wayfinder_paths/adapters/moonwell_adapter/__init__.py +7 -0
  6. wayfinder_paths/adapters/moonwell_adapter/adapter.py +1226 -0
  7. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +635 -0
  8. wayfinder_paths/core/clients/AuthClient.py +3 -0
  9. wayfinder_paths/core/clients/WayfinderClient.py +2 -2
  10. wayfinder_paths/core/constants/__init__.py +0 -2
  11. wayfinder_paths/core/constants/base.py +6 -2
  12. wayfinder_paths/core/constants/moonwell_abi.py +411 -0
  13. wayfinder_paths/core/engine/StrategyJob.py +3 -0
  14. wayfinder_paths/core/services/local_evm_txn.py +182 -217
  15. wayfinder_paths/core/services/local_token_txn.py +46 -26
  16. wayfinder_paths/core/strategies/descriptors.py +1 -1
  17. wayfinder_paths/core/utils/evm_helpers.py +0 -27
  18. wayfinder_paths/run_strategy.py +34 -74
  19. wayfinder_paths/scripts/create_strategy.py +2 -27
  20. wayfinder_paths/scripts/run_strategy.py +37 -7
  21. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +1 -1
  22. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +0 -15
  23. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +1 -1
  24. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +108 -0
  25. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/examples.json +11 -0
  26. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2975 -0
  27. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +886 -0
  28. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +0 -7
  29. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1 -1
  30. wayfinder_paths/templates/adapter/README.md +5 -21
  31. wayfinder_paths/templates/adapter/adapter.py +1 -2
  32. wayfinder_paths/templates/adapter/test_adapter.py +1 -1
  33. wayfinder_paths/templates/strategy/README.md +4 -21
  34. wayfinder_paths/tests/test_smoke_manifest.py +17 -2
  35. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/METADATA +60 -187
  36. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/RECORD +38 -45
  37. wayfinder_paths/CONFIG_GUIDE.md +0 -390
  38. wayfinder_paths/adapters/balance_adapter/manifest.yaml +0 -8
  39. wayfinder_paths/adapters/brap_adapter/manifest.yaml +0 -11
  40. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +0 -10
  41. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +0 -8
  42. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +0 -11
  43. wayfinder_paths/adapters/pool_adapter/manifest.yaml +0 -10
  44. wayfinder_paths/adapters/token_adapter/manifest.yaml +0 -6
  45. wayfinder_paths/config.example.json +0 -22
  46. wayfinder_paths/core/engine/manifest.py +0 -97
  47. wayfinder_paths/scripts/validate_manifests.py +0 -213
  48. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +0 -23
  49. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +0 -7
  50. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +0 -17
  51. wayfinder_paths/templates/adapter/manifest.yaml +0 -6
  52. wayfinder_paths/templates/strategy/manifest.yaml +0 -8
  53. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/LICENSE +0 -0
  54. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/WHEEL +0 -0
@@ -0,0 +1,886 @@
1
+ """Test template for Moonwell wstETH Loop Strategy."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy import (
9
+ ETH_TOKEN_ID,
10
+ M_USDC,
11
+ M_WETH,
12
+ M_WSTETH,
13
+ USDC_TOKEN_ID,
14
+ WETH,
15
+ WETH_TOKEN_ID,
16
+ WSTETH_TOKEN_ID,
17
+ MoonwellWstethLoopStrategy,
18
+ SwapOutcomeUnknownError,
19
+ )
20
+ from wayfinder_paths.tests.test_utils import (
21
+ get_canonical_examples,
22
+ load_strategy_examples,
23
+ )
24
+
25
+
26
+ @pytest.fixture
27
+ def strategy():
28
+ """Create a strategy instance for testing with minimal config."""
29
+ mock_config = {
30
+ "main_wallet": {"address": "0x1234567890123456789012345678901234567890"},
31
+ "strategy_wallet": {"address": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"},
32
+ }
33
+
34
+ # Patch the initialization to avoid real adapter/web3 setup
35
+ with patch.object(
36
+ MoonwellWstethLoopStrategy, "__init__", lambda self, **kwargs: None
37
+ ):
38
+ s = MoonwellWstethLoopStrategy(
39
+ config=mock_config,
40
+ main_wallet=mock_config["main_wallet"],
41
+ strategy_wallet=mock_config["strategy_wallet"],
42
+ simulation=True,
43
+ )
44
+ # Manually set attributes that would be set in __init__
45
+ s.config = mock_config
46
+ s.simulation = True
47
+ s._token_info_cache = {}
48
+ s._token_price_cache = {}
49
+ s._token_price_timestamps = {}
50
+
51
+ # Mock adapters
52
+ s.balance_adapter = MagicMock()
53
+ s.moonwell_adapter = MagicMock()
54
+ s.brap_adapter = MagicMock()
55
+ s.token_adapter = MagicMock()
56
+ s.ledger_adapter = MagicMock()
57
+ s.ledger_adapter.record_strategy_snapshot = AsyncMock(return_value=None)
58
+
59
+ return s
60
+
61
+
62
+ @pytest.fixture
63
+ def mock_adapter_responses(strategy):
64
+ """Set up mock responses for adapter calls."""
65
+ # Mock balance adapter
66
+ strategy.balance_adapter.get_balance = AsyncMock(return_value=(True, 1000000))
67
+ strategy.balance_adapter.move_from_main_wallet_to_strategy_wallet = AsyncMock(
68
+ return_value=(True, "success")
69
+ )
70
+ strategy.balance_adapter.move_from_strategy_wallet_to_main_wallet = AsyncMock(
71
+ return_value=(True, "success")
72
+ )
73
+
74
+ # Mock token adapter
75
+ strategy.token_adapter.get_token = AsyncMock(
76
+ return_value=(True, {"decimals": 18, "symbol": "TEST"})
77
+ )
78
+ strategy.token_adapter.get_token_price = AsyncMock(
79
+ return_value=(True, {"current_price": 1.0})
80
+ )
81
+
82
+ # Mock moonwell adapter
83
+ strategy.moonwell_adapter.get_pos = AsyncMock(
84
+ return_value=(
85
+ True,
86
+ {
87
+ "mtoken_balance": 1000000000000000000,
88
+ "underlying_balance": 1000000000000000000,
89
+ "borrow_balance": 0,
90
+ "exchange_rate": 1000000000000000000,
91
+ },
92
+ )
93
+ )
94
+ strategy.moonwell_adapter.get_collateral_factor = AsyncMock(
95
+ return_value=(True, 0.8)
96
+ )
97
+ strategy.moonwell_adapter.get_apy = AsyncMock(return_value=(True, 0.05))
98
+ strategy.moonwell_adapter.get_borrowable_amount = AsyncMock(
99
+ return_value=(True, 1000.0)
100
+ )
101
+ strategy.moonwell_adapter.max_withdrawable_mtoken = AsyncMock(
102
+ return_value=(True, {"cTokens_raw": 1000000, "underlying_raw": 1000000})
103
+ )
104
+ strategy.moonwell_adapter.lend = AsyncMock(return_value=(True, "success"))
105
+ strategy.moonwell_adapter.unlend = AsyncMock(return_value=(True, "success"))
106
+ strategy.moonwell_adapter.borrow = AsyncMock(return_value=(True, "success"))
107
+ strategy.moonwell_adapter.repay = AsyncMock(return_value=(True, "success"))
108
+ strategy.moonwell_adapter.set_collateral = AsyncMock(return_value=(True, "success"))
109
+ strategy.moonwell_adapter.claim_rewards = AsyncMock(return_value={})
110
+
111
+ # Mock brap adapter
112
+ strategy.brap_adapter.swap_from_token_ids = AsyncMock(
113
+ return_value=(True, {"to_amount": 1000000000000000000})
114
+ )
115
+
116
+ return strategy
117
+
118
+
119
+ @pytest.mark.asyncio
120
+ @pytest.mark.smoke
121
+ async def test_smoke(strategy, mock_adapter_responses):
122
+ """REQUIRED: Basic smoke test - verifies strategy lifecycle."""
123
+ examples = load_strategy_examples(Path(__file__))
124
+ smoke_data = examples["smoke"]
125
+
126
+ # Mock quote to return positive APY
127
+ with patch.object(strategy, "quote", new_callable=AsyncMock) as mock_quote:
128
+ mock_quote.return_value = {"apy": 0.1, "data": {}}
129
+
130
+ # Status test
131
+ with patch.object(strategy, "_status", new_callable=AsyncMock) as mock_status:
132
+ mock_status.return_value = {
133
+ "portfolio_value": 0.0,
134
+ "net_deposit": 0.0,
135
+ "strategy_status": {},
136
+ "gas_available": 0.1,
137
+ "gassed_up": True,
138
+ }
139
+ st = await strategy.status()
140
+ assert isinstance(st, dict)
141
+ assert (
142
+ "portfolio_value" in st
143
+ or "net_deposit" in st
144
+ or "strategy_status" in st
145
+ )
146
+
147
+ # Deposit test
148
+ deposit_params = smoke_data.get("deposit", {})
149
+ with patch.object(strategy, "deposit", new_callable=AsyncMock) as mock_deposit:
150
+ mock_deposit.return_value = (True, "success")
151
+ ok, msg = await strategy.deposit(**deposit_params)
152
+ assert isinstance(ok, bool)
153
+ assert isinstance(msg, str)
154
+
155
+ # Update test
156
+ with patch.object(strategy, "update", new_callable=AsyncMock) as mock_update:
157
+ mock_update.return_value = (True, "success")
158
+ ok, msg = await strategy.update(**smoke_data.get("update", {}))
159
+ assert isinstance(ok, bool)
160
+
161
+ # Withdraw test
162
+ with patch.object(
163
+ strategy, "withdraw", new_callable=AsyncMock
164
+ ) as mock_withdraw:
165
+ mock_withdraw.return_value = (True, "success")
166
+ ok, msg = await strategy.withdraw(**smoke_data.get("withdraw", {}))
167
+ assert isinstance(ok, bool)
168
+
169
+
170
+ @pytest.mark.asyncio
171
+ async def test_canonical_usage(strategy, mock_adapter_responses):
172
+ """REQUIRED: Test canonical usage examples from examples.json (minimum)."""
173
+ examples = load_strategy_examples(Path(__file__))
174
+ canonical = get_canonical_examples(examples)
175
+
176
+ for example_name, example_data in canonical.items():
177
+ # Mock methods for canonical usage tests
178
+ with patch.object(strategy, "quote", new_callable=AsyncMock) as mock_quote:
179
+ mock_quote.return_value = {"apy": 0.1, "data": {}}
180
+
181
+ if "deposit" in example_data:
182
+ deposit_params = example_data.get("deposit", {})
183
+ with patch.object(
184
+ strategy, "deposit", new_callable=AsyncMock
185
+ ) as mock_deposit:
186
+ mock_deposit.return_value = (True, "success")
187
+ ok, _ = await strategy.deposit(**deposit_params)
188
+ assert ok, f"Canonical example '{example_name}' deposit failed"
189
+
190
+ if "update" in example_data:
191
+ with patch.object(
192
+ strategy, "update", new_callable=AsyncMock
193
+ ) as mock_update:
194
+ mock_update.return_value = (True, "success")
195
+ ok, msg = await strategy.update()
196
+ assert ok, (
197
+ f"Canonical example '{example_name}' update failed: {msg}"
198
+ )
199
+
200
+ if "status" in example_data:
201
+ with patch.object(
202
+ strategy, "_status", new_callable=AsyncMock
203
+ ) as mock_status:
204
+ mock_status.return_value = {
205
+ "portfolio_value": 0.0,
206
+ "net_deposit": 0.0,
207
+ "strategy_status": {},
208
+ "gas_available": 0.1,
209
+ "gassed_up": True,
210
+ }
211
+ st = await strategy.status()
212
+ assert isinstance(st, dict), (
213
+ f"Canonical example '{example_name}' status failed"
214
+ )
215
+
216
+
217
+ @pytest.mark.asyncio
218
+ async def test_status_returns_status_dict(strategy, mock_adapter_responses):
219
+ """Test that _status returns a proper StatusDict."""
220
+ with patch.object(
221
+ strategy, "_aggregate_positions", new_callable=AsyncMock
222
+ ) as mock_pos:
223
+ mock_pos.return_value = ({}, {})
224
+ with patch.object(strategy, "compute_ltv", new_callable=AsyncMock) as mock_ltv:
225
+ mock_ltv.return_value = 0.5
226
+ with patch.object(
227
+ strategy, "_get_gas_balance", new_callable=AsyncMock
228
+ ) as mock_gas:
229
+ mock_gas.return_value = 100000000000000000 # 0.1 ETH
230
+ with patch.object(
231
+ strategy, "get_peg_diff", new_callable=AsyncMock
232
+ ) as mock_peg:
233
+ mock_peg.return_value = 0.001
234
+ with patch.object(
235
+ strategy, "quote", new_callable=AsyncMock
236
+ ) as mock_quote:
237
+ mock_quote.return_value = {"apy": 0.1, "data": {}}
238
+
239
+ status = await strategy._status()
240
+
241
+ assert "portfolio_value" in status
242
+ assert "net_deposit" in status
243
+ assert "strategy_status" in status
244
+ assert "gas_available" in status
245
+ assert "gassed_up" in status
246
+
247
+
248
+ @pytest.mark.asyncio
249
+ async def test_policies_returns_list(strategy):
250
+ """Test that policies returns a non-empty list."""
251
+ # Mock the policy functions to avoid ABI fetching
252
+ with (
253
+ patch(
254
+ "wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.musdc_mint_or_approve_or_redeem",
255
+ new_callable=AsyncMock,
256
+ return_value="mock_musdc_policy",
257
+ ),
258
+ patch(
259
+ "wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.mweth_approve_or_borrow_or_repay",
260
+ new_callable=AsyncMock,
261
+ return_value="mock_mweth_policy",
262
+ ),
263
+ patch(
264
+ "wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.mwsteth_approve_or_mint_or_redeem",
265
+ new_callable=AsyncMock,
266
+ return_value="mock_mwsteth_policy",
267
+ ),
268
+ patch(
269
+ "wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.moonwell_comptroller_enter_markets_or_claim_rewards",
270
+ new_callable=AsyncMock,
271
+ return_value="mock_comptroller_policy",
272
+ ),
273
+ patch(
274
+ "wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.weth_deposit",
275
+ new_callable=AsyncMock,
276
+ return_value="mock_weth_deposit_policy",
277
+ ),
278
+ patch(
279
+ "wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.enso_swap",
280
+ new_callable=AsyncMock,
281
+ return_value="mock_enso_swap_policy",
282
+ ),
283
+ ):
284
+ policies = await strategy.policies()
285
+ assert isinstance(policies, list)
286
+ assert len(policies) > 0
287
+
288
+
289
+ @pytest.mark.asyncio
290
+ async def test_quote_returns_apy_info(strategy, mock_adapter_responses):
291
+ """Test that quote returns APY information."""
292
+ with patch("httpx.AsyncClient") as mock_client:
293
+ mock_response = MagicMock()
294
+ mock_response.json.return_value = {"data": {"smaApr": 3.5}}
295
+ mock_client.return_value.__aenter__.return_value.get = AsyncMock(
296
+ return_value=mock_response
297
+ )
298
+
299
+ quote = await strategy.quote()
300
+
301
+ assert "apy" in quote
302
+ assert "information" in quote or "data" in quote
303
+
304
+
305
+ # Tests for new safety methods
306
+
307
+
308
+ def test_max_safe_f_calculates_correctly(strategy):
309
+ """Test that _max_safe_F calculates the depeg-aware leverage limit correctly."""
310
+ # Set up strategy with MAX_DEPEG = 0.01 (1%)
311
+ strategy.MAX_DEPEG = 0.01
312
+
313
+ # With cf_w = 0.8, a = 0.99
314
+ # F_max = 1 / (1 + 0.8 * (1 - 0.99)) = 1 / (1 + 0.8 * 0.01) = 1 / 1.008 ≈ 0.992
315
+ result = strategy._max_safe_F(0.8)
316
+ expected = 1 / (1 + 0.8 * 0.01)
317
+ assert abs(result - expected) < 0.001
318
+
319
+
320
+ def test_max_safe_f_with_zero_collateral_factor(strategy):
321
+ """Test _max_safe_F with zero collateral factor."""
322
+ strategy.MAX_DEPEG = 0.01
323
+ result = strategy._max_safe_F(0.0)
324
+ # F_max = 1 / (1 + 0 * anything) = 1.0
325
+ assert result == 1.0
326
+
327
+
328
+ @pytest.mark.asyncio
329
+ async def test_swap_with_retries_succeeds_first_attempt(
330
+ strategy, mock_adapter_responses
331
+ ):
332
+ """Test that _swap_with_retries succeeds on first attempt."""
333
+ strategy.max_swap_retries = 3
334
+ strategy.swap_slippage_tolerance = 0.005
335
+
336
+ result = await strategy._swap_with_retries(
337
+ from_token_id="usd-coin-base",
338
+ to_token_id="l2-standard-bridged-weth-base-base",
339
+ amount=1000000,
340
+ )
341
+
342
+ assert result is not None
343
+ assert "to_amount" in result
344
+ strategy.brap_adapter.swap_from_token_ids.assert_called_once()
345
+
346
+
347
+ @pytest.mark.asyncio
348
+ async def test_swap_with_retries_succeeds_on_second_attempt(
349
+ strategy, mock_adapter_responses
350
+ ):
351
+ """Test that _swap_with_retries retries and succeeds."""
352
+ strategy.max_swap_retries = 3
353
+ strategy.swap_slippage_tolerance = 0.005
354
+
355
+ # First call fails, second succeeds
356
+ strategy.brap_adapter.swap_from_token_ids = AsyncMock(
357
+ side_effect=[
358
+ Exception("First attempt failed"),
359
+ (True, {"to_amount": 1000000}),
360
+ ]
361
+ )
362
+
363
+ with patch("asyncio.sleep", new_callable=AsyncMock): # Speed up test
364
+ result = await strategy._swap_with_retries(
365
+ from_token_id="usd-coin-base",
366
+ to_token_id="l2-standard-bridged-weth-base-base",
367
+ amount=1000000,
368
+ )
369
+
370
+ assert result is not None
371
+ assert result["to_amount"] == 1000000
372
+ assert strategy.brap_adapter.swap_from_token_ids.call_count == 2
373
+
374
+
375
+ @pytest.mark.asyncio
376
+ async def test_swap_with_retries_fails_all_attempts(strategy, mock_adapter_responses):
377
+ """Test that _swap_with_retries returns None after all attempts fail."""
378
+ strategy.max_swap_retries = 3
379
+ strategy.swap_slippage_tolerance = 0.005
380
+
381
+ strategy.brap_adapter.swap_from_token_ids = AsyncMock(
382
+ side_effect=Exception("Swap failed")
383
+ )
384
+
385
+ with patch("asyncio.sleep", new_callable=AsyncMock):
386
+ result = await strategy._swap_with_retries(
387
+ from_token_id="usd-coin-base",
388
+ to_token_id="l2-standard-bridged-weth-base-base",
389
+ amount=1000000,
390
+ )
391
+
392
+ assert result is None
393
+ assert strategy.brap_adapter.swap_from_token_ids.call_count == 3
394
+
395
+
396
+ @pytest.mark.asyncio
397
+ async def test_swap_with_retries_aborts_on_unknown_outcome(
398
+ strategy, mock_adapter_responses
399
+ ):
400
+ """Swap retries must abort (no retry) when the transaction outcome is unknown."""
401
+ strategy.max_swap_retries = 3
402
+ strategy.brap_adapter.swap_from_token_ids = AsyncMock(
403
+ return_value=(
404
+ False,
405
+ "Transaction HexBytes('0xdeadbeef') is not in the chain after 120 seconds",
406
+ )
407
+ )
408
+
409
+ with pytest.raises(SwapOutcomeUnknownError):
410
+ await strategy._swap_with_retries(
411
+ from_token_id=USDC_TOKEN_ID,
412
+ to_token_id=WETH_TOKEN_ID,
413
+ amount=1000000,
414
+ )
415
+
416
+ assert strategy.brap_adapter.swap_from_token_ids.call_count == 1
417
+
418
+
419
+ @pytest.mark.asyncio
420
+ async def test_balance_weth_debt_no_action_when_balanced(
421
+ strategy, mock_adapter_responses
422
+ ):
423
+ """Test that _balance_weth_debt does nothing when debt is balanced."""
424
+ # Mock wstETH position: 2 ETH worth
425
+ strategy.moonwell_adapter.get_pos = AsyncMock(
426
+ side_effect=[
427
+ (True, {"underlying_balance": 2 * 10**18, "borrow_balance": 0}), # wstETH
428
+ (
429
+ True,
430
+ {"underlying_balance": 0, "borrow_balance": 1 * 10**18},
431
+ ), # WETH debt
432
+ ]
433
+ )
434
+
435
+ # Mock prices: wstETH = $2000, WETH = $2000 (so 2 wstETH > 1 WETH debt)
436
+ strategy.token_adapter.get_token_price = AsyncMock(
437
+ return_value=(True, {"current_price": 2000.0})
438
+ )
439
+
440
+ success, msg = await strategy._balance_weth_debt()
441
+
442
+ assert success is True
443
+ assert "balanced" in msg.lower()
444
+
445
+
446
+ @pytest.mark.asyncio
447
+ async def test_balance_weth_debt_rebalances_when_excess_debt(
448
+ strategy, mock_adapter_responses
449
+ ):
450
+ """Test that _balance_weth_debt attempts to rebalance when debt exceeds collateral."""
451
+ # Mock positions: wstETH value < WETH debt
452
+ strategy.moonwell_adapter.get_pos = AsyncMock(
453
+ side_effect=[
454
+ (True, {"underlying_balance": 1 * 10**18}), # 1 wstETH
455
+ (True, {"borrow_balance": 2 * 10**18}), # 2 WETH debt (excess)
456
+ ]
457
+ )
458
+
459
+ # Mock prices
460
+ strategy.token_adapter.get_token_price = AsyncMock(
461
+ return_value=(True, {"current_price": 2000.0})
462
+ )
463
+
464
+ # Mock wallet balances (has WETH to repay)
465
+ strategy.balance_adapter.get_balance = AsyncMock(
466
+ side_effect=[
467
+ (True, 1 * 10**18), # WETH balance
468
+ (True, 0), # ETH balance
469
+ ]
470
+ )
471
+
472
+ strategy.MIN_GAS = 0.005
473
+
474
+ success, msg = await strategy._balance_weth_debt()
475
+
476
+ # Should have attempted repayment
477
+ strategy.moonwell_adapter.repay.assert_called()
478
+
479
+
480
+ @pytest.mark.asyncio
481
+ async def test_balance_weth_debt_rebalances_when_no_wsteth_position(
482
+ strategy, mock_adapter_responses
483
+ ):
484
+ """Test that _balance_weth_debt still rebalances when wstETH position fetch fails."""
485
+ strategy.moonwell_adapter.get_pos = AsyncMock(
486
+ side_effect=[
487
+ (False, "rpc error"), # wstETH (treat as 0)
488
+ (True, {"borrow_balance": 2 * 10**18}), # WETH debt
489
+ ]
490
+ )
491
+
492
+ strategy.token_adapter.get_token_price = AsyncMock(
493
+ return_value=(True, {"current_price": 2000.0})
494
+ )
495
+
496
+ strategy.balance_adapter.get_balance = AsyncMock(
497
+ side_effect=[
498
+ (True, 2 * 10**18), # WETH balance (enough to repay)
499
+ ]
500
+ )
501
+
502
+ success, msg = await strategy._balance_weth_debt()
503
+
504
+ assert success is True
505
+ assert "balanced" in msg.lower()
506
+ strategy.moonwell_adapter.repay.assert_called()
507
+
508
+
509
+ @pytest.mark.asyncio
510
+ async def test_atomic_deposit_iteration_swaps_from_eth_when_borrow_surfaces_as_eth(
511
+ strategy, mock_adapter_responses
512
+ ):
513
+ """Borrowed WETH can surface as native ETH; iteration should wrap ETH→WETH then swap WETH→wstETH."""
514
+ # Ensure gas reserve exists so we don't drain to 0
515
+ strategy.WRAP_GAS_RESERVE = 0.0014
516
+
517
+ borrow_amt_wei = 10**18
518
+ safe_borrow = int(borrow_amt_wei * 0.98)
519
+
520
+ balances: dict[str, int] = {
521
+ ETH_TOKEN_ID: 2 * 10**18,
522
+ WETH_TOKEN_ID: 0,
523
+ WSTETH_TOKEN_ID: 0,
524
+ }
525
+
526
+ async def get_balance_side_effect(*, token_id: str, wallet_address: str, **_):
527
+ return (True, balances.get(token_id, 0))
528
+
529
+ strategy.balance_adapter.get_balance = AsyncMock(
530
+ side_effect=get_balance_side_effect
531
+ )
532
+
533
+ async def borrow_side_effect(*, mtoken: str, amount: int):
534
+ # Borrow shows up as native ETH (simulates on-chain behavior)
535
+ assert mtoken == M_WETH
536
+ balances[ETH_TOKEN_ID] += int(amount)
537
+ return (True, {"block_number": 12345})
538
+
539
+ strategy.moonwell_adapter.borrow = AsyncMock(side_effect=borrow_side_effect)
540
+
541
+ async def wrap_eth_side_effect(*, amount: int):
542
+ # Wrap ETH to WETH
543
+ balances[ETH_TOKEN_ID] -= int(amount)
544
+ balances[WETH_TOKEN_ID] += int(amount)
545
+ return (True, {"block_number": 12346})
546
+
547
+ strategy.moonwell_adapter.wrap_eth = AsyncMock(side_effect=wrap_eth_side_effect)
548
+
549
+ async def swap_side_effect(
550
+ *, from_token_id: str, to_token_id: str, amount: int, **_
551
+ ):
552
+ # After wrapping, the swap should be WETH→wstETH
553
+ assert from_token_id == WETH_TOKEN_ID
554
+ assert to_token_id == WSTETH_TOKEN_ID
555
+ # Simulate receiving wstETH
556
+ balances[WSTETH_TOKEN_ID] += 123
557
+ return {"to_amount": 123}
558
+
559
+ strategy._swap_with_retries = AsyncMock(side_effect=swap_side_effect)
560
+
561
+ lent = await strategy._atomic_deposit_iteration(borrow_amt_wei)
562
+
563
+ assert lent == 123
564
+ strategy.moonwell_adapter.borrow.assert_called_once_with(
565
+ mtoken=M_WETH, amount=safe_borrow
566
+ )
567
+ strategy.moonwell_adapter.wrap_eth.assert_called_once()
568
+ strategy._swap_with_retries.assert_called_once()
569
+
570
+
571
+ @pytest.mark.asyncio
572
+ async def test_complete_unpaired_weth_borrow_uses_eth_inventory(
573
+ strategy, mock_adapter_responses
574
+ ):
575
+ """If debt exists but wstETH collateral is missing, use wallet ETH to complete the loop."""
576
+ # wstETH pos is empty; WETH debt exists
577
+ strategy.moonwell_adapter.get_pos = AsyncMock(
578
+ side_effect=[
579
+ (True, {"underlying_balance": 0}), # mwstETH
580
+ (True, {"borrow_balance": 5 * 10**18}), # mWETH debt
581
+ ]
582
+ )
583
+
584
+ balances: dict[str, int] = {
585
+ ETH_TOKEN_ID: 10 * 10**18,
586
+ WETH_TOKEN_ID: 0,
587
+ WSTETH_TOKEN_ID: 0,
588
+ }
589
+
590
+ async def get_balance_side_effect(*, token_id: str, wallet_address: str):
591
+ return (True, balances.get(token_id, 0))
592
+
593
+ strategy.balance_adapter.get_balance = AsyncMock(
594
+ side_effect=get_balance_side_effect
595
+ )
596
+
597
+ async def swap_side_effect(
598
+ *, from_token_id: str, to_token_id: str, amount: int, **_
599
+ ):
600
+ assert from_token_id == ETH_TOKEN_ID
601
+ assert to_token_id == WSTETH_TOKEN_ID
602
+ balances[WSTETH_TOKEN_ID] += 7 * 10**18
603
+ return {"to_amount": 7 * 10**18}
604
+
605
+ strategy._swap_with_retries = AsyncMock(side_effect=swap_side_effect)
606
+
607
+ success, msg = await strategy._complete_unpaired_weth_borrow()
608
+
609
+ assert success is True
610
+ assert "completed unpaired borrow" in msg.lower()
611
+ strategy.moonwell_adapter.lend.assert_called()
612
+
613
+
614
+ @pytest.mark.asyncio
615
+ async def test_sweep_token_balances_no_tokens(strategy, mock_adapter_responses):
616
+ """Test that _sweep_token_balances handles empty wallet."""
617
+ # All balances are 0
618
+ strategy.balance_adapter.get_balance = AsyncMock(return_value=(True, 0))
619
+
620
+ success, msg = await strategy._sweep_token_balances(
621
+ target_token_id="usd-coin-base",
622
+ )
623
+
624
+ assert success is True
625
+ assert "no tokens" in msg.lower()
626
+
627
+
628
+ @pytest.mark.asyncio
629
+ async def test_sweep_token_balances_sweeps_tokens(strategy, mock_adapter_responses):
630
+ """Test that _sweep_token_balances converts dust tokens."""
631
+ strategy.min_withdraw_usd = 1.0
632
+
633
+ # Mock balance returns (has some WETH dust)
634
+ def balance_side_effect(token_id, wallet_address):
635
+ if "weth" in token_id.lower():
636
+ return (True, 100 * 10**18) # 100 WETH
637
+ return (True, 0)
638
+
639
+ strategy.balance_adapter.get_balance = AsyncMock(side_effect=balance_side_effect)
640
+
641
+ # Mock price (high enough to trigger sweep)
642
+ strategy.token_adapter.get_token_price = AsyncMock(
643
+ return_value=(True, {"current_price": 2000.0})
644
+ )
645
+
646
+ success, msg = await strategy._sweep_token_balances(
647
+ target_token_id="usd-coin-base",
648
+ exclude=set(),
649
+ )
650
+
651
+ assert success is True
652
+ # Should have called swap
653
+ strategy.brap_adapter.swap_from_token_ids.assert_called()
654
+
655
+
656
+ # Tests for code review fixes
657
+
658
+
659
+ @pytest.mark.asyncio
660
+ async def test_deposit_rejects_zero_amount(strategy):
661
+ """Test that deposit rejects zero or negative amounts."""
662
+ result = await strategy.deposit(main_token_amount=0.0)
663
+ assert result[0] is False
664
+ assert "positive" in result[1].lower()
665
+
666
+ result = await strategy.deposit(main_token_amount=-10.0)
667
+ assert result[0] is False
668
+ assert "positive" in result[1].lower()
669
+
670
+
671
+ def test_slippage_capped_at_max(strategy):
672
+ """Test that slippage is capped at MAX_SLIPPAGE_TOLERANCE."""
673
+ strategy.MAX_SLIPPAGE_TOLERANCE = 0.03
674
+ strategy.swap_slippage_tolerance = 0.02
675
+
676
+ # With 3 retries at 2% base: 2%, 4%, 6% -> should be capped at 3%
677
+ # The actual slippage calculation happens in the method, we just verify the constant exists
678
+ assert hasattr(strategy, "MAX_SLIPPAGE_TOLERANCE")
679
+ assert strategy.MAX_SLIPPAGE_TOLERANCE == 0.03
680
+
681
+
682
+ def test_price_staleness_threshold_exists(strategy):
683
+ """Test that price staleness threshold is configured."""
684
+ assert hasattr(strategy, "PRICE_STALENESS_THRESHOLD")
685
+ assert strategy.PRICE_STALENESS_THRESHOLD > 0
686
+
687
+
688
+ def test_min_leverage_gain_constant_exists(strategy):
689
+ """Test that minimum leverage gain constant is configured."""
690
+ assert hasattr(strategy, "_MIN_LEVERAGE_GAIN_BPS")
691
+ assert strategy._MIN_LEVERAGE_GAIN_BPS == 50e-4 # 50 bps
692
+
693
+
694
+ @pytest.mark.asyncio
695
+ async def test_leverage_calc_handles_high_cf_w(strategy, mock_adapter_responses):
696
+ """Test that leverage calculation handles high collateral factors safely."""
697
+ strategy.MIN_HEALTH_FACTOR = 1.2
698
+
699
+ # This should return early without crashing when cf_w >= MIN_HEALTH_FACTOR
700
+ # Pass collateral_factors directly to avoid RPC call ordering issues
701
+ # collateral_factors = (cf_usdc, cf_wsteth)
702
+ result = await strategy._loop_wsteth(
703
+ wsteth_price=2000.0,
704
+ weth_price=2000.0,
705
+ current_borrowed_value=1000.0,
706
+ initial_leverage=1.5,
707
+ usdc_lend_value=1000.0,
708
+ wsteth_lend_value=500.0,
709
+ collateral_factors=(0.8, 1.3), # cf_u=0.8, cf_w=1.3 (higher than MIN_HF)
710
+ )
711
+
712
+ # Should return failure tuple instead of crashing
713
+ assert result[0] is False
714
+ assert result[2] == -1 # Error code
715
+
716
+
717
+ @pytest.mark.asyncio
718
+ async def test_price_staleness_triggers_refresh(strategy, mock_adapter_responses):
719
+ """Test that stale prices trigger a refresh."""
720
+ strategy.PRICE_STALENESS_THRESHOLD = 1 # 1 second for test
721
+ strategy._token_price_cache = {"test-token": 100.0}
722
+ strategy._token_price_timestamps = {"test-token": 0} # Very old timestamp
723
+
724
+ # Should refresh because timestamp is stale
725
+ await strategy._get_token_price("test-token")
726
+
727
+ # Should have called token adapter because cache was stale
728
+ strategy.token_adapter.get_token_price.assert_called_with("test-token")
729
+
730
+
731
+ @pytest.mark.asyncio
732
+ async def test_partial_liquidate_prefers_wsteth_when_excess(strategy):
733
+ """Partial liquidation should redeem wstETH first when collateral exceeds debt."""
734
+
735
+ # Token metadata
736
+ async def mock_get_token(token_id: str):
737
+ if token_id == USDC_TOKEN_ID:
738
+ return (True, {"decimals": 6})
739
+ if token_id == WSTETH_TOKEN_ID:
740
+ return (True, {"decimals": 18})
741
+ return (True, {"decimals": 18})
742
+
743
+ async def mock_get_price(token_id: str):
744
+ if token_id == WSTETH_TOKEN_ID:
745
+ return (True, {"current_price": 2000.0})
746
+ return (True, {"current_price": 1.0})
747
+
748
+ strategy.token_adapter.get_token = AsyncMock(side_effect=mock_get_token)
749
+ strategy.token_adapter.get_token_price = AsyncMock(side_effect=mock_get_price)
750
+
751
+ # Wallet balances (raw)
752
+ balances: dict[str, int] = {USDC_TOKEN_ID: 0, WSTETH_TOKEN_ID: 0}
753
+
754
+ async def mock_get_balance(*, token_id: str, wallet_address: str):
755
+ return (True, balances.get(token_id, 0))
756
+
757
+ strategy.balance_adapter.get_balance = AsyncMock(side_effect=mock_get_balance)
758
+
759
+ # Position snapshot: wstETH collateral > WETH debt
760
+ totals_usd = {
761
+ f"Base_{M_WSTETH}": 500.0,
762
+ f"Base_{M_USDC}": 1000.0,
763
+ f"Base_{WETH}": -200.0,
764
+ }
765
+ strategy._aggregate_positions = AsyncMock(return_value=({}, totals_usd))
766
+
767
+ # Collateral factors
768
+ strategy.moonwell_adapter.get_collateral_factor = AsyncMock(
769
+ return_value=(True, 0.8)
770
+ )
771
+
772
+ # mwstETH redemption metadata (1:1 exchange rate for test)
773
+ async def mock_max_withdrawable(*, mtoken: str):
774
+ return (
775
+ True,
776
+ {
777
+ "cTokens_raw": 10**30,
778
+ "exchangeRate_raw": 10**18,
779
+ "conversion_factor": 1.0,
780
+ },
781
+ )
782
+
783
+ strategy.moonwell_adapter.max_withdrawable_mtoken = AsyncMock(
784
+ side_effect=mock_max_withdrawable
785
+ )
786
+
787
+ async def mock_unlend(*, mtoken: str, amount: int):
788
+ if mtoken == M_WSTETH:
789
+ balances[WSTETH_TOKEN_ID] += int(amount)
790
+ return (True, "success")
791
+
792
+ strategy.moonwell_adapter.unlend = AsyncMock(side_effect=mock_unlend)
793
+
794
+ async def mock_swap(
795
+ from_token_id,
796
+ to_token_id,
797
+ from_address,
798
+ amount,
799
+ slippage=0.0,
800
+ strategy_name=None,
801
+ **_,
802
+ ):
803
+ amt = int(amount)
804
+ # wstETH → USDC
805
+ balances[WSTETH_TOKEN_ID] -= amt
806
+ usd_out = (amt / 10**18) * 2000.0
807
+ usdc_out = int(usd_out * 10**6)
808
+ balances[USDC_TOKEN_ID] += usdc_out
809
+ return (True, {"to_amount": usdc_out})
810
+
811
+ strategy.brap_adapter.swap_from_token_ids = AsyncMock(side_effect=mock_swap)
812
+
813
+ # Also need to mock lend since partial_liquidate may try to re-lend leftover wstETH
814
+ strategy.moonwell_adapter.lend = AsyncMock(return_value=(True, "success"))
815
+
816
+ ok, msg = await strategy.partial_liquidate(usd_value=100.0)
817
+ assert ok
818
+ assert "available" in msg.lower()
819
+
820
+ # Should have redeemed mwstETH and swapped to USDC
821
+ assert strategy.moonwell_adapter.unlend.call_count == 1
822
+ assert strategy.moonwell_adapter.unlend.call_args.kwargs["mtoken"] == M_WSTETH
823
+ assert strategy.brap_adapter.swap_from_token_ids.call_count >= 1
824
+
825
+
826
+ @pytest.mark.asyncio
827
+ async def test_partial_liquidate_uses_usdc_collateral_when_no_wsteth_excess(strategy):
828
+ """If wstETH collateral doesn't exceed debt, partial liquidation should redeem USDC collateral."""
829
+ # Token metadata
830
+ strategy.token_adapter.get_token = AsyncMock(
831
+ side_effect=lambda token_id: (
832
+ True,
833
+ {"decimals": 6} if token_id == USDC_TOKEN_ID else {"decimals": 18},
834
+ )
835
+ )
836
+ strategy.token_adapter.get_token_price = AsyncMock(
837
+ return_value=(True, {"current_price": 1.0})
838
+ )
839
+
840
+ balances: dict[str, int] = {USDC_TOKEN_ID: 0}
841
+
842
+ async def mock_get_balance(*, token_id: str, wallet_address: str):
843
+ return (True, balances.get(token_id, 0))
844
+
845
+ strategy.balance_adapter.get_balance = AsyncMock(side_effect=mock_get_balance)
846
+
847
+ totals_usd = {
848
+ f"Base_{M_WSTETH}": 100.0, # <= debt
849
+ f"Base_{M_USDC}": 500.0,
850
+ f"Base_{WETH}": -200.0,
851
+ }
852
+ strategy._aggregate_positions = AsyncMock(return_value=({}, totals_usd))
853
+
854
+ strategy.moonwell_adapter.get_collateral_factor = AsyncMock(
855
+ return_value=(True, 0.8)
856
+ )
857
+
858
+ async def mock_max_withdrawable(*, mtoken: str):
859
+ return (
860
+ True,
861
+ {
862
+ "cTokens_raw": 10**30,
863
+ "exchangeRate_raw": 10**18,
864
+ "conversion_factor": 1.0,
865
+ },
866
+ )
867
+
868
+ strategy.moonwell_adapter.max_withdrawable_mtoken = AsyncMock(
869
+ side_effect=mock_max_withdrawable
870
+ )
871
+
872
+ async def mock_unlend(*, mtoken: str, amount: int):
873
+ if mtoken == M_USDC:
874
+ balances[USDC_TOKEN_ID] += int(amount)
875
+ return (True, "success")
876
+
877
+ strategy.moonwell_adapter.unlend = AsyncMock(side_effect=mock_unlend)
878
+
879
+ ok, msg = await strategy.partial_liquidate(usd_value=50.0)
880
+ assert ok
881
+ assert "available" in msg.lower()
882
+
883
+ # Should redeem mUSDC and not need a swap
884
+ assert strategy.moonwell_adapter.unlend.call_count == 1
885
+ assert strategy.moonwell_adapter.unlend.call_args.kwargs["mtoken"] == M_USDC
886
+ assert not strategy.brap_adapter.swap_from_token_ids.called