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,288 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from wayfinder_paths.core.clients.BRAPClient import BRAPClient
7
+ from wayfinder_paths.core.clients.TokenClient import TokenClient
8
+ from wayfinder_paths.core.constants.chains import CHAIN_CODE_TO_ID
9
+ from wayfinder_paths.mcp.utils import (
10
+ err,
11
+ find_wallet_by_label,
12
+ normalize_address,
13
+ ok,
14
+ parse_amount_to_raw,
15
+ )
16
+
17
+
18
+ def _chain_id(token: dict[str, Any]) -> int | None:
19
+ # Token payloads often include a database/internal `id` field; do not treat that as a chain id.
20
+ if token.get("chain_id") is not None:
21
+ try:
22
+ return int(token.get("chain_id"))
23
+ except (TypeError, ValueError):
24
+ return None
25
+
26
+ chain = token.get("chain") or {}
27
+ if not isinstance(chain, dict):
28
+ return None
29
+
30
+ for key in ("chain_id", "chainId", "id"):
31
+ if chain.get(key) is None:
32
+ continue
33
+ try:
34
+ return int(chain.get(key))
35
+ except (TypeError, ValueError):
36
+ return None
37
+
38
+ return None
39
+
40
+
41
+ _SIMPLE_CHAIN_SUFFIX_RE = re.compile(r"^[a-z0-9]+\s+[a-z0-9-]+$", re.IGNORECASE)
42
+ _ASSET_CHAIN_SPLIT_RE = re.compile(
43
+ r"^(?P<asset>[a-z0-9]+)[- _](?P<chain>[a-z0-9-]+)$", re.IGNORECASE
44
+ )
45
+
46
+
47
+ def _normalize_token_query(query: str) -> str:
48
+ q = " ".join(str(query).strip().split())
49
+ if not q or "-" in q or "_" in q:
50
+ return q
51
+ if not _SIMPLE_CHAIN_SUFFIX_RE.match(q):
52
+ return q
53
+ asset, chain_code = q.rsplit(" ", 1)
54
+ if chain_code.lower() in CHAIN_CODE_TO_ID:
55
+ return f"{asset}-{chain_code}"
56
+ return q
57
+
58
+
59
+ def _is_eth_like_token(meta: dict[str, Any]) -> bool:
60
+ asset_id = str(meta.get("asset_id") or "").lower()
61
+ symbol = str(meta.get("symbol") or "").lower()
62
+ return asset_id == "ethereum" or symbol == "eth"
63
+
64
+
65
+ def _split_asset_chain(query: str) -> tuple[str, str] | None:
66
+ q = str(query).strip()
67
+ if not q:
68
+ return None
69
+ m = _ASSET_CHAIN_SPLIT_RE.match(q)
70
+ if not m:
71
+ return None
72
+ return m.group("asset").lower(), m.group("chain").lower()
73
+
74
+
75
+ async def _resolve_token_meta(
76
+ token_client: TokenClient,
77
+ *,
78
+ query: str,
79
+ ) -> tuple[str, dict[str, Any]]:
80
+ q = _normalize_token_query(query)
81
+ split = _split_asset_chain(q)
82
+ if split:
83
+ asset, chain_code = split
84
+ if asset in {"eth", "ethereum"}:
85
+ try:
86
+ gas_meta = await token_client.get_gas_token(chain_code)
87
+ if isinstance(gas_meta, dict) and _is_eth_like_token(gas_meta):
88
+ return q, gas_meta
89
+ except Exception:
90
+ pass
91
+ meta = await token_client.get_token_details(q)
92
+ return q, meta
93
+
94
+
95
+ def _infer_chain_code_from_query(query: str, meta: dict[str, Any]) -> str | None:
96
+ q = str(query).strip().lower()
97
+ if not q:
98
+ return None
99
+
100
+ candidates: set[str] = {str(k).lower() for k in CHAIN_CODE_TO_ID.keys()}
101
+ addrs = meta.get("addresses") or {}
102
+ if isinstance(addrs, dict):
103
+ candidates.update(str(k).lower() for k in addrs.keys())
104
+
105
+ best: str | None = None
106
+ for code in candidates:
107
+ if q.endswith(f"-{code}"):
108
+ if best is None or len(code) > len(best):
109
+ best = code
110
+ return best
111
+
112
+
113
+ def _address_for_chain(meta: dict[str, Any], chain_code: str) -> str | None:
114
+ addrs = meta.get("addresses") or {}
115
+ if not isinstance(addrs, dict):
116
+ return None
117
+ for key, val in addrs.items():
118
+ if str(key).lower() == chain_code and val:
119
+ return str(val)
120
+ return None
121
+
122
+
123
+ def _select_token_chain(
124
+ meta: dict[str, Any], *, query: str
125
+ ) -> tuple[int | None, str | None]:
126
+ chain_id = _chain_id(meta)
127
+ token_address = meta.get("address")
128
+
129
+ desired_chain = _infer_chain_code_from_query(query, meta)
130
+ if desired_chain:
131
+ addr = _address_for_chain(meta, desired_chain)
132
+ if addr:
133
+ token_address = addr
134
+ if desired_chain in CHAIN_CODE_TO_ID:
135
+ chain_id = CHAIN_CODE_TO_ID[desired_chain]
136
+
137
+ token_address_out = str(token_address).strip() if token_address else None
138
+ return chain_id, token_address_out
139
+
140
+
141
+ def _slippage_float(slippage_bps: int) -> float:
142
+ return max(0.0, float(int(slippage_bps)) / 10_000.0)
143
+
144
+
145
+ async def quote_swap(
146
+ *,
147
+ wallet_label: str,
148
+ from_token: str,
149
+ to_token: str,
150
+ amount: str,
151
+ slippage_bps: int = 50,
152
+ recipient: str | None = None,
153
+ include_calldata: bool = False,
154
+ ) -> dict[str, Any]:
155
+ w = find_wallet_by_label(wallet_label)
156
+ if not w:
157
+ return err("not_found", f"Unknown wallet_label: {wallet_label}")
158
+ sender = normalize_address(w.get("address"))
159
+ if not sender:
160
+ return err("invalid_wallet", f"Wallet {wallet_label} missing address")
161
+
162
+ token_client = TokenClient()
163
+ brap_client = BRAPClient()
164
+
165
+ try:
166
+ from_q, from_meta = await _resolve_token_meta(token_client, query=from_token)
167
+ to_q, to_meta = await _resolve_token_meta(token_client, query=to_token)
168
+ except Exception as exc: # noqa: BLE001
169
+ return err("token_error", str(exc))
170
+
171
+ from_chain_id, from_token_addr = _select_token_chain(from_meta, query=from_q)
172
+ to_chain_id, to_token_addr = _select_token_chain(to_meta, query=to_q)
173
+ if from_chain_id is None or to_chain_id is None:
174
+ return err(
175
+ "invalid_token",
176
+ "Could not resolve chain_id for one or more tokens",
177
+ {"from_chain_id": from_chain_id, "to_chain_id": to_chain_id},
178
+ )
179
+ if not from_token_addr or not to_token_addr:
180
+ return err(
181
+ "invalid_token",
182
+ "Could not resolve token address for one or more tokens",
183
+ {"from_token_address": from_token_addr, "to_token_address": to_token_addr},
184
+ )
185
+
186
+ decimals = int(from_meta.get("decimals") or 18)
187
+ try:
188
+ amount_raw = parse_amount_to_raw(amount, decimals)
189
+ except ValueError as exc:
190
+ return err("invalid_amount", str(exc))
191
+
192
+ rcpt = normalize_address(recipient) or sender
193
+ slip = _slippage_float(slippage_bps)
194
+
195
+ try:
196
+ data = await brap_client.get_quote(
197
+ from_token=from_token_addr,
198
+ to_token=to_token_addr,
199
+ from_chain_id=from_chain_id,
200
+ to_chain_id=to_chain_id,
201
+ from_wallet=sender,
202
+ amount1=str(amount_raw),
203
+ slippage=slip,
204
+ )
205
+ except Exception as exc: # noqa: BLE001
206
+ return err("quote_error", str(exc))
207
+
208
+ quotes_data = data.get("quotes", {}) if isinstance(data, dict) else {}
209
+ if not isinstance(quotes_data, dict):
210
+ quotes_data = {}
211
+ all_quotes = quotes_data.get("all_quotes", [])
212
+ if not isinstance(all_quotes, list):
213
+ all_quotes = []
214
+ best_quote = quotes_data.get("best_quote")
215
+ quote_count = quotes_data.get("quote_count", len(all_quotes))
216
+
217
+ providers: list[str] = []
218
+ seen: set[str] = set()
219
+ for q in all_quotes:
220
+ if not isinstance(q, dict):
221
+ continue
222
+ p = q.get("provider")
223
+ if not p:
224
+ continue
225
+ p_str = str(p)
226
+ if p_str in seen:
227
+ continue
228
+ seen.add(p_str)
229
+ providers.append(p_str)
230
+
231
+ best_out: dict[str, Any] | None = None
232
+ if isinstance(best_quote, dict):
233
+ calldata = best_quote.get("calldata")
234
+ calldata_len = len(calldata) if calldata else 0
235
+
236
+ best_out = {
237
+ "provider": best_quote.get("provider"),
238
+ "input_amount": best_quote.get("input_amount"),
239
+ "output_amount": best_quote.get("output_amount"),
240
+ "input_amount_usd": best_quote.get("input_amount_usd"),
241
+ "output_amount_usd": best_quote.get("output_amount_usd"),
242
+ "gas_estimate": best_quote.get("gas_estimate"),
243
+ "fee_estimate": best_quote.get("fee_estimate"),
244
+ "native_input": best_quote.get("native_input"),
245
+ "native_output": best_quote.get("native_output"),
246
+ "error": best_quote.get("error"),
247
+ "wrap_transaction": best_quote.get("wrap_transaction"),
248
+ "unwrap_transaction": best_quote.get("unwrap_transaction"),
249
+ }
250
+ if include_calldata:
251
+ best_out["calldata"] = calldata
252
+ else:
253
+ best_out["calldata_len"] = calldata_len
254
+
255
+ preview = (
256
+ f"Swap {amount} {from_meta.get('symbol')} → {to_meta.get('symbol')} "
257
+ f"(chain {from_chain_id} → {to_chain_id}). "
258
+ f"Sender={sender} Recipient={rcpt}. Slippage={slip:.2%}."
259
+ )
260
+ if rcpt.lower() != sender.lower():
261
+ preview = "⚠ RECIPIENT DIFFERS FROM SENDER\n" + preview
262
+
263
+ from_token_id = from_meta.get("token_id") or from_q
264
+ to_token_id = to_meta.get("token_id") or to_q
265
+
266
+ result = {
267
+ "preview": preview,
268
+ "quote": {
269
+ "best_quote": best_out,
270
+ "quote_count": quote_count,
271
+ "providers": providers,
272
+ },
273
+ "from_token": from_meta.get("symbol"),
274
+ "to_token": to_meta.get("symbol"),
275
+ "amount": str(amount),
276
+ "slippage_bps": int(slippage_bps),
277
+ "suggested_execute_request": {
278
+ "kind": "swap",
279
+ "wallet_label": wallet_label,
280
+ "from_token": from_token_id,
281
+ "to_token": to_token_id,
282
+ "amount": str(amount),
283
+ "slippage_bps": int(slippage_bps),
284
+ "recipient": rcpt,
285
+ },
286
+ }
287
+
288
+ return ok(result)
@@ -0,0 +1,286 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import os
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from wayfinder_paths.mcp.preview import build_run_script_preview
12
+ from wayfinder_paths.mcp.state.profile_store import WalletProfileStore
13
+ from wayfinder_paths.mcp.state.store import IdempotencyStore
14
+ from wayfinder_paths.mcp.utils import (
15
+ err,
16
+ find_wallet_by_label,
17
+ ok,
18
+ repo_root,
19
+ sha256_json,
20
+ )
21
+
22
+
23
+ def _runs_root() -> Path:
24
+ candidate = (os.getenv("WAYFINDER_RUNS_DIR") or ".wayfinder_runs").strip()
25
+ p = Path(candidate)
26
+ if not p.is_absolute():
27
+ p = repo_root() / p
28
+ return p.resolve(strict=False)
29
+
30
+
31
+ def _resolve_script_path(script_path: str) -> tuple[bool, Path | dict[str, Any]]:
32
+ raw = str(script_path).strip()
33
+ if not raw:
34
+ return False, {"code": "invalid_request", "message": "script_path is required"}
35
+
36
+ p = Path(raw)
37
+ if not p.is_absolute():
38
+ p = repo_root() / p
39
+ resolved = p.resolve(strict=False)
40
+
41
+ runs_root = _runs_root()
42
+ try:
43
+ resolved.relative_to(runs_root)
44
+ except ValueError:
45
+ return (
46
+ False,
47
+ {
48
+ "code": "invalid_request",
49
+ "message": "script_path must be inside the local runs directory",
50
+ "details": {
51
+ "runs_root": str(runs_root),
52
+ "script_path": str(resolved),
53
+ },
54
+ },
55
+ )
56
+
57
+ if not resolved.exists():
58
+ return (
59
+ False,
60
+ {
61
+ "code": "not_found",
62
+ "message": "Script file not found",
63
+ "details": {"script_path": str(resolved)},
64
+ },
65
+ )
66
+
67
+ if resolved.suffix.lower() != ".py":
68
+ return (
69
+ False,
70
+ {
71
+ "code": "invalid_request",
72
+ "message": "Only .py scripts are supported",
73
+ "details": {"script_path": str(resolved)},
74
+ },
75
+ )
76
+
77
+ return True, resolved
78
+
79
+
80
+ def _sha256_file(path: Path) -> str:
81
+ h = hashlib.sha256()
82
+ with path.open("rb") as f:
83
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
84
+ h.update(chunk)
85
+ return h.hexdigest()
86
+
87
+
88
+ def _truncate(text: str, *, max_chars: int = 20_000) -> str:
89
+ if len(text) <= max_chars:
90
+ return text
91
+ return text[: max_chars - 3] + "..."
92
+
93
+
94
+ def _with_repo_pythonpath(env: dict[str, str]) -> dict[str, str]:
95
+ root = str(repo_root())
96
+ cur = env.get("PYTHONPATH", "").strip()
97
+ env_out = dict(env)
98
+ env_out["PYTHONPATH"] = f"{root}{os.pathsep}{cur}" if cur else root
99
+ return env_out
100
+
101
+
102
+ _PROTOCOL_PATTERNS: list[tuple[str, str]] = [
103
+ ("moonwell", "moonwell"),
104
+ ("hyperliquid", "hyperliquid"),
105
+ ("hyperlend", "hyperlend"),
106
+ ("pendle", "pendle"),
107
+ ("boros", "boros"),
108
+ ("brap", "brap"),
109
+ ("swap", "brap"),
110
+ ]
111
+
112
+
113
+ def _infer_protocol_from_script(script_path: Path) -> str | None:
114
+ name = script_path.stem.lower()
115
+ for pattern, protocol in _PROTOCOL_PATTERNS:
116
+ if pattern in name:
117
+ return protocol
118
+ return None
119
+
120
+
121
+ def _annotate_script_run(
122
+ *,
123
+ script_path: str,
124
+ status: str,
125
+ wallet_label: str | None = None,
126
+ protocol: str | None = None,
127
+ idempotency_key: str | None = None,
128
+ ) -> None:
129
+ # Only annotates if wallet_label is provided - we must know the actual wallet
130
+ if not protocol:
131
+ return
132
+
133
+ if not wallet_label:
134
+ return
135
+
136
+ wallet = find_wallet_by_label(wallet_label)
137
+ if not wallet:
138
+ return
139
+
140
+ address = wallet.get("address")
141
+ if not address:
142
+ return
143
+
144
+ store = WalletProfileStore.default()
145
+ store.annotate_safe(
146
+ address=address,
147
+ label=wallet_label,
148
+ protocol=protocol,
149
+ action="run_script",
150
+ tool="run_script",
151
+ status=status,
152
+ details={"script_path": script_path},
153
+ idempotency_key=idempotency_key,
154
+ )
155
+
156
+
157
+ async def run_script(
158
+ *,
159
+ script_path: str,
160
+ args: list[str] | None = None,
161
+ dry_run: bool = True,
162
+ timeout_s: int = 600,
163
+ env: dict[str, str] | None = None,
164
+ idempotency_key: str | None = None,
165
+ force: bool = False,
166
+ wallet_label: str | None = None,
167
+ ) -> dict[str, Any]:
168
+ ok_path, resolved_or_error = _resolve_script_path(script_path)
169
+ if not ok_path:
170
+ payload = resolved_or_error if isinstance(resolved_or_error, dict) else {}
171
+ return err(
172
+ payload.get("code") or "invalid_request",
173
+ payload.get("message") or "Invalid script_path",
174
+ payload.get("details"),
175
+ )
176
+ script = resolved_or_error
177
+
178
+ root = repo_root()
179
+ display_path = str(script)
180
+ try:
181
+ display_path = str(script.relative_to(root))
182
+ except ValueError:
183
+ pass
184
+
185
+ args_list: list[str] = []
186
+ if args:
187
+ args_list = [str(a) for a in args if str(a).strip()]
188
+
189
+ script_sha = None
190
+ try:
191
+ script_sha = _sha256_file(script)
192
+ except OSError:
193
+ pass
194
+
195
+ tool_input = {
196
+ "script_path": display_path,
197
+ "args": args_list,
198
+ "dry_run": bool(dry_run),
199
+ "timeout_s": int(timeout_s),
200
+ "env": env or {},
201
+ "script_sha256": script_sha,
202
+ }
203
+ preview_obj = build_run_script_preview(tool_input)
204
+ preview_text = str(preview_obj.get("summary") or "").strip()
205
+
206
+ store = IdempotencyStore.default()
207
+ key = (idempotency_key or "").strip() or sha256_json(tool_input)
208
+ prior = store.get(key)
209
+ if prior and not force:
210
+ try:
211
+ prior_result = prior.get("result") if isinstance(prior, dict) else None
212
+ if isinstance(prior_result, dict):
213
+ prior_result["status"] = "duplicate"
214
+ prior_result["idempotency_key"] = key
215
+ prior_result.setdefault("preview", preview_text)
216
+ except Exception:
217
+ pass
218
+ return prior
219
+
220
+ try:
221
+ timeout = max(1, int(timeout_s))
222
+ except (TypeError, ValueError):
223
+ timeout = 600
224
+
225
+ base_env = os.environ.copy()
226
+ if env:
227
+ base_env.update({str(k): str(v) for k, v in env.items()})
228
+ base_env["DRY_RUN"] = "1" if dry_run else "0"
229
+ exec_env = _with_repo_pythonpath(base_env)
230
+
231
+ start = time.time()
232
+ timed_out = False
233
+
234
+ proc = await asyncio.create_subprocess_exec(
235
+ sys.executable,
236
+ str(script),
237
+ *args_list,
238
+ stdout=asyncio.subprocess.PIPE,
239
+ stderr=asyncio.subprocess.PIPE,
240
+ cwd=str(root),
241
+ env=exec_env,
242
+ )
243
+
244
+ try:
245
+ stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
246
+ except TimeoutError:
247
+ timed_out = True
248
+ proc.kill()
249
+ stdout_b, stderr_b = await proc.communicate()
250
+
251
+ duration_s = time.time() - start
252
+ stdout = _truncate((stdout_b or b"").decode("utf-8", errors="replace"))
253
+ stderr = _truncate((stderr_b or b"").decode("utf-8", errors="replace"))
254
+
255
+ exit_code = int(proc.returncode or 0)
256
+ status = "timeout" if timed_out else ("completed" if exit_code == 0 else "failed")
257
+
258
+ response = ok(
259
+ {
260
+ "idempotency_key": key,
261
+ "status": status,
262
+ "dry_run": bool(dry_run),
263
+ "script_path": display_path,
264
+ "script_sha256": script_sha,
265
+ "args": args_list,
266
+ "timeout_s": timeout,
267
+ "duration_s": duration_s,
268
+ "exit_code": exit_code,
269
+ "stdout": stdout,
270
+ "stderr": stderr,
271
+ "preview": preview_text,
272
+ }
273
+ )
274
+ store.put(key, tool_input, response)
275
+
276
+ inferred_protocol = _infer_protocol_from_script(script)
277
+ if inferred_protocol and wallet_label:
278
+ _annotate_script_run(
279
+ script_path=display_path,
280
+ status=status,
281
+ wallet_label=wallet_label,
282
+ protocol=inferred_protocol,
283
+ idempotency_key=key,
284
+ )
285
+
286
+ return response