wayfinder-paths 0.1.23__py3-none-any.whl → 0.1.25__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.
- wayfinder_paths/__init__.py +2 -0
- wayfinder_paths/adapters/balance_adapter/adapter.py +250 -0
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -11
- wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
- wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
- wayfinder_paths/adapters/boros_adapter/client.py +476 -0
- wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
- wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
- wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
- wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
- wayfinder_paths/adapters/boros_adapter/types.py +70 -0
- wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
- wayfinder_paths/adapters/brap_adapter/adapter.py +1 -1
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +161 -26
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +77 -13
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +585 -61
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
- wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
- wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +592 -400
- wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +126 -219
- wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
- wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
- wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
- wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
- wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
- wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
- wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
- wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
- wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
- wayfinder_paths/adapters/token_adapter/examples.json +0 -4
- wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
- wayfinder_paths/conftest.py +24 -17
- wayfinder_paths/core/__init__.py +2 -0
- wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
- wayfinder_paths/core/adapters/models.py +17 -7
- wayfinder_paths/core/clients/BRAPClient.py +1 -1
- wayfinder_paths/core/clients/TokenClient.py +47 -1
- wayfinder_paths/core/clients/WayfinderClient.py +1 -2
- wayfinder_paths/core/clients/protocols.py +21 -22
- wayfinder_paths/core/clients/test_ledger_client.py +448 -0
- wayfinder_paths/core/config.py +12 -0
- wayfinder_paths/core/constants/__init__.py +15 -0
- wayfinder_paths/core/constants/base.py +6 -1
- wayfinder_paths/core/constants/contracts.py +39 -26
- wayfinder_paths/core/constants/erc20_abi.py +0 -1
- wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
- wayfinder_paths/core/constants/hyperliquid.py +16 -0
- wayfinder_paths/core/constants/moonwell_abi.py +0 -15
- wayfinder_paths/core/engine/manifest.py +66 -0
- wayfinder_paths/core/strategies/Strategy.py +0 -61
- wayfinder_paths/core/strategies/__init__.py +10 -1
- wayfinder_paths/core/strategies/opa_loop.py +167 -0
- wayfinder_paths/core/utils/test_transaction.py +289 -0
- wayfinder_paths/core/utils/transaction.py +44 -1
- wayfinder_paths/core/utils/web3.py +3 -0
- wayfinder_paths/mcp/__init__.py +5 -0
- wayfinder_paths/mcp/preview.py +185 -0
- wayfinder_paths/mcp/scripting.py +84 -0
- wayfinder_paths/mcp/server.py +52 -0
- wayfinder_paths/mcp/state/profile_store.py +195 -0
- wayfinder_paths/mcp/state/store.py +89 -0
- wayfinder_paths/mcp/test_scripting.py +267 -0
- wayfinder_paths/mcp/tools/__init__.py +0 -0
- wayfinder_paths/mcp/tools/balances.py +290 -0
- wayfinder_paths/mcp/tools/discovery.py +158 -0
- wayfinder_paths/mcp/tools/execute.py +770 -0
- wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
- wayfinder_paths/mcp/tools/quotes.py +288 -0
- wayfinder_paths/mcp/tools/run_script.py +286 -0
- wayfinder_paths/mcp/tools/strategies.py +188 -0
- wayfinder_paths/mcp/tools/tokens.py +46 -0
- wayfinder_paths/mcp/tools/wallets.py +354 -0
- wayfinder_paths/mcp/utils.py +129 -0
- wayfinder_paths/policies/hyperliquid.py +1 -1
- wayfinder_paths/policies/lifi.py +18 -0
- wayfinder_paths/policies/util.py +8 -2
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +28 -119
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
- wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
- wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
- wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
- wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
- wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
- wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
- wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
- wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
- wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
- wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
- wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
- wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
- wayfinder_paths/strategies/boros_hype_strategy/test_strategy.py +202 -0
- wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
- wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +3 -12
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +7 -29
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +63 -40
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +0 -34
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +11 -34
- wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
- wayfinder_paths/tests/test_test_coverage.py +1 -4
- wayfinder_paths-0.1.25.dist-info/METADATA +377 -0
- wayfinder_paths-0.1.25.dist-info/RECORD +185 -0
- wayfinder_paths/scripts/create_strategy.py +0 -139
- wayfinder_paths/scripts/make_wallets.py +0 -142
- wayfinder_paths-0.1.23.dist-info/METADATA +0 -354
- wayfinder_paths-0.1.23.dist-info/RECORD +0 -120
- /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
- {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.25.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.25.dist-info}/WHEEL +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
|