wayfinder-paths 0.1.22__py3-none-any.whl → 0.1.24__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 (156) hide show
  1. wayfinder_paths/__init__.py +0 -4
  2. wayfinder_paths/adapters/balance_adapter/README.md +0 -1
  3. wayfinder_paths/adapters/balance_adapter/adapter.py +313 -167
  4. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  5. wayfinder_paths/adapters/balance_adapter/test_adapter.py +41 -124
  6. wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
  7. wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
  8. wayfinder_paths/adapters/boros_adapter/client.py +476 -0
  9. wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
  10. wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
  11. wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
  12. wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
  13. wayfinder_paths/adapters/boros_adapter/types.py +70 -0
  14. wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
  15. wayfinder_paths/adapters/brap_adapter/README.md +22 -75
  16. wayfinder_paths/adapters/brap_adapter/adapter.py +187 -576
  17. wayfinder_paths/adapters/brap_adapter/examples.json +21 -140
  18. wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
  19. wayfinder_paths/adapters/brap_adapter/test_adapter.py +6 -234
  20. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +180 -92
  21. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
  22. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +82 -14
  23. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
  24. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +586 -61
  25. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
  26. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
  27. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
  28. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
  29. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
  30. wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
  31. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
  32. wayfinder_paths/adapters/ledger_adapter/README.md +4 -1
  33. wayfinder_paths/adapters/ledger_adapter/adapter.py +3 -3
  34. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
  35. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
  36. wayfinder_paths/adapters/moonwell_adapter/adapter.py +649 -547
  37. wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
  38. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +160 -239
  39. wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
  40. wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
  41. wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
  42. wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
  43. wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
  44. wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
  45. wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
  46. wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
  47. wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
  48. wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
  49. wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
  50. wayfinder_paths/adapters/token_adapter/adapter.py +14 -0
  51. wayfinder_paths/adapters/token_adapter/examples.json +0 -4
  52. wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
  53. wayfinder_paths/conftest.py +24 -17
  54. wayfinder_paths/core/__init__.py +0 -3
  55. wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
  56. wayfinder_paths/core/adapters/models.py +17 -7
  57. wayfinder_paths/core/clients/BRAPClient.py +4 -1
  58. wayfinder_paths/core/clients/ClientManager.py +0 -7
  59. wayfinder_paths/core/clients/LedgerClient.py +196 -172
  60. wayfinder_paths/core/clients/TokenClient.py +47 -1
  61. wayfinder_paths/core/clients/WayfinderClient.py +1 -3
  62. wayfinder_paths/core/clients/__init__.py +0 -5
  63. wayfinder_paths/core/clients/protocols.py +21 -35
  64. wayfinder_paths/core/clients/test_ledger_client.py +448 -0
  65. wayfinder_paths/core/config.py +10 -162
  66. wayfinder_paths/core/constants/__init__.py +73 -2
  67. wayfinder_paths/core/constants/base.py +8 -17
  68. wayfinder_paths/core/constants/chains.py +36 -0
  69. wayfinder_paths/core/constants/contracts.py +52 -0
  70. wayfinder_paths/core/constants/erc20_abi.py +0 -1
  71. wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
  72. wayfinder_paths/core/constants/hyperliquid.py +16 -0
  73. wayfinder_paths/core/constants/moonwell_abi.py +0 -15
  74. wayfinder_paths/core/constants/tokens.py +9 -0
  75. wayfinder_paths/core/engine/manifest.py +66 -0
  76. wayfinder_paths/core/strategies/Strategy.py +0 -71
  77. wayfinder_paths/core/strategies/__init__.py +10 -1
  78. wayfinder_paths/core/strategies/opa_loop.py +167 -0
  79. wayfinder_paths/core/utils/evm_helpers.py +5 -15
  80. wayfinder_paths/core/utils/test_transaction.py +289 -0
  81. wayfinder_paths/core/utils/tokens.py +28 -0
  82. wayfinder_paths/core/utils/transaction.py +57 -8
  83. wayfinder_paths/core/utils/web3.py +8 -3
  84. wayfinder_paths/mcp/__init__.py +5 -0
  85. wayfinder_paths/mcp/preview.py +185 -0
  86. wayfinder_paths/mcp/scripting.py +84 -0
  87. wayfinder_paths/mcp/server.py +52 -0
  88. wayfinder_paths/mcp/state/profile_store.py +195 -0
  89. wayfinder_paths/mcp/state/store.py +89 -0
  90. wayfinder_paths/mcp/test_scripting.py +267 -0
  91. wayfinder_paths/mcp/tools/__init__.py +0 -0
  92. wayfinder_paths/mcp/tools/balances.py +290 -0
  93. wayfinder_paths/mcp/tools/discovery.py +158 -0
  94. wayfinder_paths/mcp/tools/execute.py +770 -0
  95. wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
  96. wayfinder_paths/mcp/tools/quotes.py +288 -0
  97. wayfinder_paths/mcp/tools/run_script.py +286 -0
  98. wayfinder_paths/mcp/tools/strategies.py +188 -0
  99. wayfinder_paths/mcp/tools/tokens.py +46 -0
  100. wayfinder_paths/mcp/tools/wallets.py +354 -0
  101. wayfinder_paths/mcp/utils.py +129 -0
  102. wayfinder_paths/policies/enso.py +1 -2
  103. wayfinder_paths/policies/hyper_evm.py +6 -3
  104. wayfinder_paths/policies/hyperlend.py +1 -2
  105. wayfinder_paths/policies/hyperliquid.py +1 -1
  106. wayfinder_paths/policies/lifi.py +18 -0
  107. wayfinder_paths/policies/moonwell.py +12 -7
  108. wayfinder_paths/policies/prjx.py +1 -3
  109. wayfinder_paths/policies/util.py +8 -2
  110. wayfinder_paths/run_strategy.py +97 -300
  111. wayfinder_paths/strategies/basis_trading_strategy/constants.py +3 -1
  112. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +47 -133
  113. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
  114. wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
  115. wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
  116. wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
  117. wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
  118. wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
  119. wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
  120. wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
  121. wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
  122. wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
  123. wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
  124. wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
  125. wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
  126. wayfinder_paths/{templates/strategy → strategies/boros_hype_strategy}/test_strategy.py +99 -63
  127. wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
  128. wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
  129. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +15 -23
  130. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +27 -62
  131. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +84 -58
  132. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
  133. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +69 -164
  134. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +43 -76
  135. wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
  136. wayfinder_paths/tests/test_test_coverage.py +1 -4
  137. wayfinder_paths-0.1.24.dist-info/METADATA +378 -0
  138. wayfinder_paths-0.1.24.dist-info/RECORD +185 -0
  139. {wayfinder_paths-0.1.22.dist-info → wayfinder_paths-0.1.24.dist-info}/WHEEL +1 -1
  140. wayfinder_paths/core/clients/WalletClient.py +0 -41
  141. wayfinder_paths/core/engine/StrategyJob.py +0 -110
  142. wayfinder_paths/core/services/test_local_evm_txn.py +0 -145
  143. wayfinder_paths/scripts/create_strategy.py +0 -139
  144. wayfinder_paths/scripts/make_wallets.py +0 -142
  145. wayfinder_paths/templates/adapter/README.md +0 -150
  146. wayfinder_paths/templates/adapter/adapter.py +0 -16
  147. wayfinder_paths/templates/adapter/examples.json +0 -8
  148. wayfinder_paths/templates/adapter/test_adapter.py +0 -30
  149. wayfinder_paths/templates/strategy/README.md +0 -186
  150. wayfinder_paths/templates/strategy/examples.json +0 -11
  151. wayfinder_paths/templates/strategy/strategy.py +0 -35
  152. wayfinder_paths/tests/test_smoke_manifest.py +0 -63
  153. wayfinder_paths-0.1.22.dist-info/METADATA +0 -355
  154. wayfinder_paths-0.1.22.dist-info/RECORD +0 -129
  155. /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
  156. {wayfinder_paths-0.1.22.dist-info → wayfinder_paths-0.1.24.dist-info}/LICENSE +0 -0
@@ -0,0 +1,666 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ from datetime import UTC, datetime
5
+ from typing import Any
6
+ from unittest.mock import AsyncMock, patch
7
+
8
+ import httpx
9
+ import pytest
10
+ from web3 import Web3
11
+
12
+ from wayfinder_paths.adapters.pendle_adapter.adapter import PendleAdapter
13
+
14
+
15
+ class TestPendleAdapter:
16
+ def test_adapter_type(self):
17
+ adapter = PendleAdapter(config={})
18
+ assert adapter.adapter_type == "PENDLE"
19
+
20
+ @pytest.mark.asyncio
21
+ async def test_list_active_pt_yt_markets_filters_and_sort(self, monkeypatch):
22
+ fixed_now = datetime(2026, 1, 1, tzinfo=UTC)
23
+ monkeypatch.setattr(
24
+ "wayfinder_paths.adapters.pendle_adapter.adapter._now_utc",
25
+ lambda: fixed_now,
26
+ )
27
+
28
+ def markets_payload(chain_id: int) -> dict[str, Any]:
29
+ if chain_id == 42161:
30
+ return {
31
+ "markets": [
32
+ {
33
+ "chainId": 42161,
34
+ "name": "PT-USDC-Example",
35
+ "address": "42161-0xMarketA",
36
+ "pt": "42161-0xPTA",
37
+ "yt": "42161-0xYTA",
38
+ "sy": "42161-0xSYA",
39
+ "underlyingAsset": "42161-0xUSDC",
40
+ "expiry": "2026-02-01T00:00:00.000Z",
41
+ "details": {
42
+ "liquidity": 300_000,
43
+ "tradingVolume": 40_000,
44
+ "totalTvl": 500_000,
45
+ "impliedApy": 0.12,
46
+ "underlyingApy": 0.15,
47
+ },
48
+ },
49
+ {
50
+ "chainId": 42161,
51
+ "name": "TooSmallLiquidity",
52
+ "address": "42161-0xMarketB",
53
+ "pt": "42161-0xPTB",
54
+ "yt": "42161-0xYTB",
55
+ "sy": "42161-0xSYB",
56
+ "underlyingAsset": "42161-0xUSDT",
57
+ "expiry": "2026-02-01T00:00:00.000Z",
58
+ "details": {
59
+ "liquidity": 10_000,
60
+ "tradingVolume": 999_999,
61
+ "totalTvl": 500_000,
62
+ "impliedApy": 0.50,
63
+ "underlyingApy": 0.55,
64
+ },
65
+ },
66
+ ]
67
+ }
68
+ if chain_id == 8453:
69
+ return {
70
+ "markets": [
71
+ {
72
+ "chainId": 8453,
73
+ "name": "PT-DAI-Example",
74
+ "address": "8453-0xMarketC",
75
+ "pt": "8453-0xPTC",
76
+ "yt": "8453-0xYTC",
77
+ "sy": "8453-0xSYC",
78
+ "underlyingAsset": "8453-0xDAI",
79
+ "expiry": "2026-03-15T00:00:00.000Z",
80
+ "details": {
81
+ "liquidity": 800_000,
82
+ "tradingVolume": 30_000,
83
+ "totalTvl": 1_100_000,
84
+ "impliedApy": 0.10,
85
+ "underlyingApy": 0.13,
86
+ },
87
+ }
88
+ ]
89
+ }
90
+ return {"markets": []}
91
+
92
+ def handler(request: httpx.Request) -> httpx.Response:
93
+ assert request.url.path == "/core/v1/markets/all"
94
+ chain_id = int(request.url.params.get("chainId", "0"))
95
+ assert request.url.params.get("isActive") == "true"
96
+ return httpx.Response(200, json=markets_payload(chain_id))
97
+
98
+ client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
99
+ adapter = PendleAdapter(config={}, client=client)
100
+
101
+ rows = await adapter.list_active_pt_yt_markets(
102
+ chains=["arbitrum", "base"],
103
+ min_liquidity_usd=250_000,
104
+ min_volume_usd_24h=25_000,
105
+ min_days_to_expiry=7,
106
+ sort_by="fixed_apy",
107
+ descending=True,
108
+ )
109
+
110
+ await client.aclose()
111
+
112
+ # TooSmallLiquidity filtered out; remaining rows sorted by fixedApy desc
113
+ assert [r["marketAddress"] for r in rows] == ["0xMarketA", "0xMarketC"]
114
+ assert rows[0]["fixedApy"] == 0.12
115
+ assert rows[0]["floatingApy"] == pytest.approx(0.03)
116
+ assert rows[0]["underlyingAddress"] == "0xUSDC"
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_sdk_swap_v2_builds_query_params(self):
120
+ captured: dict[str, Any] = {}
121
+
122
+ def handler(request: httpx.Request) -> httpx.Response:
123
+ captured["path"] = request.url.path
124
+ captured["params"] = dict(request.url.params)
125
+ return httpx.Response(
126
+ 200,
127
+ json={
128
+ "tx": {"to": "0xRouter", "data": "0xdeadbeef", "value": "0"},
129
+ "tokenApprovals": [],
130
+ "data": {"amountOut": "123"},
131
+ },
132
+ )
133
+
134
+ client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
135
+ adapter = PendleAdapter(
136
+ config={}, client=client, base_url="https://api-v2.pendle.finance/core"
137
+ )
138
+
139
+ market = "0xMarketA"
140
+ await adapter.sdk_swap_v2(
141
+ chain="arbitrum",
142
+ market_address=market,
143
+ receiver="0xReceiver",
144
+ slippage=0.01,
145
+ token_in="0xTokenIn",
146
+ token_out="0xTokenOut",
147
+ amount_in="1000",
148
+ enable_aggregator=True,
149
+ aggregators=["one", "two"],
150
+ additional_data=["impliedApy", "effectiveApy"],
151
+ need_scale=True,
152
+ )
153
+
154
+ await client.aclose()
155
+
156
+ assert captured["path"] == f"/core/v2/sdk/42161/markets/{market}/swap"
157
+ params = captured["params"]
158
+ assert params["receiver"] == "0xReceiver"
159
+ assert params["slippage"] == "0.01"
160
+ assert params["enableAggregator"] == "true"
161
+ assert params["aggregators"] == "one,two"
162
+ assert params["tokenIn"] == "0xTokenIn"
163
+ assert params["tokenOut"] == "0xTokenOut"
164
+ assert params["amountIn"] == "1000"
165
+ assert params["additionalData"] == "impliedApy,effectiveApy"
166
+ assert params["needScale"] == "true"
167
+
168
+ @pytest.mark.asyncio
169
+ async def test_sdk_convert_v2_falls_back_to_get_and_builds_query_params(self):
170
+ captured: dict[str, Any] = {"methods": []}
171
+
172
+ def handler(request: httpx.Request) -> httpx.Response:
173
+ captured["methods"].append(request.method)
174
+
175
+ if request.method == "POST":
176
+ # Pendle currently responds 404 for POST in some environments.
177
+ return httpx.Response(
178
+ 404,
179
+ json={"message": "Not Found"},
180
+ headers={"x-ratelimit-remaining": "98"},
181
+ )
182
+
183
+ captured["path"] = request.url.path
184
+ captured["params"] = dict(request.url.params)
185
+ return httpx.Response(
186
+ 200,
187
+ json={
188
+ "action": "swap",
189
+ "inputs": [{"token": "0xTokenIn", "amount": "1000"}],
190
+ "requiredApprovals": [{"token": "0xTokenIn", "amount": "1000"}],
191
+ "routes": [
192
+ {
193
+ "tx": {
194
+ "to": "0xRouter",
195
+ "from": "0xFrom",
196
+ "data": "0xdead",
197
+ "value": "0",
198
+ },
199
+ "outputs": [{"token": "0xTokenOut", "amount": "123"}],
200
+ "data": {"effectiveApy": 0.1, "priceImpact": 0.01},
201
+ }
202
+ ],
203
+ },
204
+ headers={"x-computing-unit": "6", "x-ratelimit-remaining": "99"},
205
+ )
206
+
207
+ client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
208
+ adapter = PendleAdapter(
209
+ config={"strategy_wallet": {"address": "0x" + "a" * 40}},
210
+ client=client,
211
+ base_url="https://api-v2.pendle.finance/core",
212
+ )
213
+
214
+ resp = await adapter.sdk_convert_v2(
215
+ chain="arbitrum",
216
+ slippage=0.01,
217
+ receiver="0xReceiver",
218
+ inputs=[{"token": "0xTokenIn", "amount": "1000"}],
219
+ outputs=["0xTokenOut"],
220
+ enable_aggregator=True,
221
+ aggregators=["kyberswap"],
222
+ additional_data=["impliedApy", "effectiveApy", "priceImpact"],
223
+ need_scale=True,
224
+ use_limit_order=True,
225
+ )
226
+
227
+ await client.aclose()
228
+
229
+ assert captured["methods"] == ["POST", "GET"]
230
+ assert captured["path"] == "/core/v2/sdk/42161/convert"
231
+ params = captured["params"]
232
+ assert params["receiver"] == "0xReceiver"
233
+ assert params["slippage"] == "0.01"
234
+ assert params["tokensIn"] == "0xTokenIn"
235
+ assert params["amountsIn"] == "1000"
236
+ assert params["tokensOut"] == "0xTokenOut"
237
+ assert params["enableAggregator"] == "true"
238
+ assert params["aggregators"] == "kyberswap"
239
+ assert params["additionalData"] == "impliedApy,effectiveApy,priceImpact"
240
+ assert params["needScale"] == "true"
241
+ assert params["useLimitOrder"] == "true"
242
+ assert resp["rateLimit"]["computingUnit"] == 6
243
+
244
+ def test_build_convert_plan_selects_best_route(self):
245
+ adapter = PendleAdapter(
246
+ config={"strategy_wallet": {"address": "0x" + "a" * 40}}
247
+ )
248
+ convert_response = {
249
+ "action": "swap",
250
+ "requiredApprovals": [{"token": "0xTokenIn", "amount": "1000"}],
251
+ "routes": [
252
+ {
253
+ "tx": {
254
+ "to": "0x" + "b" * 40,
255
+ "from": "0x" + "a" * 40,
256
+ "data": "0x01",
257
+ "value": "0",
258
+ },
259
+ "outputs": [{"token": "0xTokenOut", "amount": "100"}],
260
+ },
261
+ {
262
+ "tx": {
263
+ "to": "0x" + "c" * 40,
264
+ "from": "0x" + "a" * 40,
265
+ "data": "0x02",
266
+ "value": "0",
267
+ },
268
+ "outputs": [{"token": "0xTokenOut", "amount": "200"}],
269
+ },
270
+ ],
271
+ }
272
+
273
+ plan = adapter.build_convert_plan(
274
+ chain=42161, convert_response=convert_response
275
+ )
276
+ assert plan["tx"]["to"].lower() == ("0x" + "c" * 40).lower()
277
+ assert plan["outputs"][0]["amount"] == "200"
278
+ assert plan["approvals"][0]["token"] == "0xTokenIn"
279
+
280
+ @pytest.mark.asyncio
281
+ async def test_execute_convert_success(self):
282
+ signing_callback = AsyncMock(return_value=b"\x00" * 65)
283
+
284
+ adapter = PendleAdapter(
285
+ config={"strategy_wallet": {"address": "0x" + "a" * 40}},
286
+ strategy_wallet_signing_callback=signing_callback,
287
+ )
288
+
289
+ adapter.sdk_convert_v2 = AsyncMock(
290
+ return_value={
291
+ "action": "swap",
292
+ "requiredApprovals": [{"token": "0x" + "c" * 40, "amount": "1000"}],
293
+ "routes": [
294
+ {
295
+ "tx": {
296
+ "to": "0x" + "b" * 40,
297
+ "from": "0x" + "a" * 40,
298
+ "data": "0xdead",
299
+ "value": "0",
300
+ },
301
+ "outputs": [{"token": "0x" + "d" * 40, "amount": "123"}],
302
+ }
303
+ ],
304
+ }
305
+ )
306
+
307
+ with (
308
+ patch(
309
+ "wayfinder_paths.adapters.pendle_adapter.adapter.get_token_balance",
310
+ new_callable=AsyncMock,
311
+ return_value=10_000,
312
+ ),
313
+ patch.object(
314
+ adapter,
315
+ "_ensure_allowance",
316
+ new_callable=AsyncMock,
317
+ return_value=(True, {"status": "already_approved"}),
318
+ ),
319
+ patch.object(
320
+ adapter,
321
+ "_send_tx",
322
+ new_callable=AsyncMock,
323
+ return_value=(True, "0xtxhash123"),
324
+ ),
325
+ ):
326
+ ok, res = await adapter.execute_convert(
327
+ chain="arbitrum",
328
+ slippage=0.01,
329
+ inputs=[{"token": "0x" + "c" * 40, "amount": "1000"}],
330
+ outputs=["0x" + "d" * 40],
331
+ receiver="0x" + "a" * 40,
332
+ )
333
+
334
+ assert ok is True
335
+ assert res["tx_hash"] == "0xtxhash123"
336
+ assert res["chainId"] == 42161
337
+
338
+ @pytest.mark.asyncio
339
+ async def test_build_best_pt_swap_tx_selects_best_effective_apy(self):
340
+ adapter = PendleAdapter(config={})
341
+ adapter.list_active_pt_yt_markets = AsyncMock(
342
+ return_value=[
343
+ {
344
+ "marketAddress": "0xM1",
345
+ "ptAddress": "0xPT1",
346
+ "fixedApy": 0.10,
347
+ "liquidityUsd": 500_000,
348
+ "volumeUsd24h": 100_000,
349
+ "daysToExpiry": 30.0,
350
+ },
351
+ {
352
+ "marketAddress": "0xM2",
353
+ "ptAddress": "0xPT2",
354
+ "fixedApy": 0.12,
355
+ "liquidityUsd": 500_000,
356
+ "volumeUsd24h": 100_000,
357
+ "daysToExpiry": 30.0,
358
+ },
359
+ ]
360
+ )
361
+
362
+ async def fake_swap(*, market_address: str, **_: Any) -> dict[str, Any]:
363
+ if market_address == "0xM1":
364
+ return {
365
+ "tx": {"to": "0xRouter", "data": "0x1"},
366
+ "data": {"effectiveApy": 0.08, "priceImpact": 0.001},
367
+ "tokenApprovals": [],
368
+ }
369
+ return {
370
+ "tx": {"to": "0xRouter", "data": "0x2"},
371
+ "data": {"effectiveApy": 0.09, "priceImpact": 0.005},
372
+ "tokenApprovals": [],
373
+ }
374
+
375
+ adapter.sdk_swap_v2 = AsyncMock(side_effect=fake_swap)
376
+
377
+ best = await adapter.build_best_pt_swap_tx(
378
+ chain=42161,
379
+ token_in="0xTokenIn",
380
+ amount_in="1000",
381
+ receiver="0xReceiver",
382
+ max_markets_to_quote=2,
383
+ prefer="effective_apy",
384
+ )
385
+
386
+ assert best["ok"] is True
387
+ assert best["selectedMarket"]["marketAddress"] == "0xM2"
388
+ assert best["quote"]["effectiveApy"] == 0.09
389
+
390
+ @pytest.mark.asyncio
391
+ async def test_get_full_user_state_onchain_multicall_filters_zeros(
392
+ self, monkeypatch
393
+ ):
394
+ w3 = Web3()
395
+ user = "0x" + ("a" * 40)
396
+ market1 = "0x" + ("1" * 40)
397
+ pt1 = "0x" + ("2" * 40)
398
+ yt1 = "0x" + ("3" * 40)
399
+ sy1 = "0x" + ("4" * 40)
400
+ u1 = "0x" + ("5" * 40)
401
+
402
+ market2 = "0x" + ("6" * 40)
403
+ pt2 = "0x" + ("7" * 40)
404
+ yt2 = "0x" + ("8" * 40)
405
+ sy2 = "0x" + ("9" * 40)
406
+ u2 = "0x" + ("b" * 40)
407
+
408
+ @asynccontextmanager
409
+ async def mock_web3_ctx(_chain_id):
410
+ yield w3
411
+
412
+ adapter = PendleAdapter(config={})
413
+ adapter.fetch_markets = AsyncMock(
414
+ return_value={
415
+ "markets": [
416
+ {
417
+ "chainId": 42161,
418
+ "name": "M1",
419
+ "address": f"42161-{market1}",
420
+ "pt": f"42161-{pt1}",
421
+ "yt": f"42161-{yt1}",
422
+ "sy": f"42161-{sy1}",
423
+ "underlyingAsset": f"42161-{u1}",
424
+ "expiry": "2026-02-01T00:00:00.000Z",
425
+ "isActive": True,
426
+ },
427
+ {
428
+ "chainId": 42161,
429
+ "name": "M2",
430
+ "address": f"42161-{market2}",
431
+ "pt": f"42161-{pt2}",
432
+ "yt": f"42161-{yt2}",
433
+ "sy": f"42161-{sy2}",
434
+ "underlyingAsset": f"42161-{u2}",
435
+ "expiry": "2026-02-01T00:00:00.000Z",
436
+ "isActive": True,
437
+ },
438
+ ]
439
+ }
440
+ )
441
+
442
+ # Order is: (pt bal, pt dec, yt bal, yt dec, lp bal, lp dec, sy bal, sy dec) per market.
443
+ adapter._multicall_uint256_chunked = AsyncMock(
444
+ return_value=[
445
+ 1000,
446
+ 18,
447
+ 0,
448
+ 18,
449
+ 0,
450
+ 18,
451
+ 0,
452
+ 18,
453
+ 0,
454
+ 18,
455
+ 0,
456
+ 18,
457
+ 0,
458
+ 18,
459
+ 0,
460
+ 18,
461
+ ]
462
+ )
463
+
464
+ monkeypatch.setattr(
465
+ "wayfinder_paths.adapters.pendle_adapter.adapter.web3_from_chain_id",
466
+ mock_web3_ctx,
467
+ )
468
+
469
+ ok, state = await adapter.get_full_user_state(chain=42161, account=user)
470
+ assert ok is True
471
+ assert state["protocol"] == "pendle"
472
+ assert state["chainId"] == 42161
473
+ assert state["account"] == user
474
+ assert len(state["positions"]) == 1
475
+ assert state["positions"][0]["marketAddress"] == market1
476
+
477
+ @pytest.mark.asyncio
478
+ async def test_execute_swap_success(self):
479
+ """Test successful swap execution."""
480
+ signing_callback = AsyncMock(return_value=b"\x00" * 65)
481
+
482
+ router_addr = "0x" + "b" * 40
483
+ token_in_addr = "0x" + "c" * 40
484
+
485
+ adapter = PendleAdapter(
486
+ config={"strategy_wallet": {"address": "0x" + "a" * 40}},
487
+ strategy_wallet_signing_callback=signing_callback,
488
+ )
489
+
490
+ # Mock sdk_swap_v2 to return a valid quote
491
+ adapter.sdk_swap_v2 = AsyncMock(
492
+ return_value={
493
+ "tx": {"to": router_addr, "data": "0xdeadbeef", "value": "0"},
494
+ "tokenApprovals": [{"token": token_in_addr, "amount": "1000000"}],
495
+ "data": {"amountOut": "990000", "priceImpact": 0.001},
496
+ }
497
+ )
498
+
499
+ # Mock allowance and approval
500
+ with (
501
+ patch(
502
+ "wayfinder_paths.adapters.pendle_adapter.adapter.get_token_allowance",
503
+ new_callable=AsyncMock,
504
+ return_value=2**256 - 1, # Already approved
505
+ ),
506
+ patch(
507
+ "wayfinder_paths.adapters.pendle_adapter.adapter.send_transaction",
508
+ new_callable=AsyncMock,
509
+ return_value="0xtxhash123",
510
+ ) as mock_send,
511
+ ):
512
+ success, result = await adapter.execute_swap(
513
+ chain="arbitrum",
514
+ market_address="0x" + "d" * 40,
515
+ token_in=token_in_addr,
516
+ token_out="0x" + "e" * 40,
517
+ amount_in="1000000",
518
+ slippage=0.01,
519
+ )
520
+
521
+ assert success is True
522
+ assert result["tx_hash"] == "0xtxhash123"
523
+ assert result["chainId"] == 42161
524
+ assert "quote" in result
525
+ mock_send.assert_called_once()
526
+
527
+ @pytest.mark.asyncio
528
+ async def test_execute_swap_quote_fails(self):
529
+ """Test swap fails when quote returns invalid tx."""
530
+ adapter = PendleAdapter(
531
+ config={"strategy_wallet": {"address": "0x" + "a" * 40}},
532
+ strategy_wallet_signing_callback=AsyncMock(),
533
+ )
534
+
535
+ adapter.sdk_swap_v2 = AsyncMock(
536
+ return_value={"tx": None, "tokenApprovals": [], "data": {}}
537
+ )
538
+
539
+ success, result = await adapter.execute_swap(
540
+ chain="arbitrum",
541
+ market_address="0xMarket",
542
+ token_in="0xTokenIn",
543
+ token_out="0xPT",
544
+ amount_in="1000000",
545
+ )
546
+
547
+ assert success is False
548
+ assert result["stage"] == "quote"
549
+ assert "error" in result
550
+
551
+ @pytest.mark.asyncio
552
+ async def test_execute_swap_approval_fails(self):
553
+ """Test swap fails when approval fails."""
554
+ router_addr = "0x" + "b" * 40
555
+ token_in_addr = "0x" + "c" * 40
556
+
557
+ adapter = PendleAdapter(
558
+ config={"strategy_wallet": {"address": "0x" + "a" * 40}},
559
+ strategy_wallet_signing_callback=AsyncMock(return_value=b"\x00" * 65),
560
+ )
561
+
562
+ adapter.sdk_swap_v2 = AsyncMock(
563
+ return_value={
564
+ "tx": {"to": router_addr, "data": "0xdeadbeef"},
565
+ "tokenApprovals": [{"token": token_in_addr, "amount": "1000000"}],
566
+ "data": {},
567
+ }
568
+ )
569
+
570
+ with (
571
+ patch(
572
+ "wayfinder_paths.adapters.pendle_adapter.adapter.get_token_allowance",
573
+ new_callable=AsyncMock,
574
+ return_value=0, # No allowance
575
+ ),
576
+ patch(
577
+ "wayfinder_paths.adapters.pendle_adapter.adapter.build_approve_transaction",
578
+ new_callable=AsyncMock,
579
+ return_value={
580
+ "to": token_in_addr,
581
+ "data": "0x",
582
+ "chainId": 42161,
583
+ "from": "0x" + "a" * 40,
584
+ },
585
+ ),
586
+ patch(
587
+ "wayfinder_paths.adapters.pendle_adapter.adapter.send_transaction",
588
+ new_callable=AsyncMock,
589
+ side_effect=Exception("Approval tx failed"),
590
+ ),
591
+ ):
592
+ success, result = await adapter.execute_swap(
593
+ chain="arbitrum",
594
+ market_address="0x" + "d" * 40,
595
+ token_in=token_in_addr,
596
+ token_out="0x" + "e" * 40,
597
+ amount_in="1000000",
598
+ )
599
+
600
+ assert success is False
601
+ assert result["stage"] == "approval"
602
+
603
+ @pytest.mark.asyncio
604
+ async def test_execute_swap_requires_signing_callback(self):
605
+ """Test swap fails without signing callback."""
606
+ router_addr = "0x" + "b" * 40
607
+ token_in_addr = "0x" + "c" * 40
608
+
609
+ adapter = PendleAdapter(
610
+ config={"strategy_wallet": {"address": "0x" + "a" * 40}},
611
+ # No signing callback
612
+ )
613
+
614
+ # Include tokenApprovals so it fails during the approval stage
615
+ adapter.sdk_swap_v2 = AsyncMock(
616
+ return_value={
617
+ "tx": {"to": router_addr, "data": "0xdeadbeef"},
618
+ "tokenApprovals": [{"token": token_in_addr, "amount": "1000000"}],
619
+ "data": {},
620
+ }
621
+ )
622
+
623
+ with (
624
+ patch(
625
+ "wayfinder_paths.adapters.pendle_adapter.adapter.get_token_allowance",
626
+ new_callable=AsyncMock,
627
+ return_value=0, # No allowance, will try to approve
628
+ ),
629
+ patch(
630
+ "wayfinder_paths.adapters.pendle_adapter.adapter.build_approve_transaction",
631
+ new_callable=AsyncMock,
632
+ return_value={
633
+ "to": token_in_addr,
634
+ "data": "0xapprove",
635
+ "chainId": 42161,
636
+ },
637
+ ),
638
+ ):
639
+ success, result = await adapter.execute_swap(
640
+ chain="arbitrum",
641
+ market_address="0x" + "d" * 40,
642
+ token_in=token_in_addr,
643
+ token_out="0x" + "e" * 40,
644
+ amount_in="1000000",
645
+ )
646
+
647
+ assert success is False
648
+ assert result["stage"] == "approval"
649
+ assert "strategy_wallet_signing_callback" in result["details"]["error"]
650
+
651
+ @pytest.mark.asyncio
652
+ async def test_execute_swap_requires_strategy_wallet(self):
653
+ """Test swap fails without strategy_wallet address."""
654
+ adapter = PendleAdapter(
655
+ config={}, # No strategy_wallet
656
+ strategy_wallet_signing_callback=AsyncMock(),
657
+ )
658
+
659
+ with pytest.raises(ValueError, match="strategy_wallet address is required"):
660
+ await adapter.execute_swap(
661
+ chain="arbitrum",
662
+ market_address="0xMarket",
663
+ token_in="0xTokenIn",
664
+ token_out="0xPT",
665
+ amount_in="1000000",
666
+ )
@@ -0,0 +1,6 @@
1
+ schema_version: "0.1"
2
+ entrypoint: "adapters.pool_adapter.adapter.PoolAdapter"
3
+ capabilities:
4
+ - "pool.read"
5
+ - "pool.discover"
6
+ dependencies: []
@@ -59,6 +59,20 @@ class TokenAdapter(BaseAdapter):
59
59
  self.logger.error(f"Error getting token price for {token_id}: {e}")
60
60
  return (False, str(e))
61
61
 
62
+ async def get_amount_usd(
63
+ self,
64
+ token_id: str | None,
65
+ raw_amount: int | str | None,
66
+ decimals: int = 18,
67
+ ) -> float | None:
68
+ if raw_amount is None or token_id is None:
69
+ return None
70
+ success, price_data = await self.get_token_price(token_id)
71
+ if not success or not isinstance(price_data, dict):
72
+ return None
73
+ price = price_data.get("current_price", 0.0)
74
+ return price * float(raw_amount) / 10 ** int(decimals)
75
+
62
76
  async def get_gas_token(self, chain_code: str) -> tuple[bool, GasToken | str]:
63
77
  try:
64
78
  data = await self.token_client.get_gas_token(chain_code)