wayfinder-paths 0.1.23__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 (122) hide show
  1. wayfinder_paths/adapters/balance_adapter/adapter.py +250 -0
  2. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  3. wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -11
  4. wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
  5. wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
  6. wayfinder_paths/adapters/boros_adapter/client.py +476 -0
  7. wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
  8. wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
  9. wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
  10. wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
  11. wayfinder_paths/adapters/boros_adapter/types.py +70 -0
  12. wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
  13. wayfinder_paths/adapters/brap_adapter/adapter.py +1 -1
  14. wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
  15. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +161 -26
  16. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
  17. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +77 -13
  18. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
  19. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +585 -61
  20. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
  21. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
  22. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
  23. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
  24. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
  25. wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
  26. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
  27. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
  28. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
  29. wayfinder_paths/adapters/moonwell_adapter/adapter.py +592 -400
  30. wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
  31. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +126 -219
  32. wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
  33. wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
  34. wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
  35. wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
  36. wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
  37. wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
  38. wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
  39. wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
  40. wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
  41. wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
  42. wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
  43. wayfinder_paths/adapters/token_adapter/examples.json +0 -4
  44. wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
  45. wayfinder_paths/conftest.py +24 -17
  46. wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
  47. wayfinder_paths/core/adapters/models.py +17 -7
  48. wayfinder_paths/core/clients/BRAPClient.py +1 -1
  49. wayfinder_paths/core/clients/TokenClient.py +47 -1
  50. wayfinder_paths/core/clients/WayfinderClient.py +1 -2
  51. wayfinder_paths/core/clients/protocols.py +21 -22
  52. wayfinder_paths/core/clients/test_ledger_client.py +448 -0
  53. wayfinder_paths/core/config.py +12 -0
  54. wayfinder_paths/core/constants/__init__.py +15 -0
  55. wayfinder_paths/core/constants/base.py +6 -1
  56. wayfinder_paths/core/constants/contracts.py +39 -26
  57. wayfinder_paths/core/constants/erc20_abi.py +0 -1
  58. wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
  59. wayfinder_paths/core/constants/hyperliquid.py +16 -0
  60. wayfinder_paths/core/constants/moonwell_abi.py +0 -15
  61. wayfinder_paths/core/engine/manifest.py +66 -0
  62. wayfinder_paths/core/strategies/Strategy.py +0 -61
  63. wayfinder_paths/core/strategies/__init__.py +10 -1
  64. wayfinder_paths/core/strategies/opa_loop.py +167 -0
  65. wayfinder_paths/core/utils/test_transaction.py +289 -0
  66. wayfinder_paths/core/utils/transaction.py +44 -1
  67. wayfinder_paths/core/utils/web3.py +3 -0
  68. wayfinder_paths/mcp/__init__.py +5 -0
  69. wayfinder_paths/mcp/preview.py +185 -0
  70. wayfinder_paths/mcp/scripting.py +84 -0
  71. wayfinder_paths/mcp/server.py +52 -0
  72. wayfinder_paths/mcp/state/profile_store.py +195 -0
  73. wayfinder_paths/mcp/state/store.py +89 -0
  74. wayfinder_paths/mcp/test_scripting.py +267 -0
  75. wayfinder_paths/mcp/tools/__init__.py +0 -0
  76. wayfinder_paths/mcp/tools/balances.py +290 -0
  77. wayfinder_paths/mcp/tools/discovery.py +158 -0
  78. wayfinder_paths/mcp/tools/execute.py +770 -0
  79. wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
  80. wayfinder_paths/mcp/tools/quotes.py +288 -0
  81. wayfinder_paths/mcp/tools/run_script.py +286 -0
  82. wayfinder_paths/mcp/tools/strategies.py +188 -0
  83. wayfinder_paths/mcp/tools/tokens.py +46 -0
  84. wayfinder_paths/mcp/tools/wallets.py +354 -0
  85. wayfinder_paths/mcp/utils.py +129 -0
  86. wayfinder_paths/policies/hyperliquid.py +1 -1
  87. wayfinder_paths/policies/lifi.py +18 -0
  88. wayfinder_paths/policies/util.py +8 -2
  89. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +28 -119
  90. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
  91. wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
  92. wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
  93. wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
  94. wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
  95. wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
  96. wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
  97. wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
  98. wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
  99. wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
  100. wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
  101. wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
  102. wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
  103. wayfinder_paths/strategies/boros_hype_strategy/test_strategy.py +202 -0
  104. wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
  105. wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
  106. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +3 -12
  107. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +7 -29
  108. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +63 -40
  109. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
  110. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +0 -34
  111. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +11 -34
  112. wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
  113. wayfinder_paths/tests/test_test_coverage.py +1 -4
  114. wayfinder_paths-0.1.24.dist-info/METADATA +378 -0
  115. wayfinder_paths-0.1.24.dist-info/RECORD +185 -0
  116. {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.24.dist-info}/WHEEL +1 -1
  117. wayfinder_paths/scripts/create_strategy.py +0 -139
  118. wayfinder_paths/scripts/make_wallets.py +0 -142
  119. wayfinder_paths-0.1.23.dist-info/METADATA +0 -354
  120. wayfinder_paths-0.1.23.dist-info/RECORD +0 -120
  121. /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
  122. {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.24.dist-info}/LICENSE +0 -0
@@ -0,0 +1,770 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from typing import Any, Literal
6
+
7
+ from eth_account import Account
8
+ from eth_utils import to_checksum_address
9
+ from pydantic import BaseModel, Field, ValidationError, model_validator
10
+ from web3 import Web3
11
+
12
+ from wayfinder_paths.core.clients.BRAPClient import BRAPClient
13
+ from wayfinder_paths.core.clients.TokenClient import TokenClient
14
+ from wayfinder_paths.core.constants import ZERO_ADDRESS
15
+ from wayfinder_paths.core.constants.chains import CHAIN_CODE_TO_ID
16
+ from wayfinder_paths.core.constants.contracts import TOKENS_REQUIRING_APPROVAL_RESET
17
+ from wayfinder_paths.core.constants.erc20_abi import ERC20_ABI
18
+ from wayfinder_paths.core.constants.hyperliquid import (
19
+ ARBITRUM_USDC_ADDRESS,
20
+ ARBITRUM_USDC_TOKEN_ID,
21
+ HYPERLIQUID_BRIDGE_ADDRESS,
22
+ )
23
+ from wayfinder_paths.core.utils.tokens import (
24
+ build_approve_transaction,
25
+ get_token_allowance,
26
+ )
27
+ from wayfinder_paths.core.utils.transaction import send_transaction
28
+ from wayfinder_paths.mcp.preview import build_execution_preview
29
+ from wayfinder_paths.mcp.state.profile_store import WalletProfileStore
30
+ from wayfinder_paths.mcp.state.store import IdempotencyStore
31
+ from wayfinder_paths.mcp.utils import (
32
+ err,
33
+ find_wallet_by_label,
34
+ normalize_address,
35
+ ok,
36
+ parse_amount_to_raw,
37
+ sha256_json,
38
+ )
39
+
40
+
41
+ class ExecutionRequest(BaseModel):
42
+ kind: Literal["swap", "send", "hyperliquid_deposit"]
43
+ wallet_label: str = Field(..., description="wallets.json label (e.g. main)")
44
+
45
+ # Shared
46
+ amount: str = Field(..., description="Human units as a string (e.g. '1000')")
47
+ recipient: str | None = Field(
48
+ default=None, description="Destination address (defaults to sender for swap)"
49
+ )
50
+
51
+ # swap-only
52
+ from_token: str | None = Field(default=None, description="Token id/address query")
53
+ to_token: str | None = Field(default=None, description="Token id/address query")
54
+ slippage_bps: int = Field(default=50, description="Slippage in bps (50 = 0.50%)")
55
+ deadline_seconds: int = Field(
56
+ default=300, description="Best-effort TTL for quoting"
57
+ )
58
+
59
+ # send-only
60
+ token: str | None = Field(
61
+ default=None, description="Token id/address query, or 'native'"
62
+ )
63
+ chain_id: int | None = Field(
64
+ default=None, description="Required when token='native'"
65
+ )
66
+
67
+ @model_validator(mode="after")
68
+ def _validate_kind(self) -> ExecutionRequest:
69
+ if not self.wallet_label.strip():
70
+ raise ValueError("wallet_label is required")
71
+ if self.kind == "swap":
72
+ if not (self.from_token and self.to_token):
73
+ raise ValueError("swap requires from_token and to_token")
74
+ if self.slippage_bps < 0:
75
+ raise ValueError("slippage_bps must be >= 0")
76
+ if self.deadline_seconds <= 0:
77
+ raise ValueError("deadline_seconds must be positive")
78
+ if self.kind == "send":
79
+ if not self.token:
80
+ raise ValueError("send requires token")
81
+ if not self.recipient:
82
+ raise ValueError("send requires recipient")
83
+ if str(self.token).strip().lower() == "native" and self.chain_id is None:
84
+ raise ValueError("send requires chain_id when token='native'")
85
+ if self.kind == "hyperliquid_deposit":
86
+ # Hard-coded Bridge2 deposit: Arbitrum USDC -> Hyperliquid bridge address.
87
+ # Allow callers to omit token/recipient entirely; if provided, they must match.
88
+ if self.recipient and _addr_lower(self.recipient) != _addr_lower(
89
+ HYPERLIQUID_BRIDGE_ADDRESS
90
+ ):
91
+ raise ValueError("hyperliquid_deposit recipient must be bridge address")
92
+ if self.token and str(self.token).strip() != ARBITRUM_USDC_TOKEN_ID:
93
+ raise ValueError(
94
+ f"hyperliquid_deposit token must be {ARBITRUM_USDC_TOKEN_ID}"
95
+ )
96
+ if self.chain_id is not None and int(self.chain_id) != 42161:
97
+ raise ValueError(
98
+ "hyperliquid_deposit chain_id must be 42161 (Arbitrum)"
99
+ )
100
+ try:
101
+ amt = float(self.amount)
102
+ except (TypeError, ValueError):
103
+ amt = None
104
+ if amt is not None and amt < 5:
105
+ raise ValueError(
106
+ "hyperliquid_deposit amount must be >= 5 USDC (deposits below are lost)"
107
+ )
108
+ return self
109
+
110
+
111
+ def _slippage_float(bps: int) -> float:
112
+ return max(0.0, float(int(bps)) / 10_000.0)
113
+
114
+
115
+ def _addr_lower(addr: str | None) -> str | None:
116
+ if not addr:
117
+ return None
118
+ a = str(addr).strip()
119
+ return a.lower() if a else None
120
+
121
+
122
+ def _chain_id_from_token(meta: dict[str, Any]) -> int | None:
123
+ # Token payloads often include an internal/db `id` field. Only accept:
124
+ # - meta["chain_id"] (preferred)
125
+ # - meta["chain"]["chain_id"] / ["chainId"] / ["id"]
126
+ if meta.get("chain_id") is not None:
127
+ try:
128
+ return int(meta.get("chain_id"))
129
+ except (TypeError, ValueError):
130
+ return None
131
+
132
+ chain = meta.get("chain") or {}
133
+ if not isinstance(chain, dict):
134
+ return None
135
+
136
+ for key in ("chain_id", "chainId", "id"):
137
+ if chain.get(key) is None:
138
+ continue
139
+ try:
140
+ return int(chain.get(key))
141
+ except (TypeError, ValueError):
142
+ return None
143
+
144
+ return None
145
+
146
+
147
+ _SIMPLE_CHAIN_SUFFIX_RE = re.compile(r"^[a-z0-9]+\s+[a-z0-9-]+$", re.IGNORECASE)
148
+ _ASSET_CHAIN_SPLIT_RE = re.compile(
149
+ r"^(?P<asset>[a-z0-9]+)[- _](?P<chain>[a-z0-9-]+)$", re.IGNORECASE
150
+ )
151
+
152
+
153
+ def _normalize_token_query(query: str) -> str:
154
+ q = " ".join(str(query).strip().split())
155
+ if not q or "-" in q or "_" in q:
156
+ return q
157
+ if not _SIMPLE_CHAIN_SUFFIX_RE.match(q):
158
+ return q
159
+ asset, chain_code = q.rsplit(" ", 1)
160
+ if chain_code.lower() in CHAIN_CODE_TO_ID:
161
+ return f"{asset}-{chain_code}"
162
+ return q
163
+
164
+
165
+ def _is_eth_like_token(meta: dict[str, Any]) -> bool:
166
+ asset_id = str(meta.get("asset_id") or "").lower()
167
+ symbol = str(meta.get("symbol") or "").lower()
168
+ return asset_id == "ethereum" or symbol == "eth"
169
+
170
+
171
+ def _split_asset_chain(query: str) -> tuple[str, str] | None:
172
+ q = str(query).strip()
173
+ if not q:
174
+ return None
175
+ m = _ASSET_CHAIN_SPLIT_RE.match(q)
176
+ if not m:
177
+ return None
178
+ return m.group("asset").lower(), m.group("chain").lower()
179
+
180
+
181
+ async def _resolve_token_meta(
182
+ token_client: TokenClient,
183
+ *,
184
+ query: str,
185
+ ) -> tuple[str, dict[str, Any]]:
186
+ q = _normalize_token_query(query)
187
+ split = _split_asset_chain(q)
188
+ if split:
189
+ asset, chain_code = split
190
+ if asset in {"eth", "ethereum"}:
191
+ try:
192
+ gas_meta = await token_client.get_gas_token(chain_code)
193
+ if isinstance(gas_meta, dict) and _is_eth_like_token(gas_meta):
194
+ return q, gas_meta
195
+ except Exception:
196
+ pass
197
+ meta = await token_client.get_token_details(q)
198
+ return q, meta
199
+
200
+
201
+ def _infer_chain_code_from_query(query: str, meta: dict[str, Any]) -> str | None:
202
+ q = str(query).strip().lower()
203
+ if not q:
204
+ return None
205
+
206
+ candidates: set[str] = {str(k).lower() for k in CHAIN_CODE_TO_ID.keys()}
207
+ addrs = meta.get("addresses") or {}
208
+ if isinstance(addrs, dict):
209
+ candidates.update(str(k).lower() for k in addrs.keys())
210
+
211
+ best: str | None = None
212
+ for code in candidates:
213
+ if q.endswith(f"-{code}"):
214
+ if best is None or len(code) > len(best):
215
+ best = code
216
+ return best
217
+
218
+
219
+ def _address_for_chain(meta: dict[str, Any], chain_code: str) -> str | None:
220
+ addrs = meta.get("addresses") or {}
221
+ if not isinstance(addrs, dict):
222
+ return None
223
+ for key, val in addrs.items():
224
+ if str(key).lower() == chain_code and val:
225
+ return str(val)
226
+ return None
227
+
228
+
229
+ def _select_token_chain(
230
+ meta: dict[str, Any], *, query: str
231
+ ) -> tuple[int | None, str | None]:
232
+ chain_id = _chain_id_from_token(meta)
233
+ token_address = meta.get("address")
234
+
235
+ desired_chain = _infer_chain_code_from_query(query, meta)
236
+ if desired_chain:
237
+ addr = _address_for_chain(meta, desired_chain)
238
+ if addr:
239
+ token_address = addr
240
+ if desired_chain in CHAIN_CODE_TO_ID:
241
+ chain_id = CHAIN_CODE_TO_ID[desired_chain]
242
+
243
+ token_address_out = str(token_address).strip() if token_address else None
244
+ return chain_id, token_address_out
245
+
246
+
247
+ def _sanitize_for_json(obj: Any) -> Any:
248
+ """Recursively convert HexBytes and bytes to hex strings for JSON serialization."""
249
+ if hasattr(obj, "hex") and callable(obj.hex):
250
+ return obj.hex()
251
+ if isinstance(obj, bytes):
252
+ return obj.hex()
253
+ if isinstance(obj, dict):
254
+ return {k: _sanitize_for_json(v) for k, v in obj.items()}
255
+ if isinstance(obj, (list, tuple)):
256
+ return [_sanitize_for_json(v) for v in obj]
257
+ return obj
258
+
259
+
260
+ def _is_dry_run() -> bool:
261
+ return os.getenv("DRY_RUN", "").lower() in ("1", "true")
262
+
263
+
264
+ def _make_sign_callback(private_key: str):
265
+ account = Account.from_key(private_key)
266
+
267
+ async def sign_callback(transaction: dict) -> bytes:
268
+ signed = account.sign_transaction(transaction)
269
+ return signed.raw_transaction
270
+
271
+ return sign_callback
272
+
273
+
274
+ async def _broadcast(
275
+ sign_callback,
276
+ tx: dict[str, Any],
277
+ *,
278
+ chain_id: int,
279
+ ) -> tuple[bool, dict[str, Any]]:
280
+ if _is_dry_run():
281
+ return True, {"dry_run": True, "transaction": tx, "chain_id": chain_id}
282
+ try:
283
+ tx_hash = await send_transaction(tx, sign_callback, wait_for_receipt=True)
284
+ return True, {"tx_hash": tx_hash, "chain_id": chain_id}
285
+ except Exception as e:
286
+ return False, {"error": _sanitize_for_json(str(e)), "chain_id": chain_id}
287
+
288
+
289
+ async def _ensure_allowance(
290
+ *,
291
+ sign_callback,
292
+ chain_id: int,
293
+ token_address: str,
294
+ owner: str,
295
+ spender: str,
296
+ amount: int,
297
+ ) -> tuple[bool, list[dict[str, Any]]]:
298
+ # May emit 0-allowance clear tx for tokens requiring approval reset (e.g. USDT)
299
+ effects: list[dict[str, Any]] = []
300
+
301
+ allowance = await get_token_allowance(
302
+ token_address=token_address,
303
+ chain_id=chain_id,
304
+ owner_address=owner,
305
+ spender_address=spender,
306
+ )
307
+
308
+ token_checksum = to_checksum_address(token_address)
309
+ if (chain_id, token_checksum) in TOKENS_REQUIRING_APPROVAL_RESET and allowance > 0:
310
+ clear_tx = await build_approve_transaction(
311
+ from_address=owner,
312
+ chain_id=chain_id,
313
+ token_address=token_checksum,
314
+ spender_address=spender,
315
+ amount=0,
316
+ )
317
+ sent_ok, sent = await _broadcast(sign_callback, clear_tx, chain_id=chain_id)
318
+ effects.append({"type": "tx", "label": "approve_clear", **sent})
319
+ if not sent_ok:
320
+ return False, effects
321
+ # After clear, we will still submit a fresh approve below.
322
+
323
+ if allowance >= int(amount):
324
+ return True, effects
325
+
326
+ approve_tx = await build_approve_transaction(
327
+ from_address=owner,
328
+ chain_id=chain_id,
329
+ token_address=token_checksum,
330
+ spender_address=spender,
331
+ amount=int(amount),
332
+ )
333
+
334
+ sent_ok, sent = await _broadcast(sign_callback, approve_tx, chain_id=chain_id)
335
+ effects.append({"type": "tx", "label": "approve", **sent})
336
+ return sent_ok, effects
337
+
338
+
339
+ def _annotate_profile(
340
+ *,
341
+ address: str,
342
+ label: str,
343
+ protocol: str,
344
+ action: str,
345
+ status: str,
346
+ chain_id: int | None = None,
347
+ details: dict[str, Any] | None = None,
348
+ idempotency_key: str | None = None,
349
+ ) -> None:
350
+ store = WalletProfileStore.default()
351
+ store.annotate_safe(
352
+ address=address,
353
+ label=label,
354
+ protocol=protocol,
355
+ action=action,
356
+ tool="execute",
357
+ status=status,
358
+ chain_id=chain_id,
359
+ details=details,
360
+ idempotency_key=idempotency_key,
361
+ )
362
+
363
+
364
+ async def execute(
365
+ *,
366
+ request: dict[str, Any],
367
+ idempotency_key: str | None = None,
368
+ force: bool = False,
369
+ ) -> dict[str, Any]:
370
+ if isinstance(request, dict) and "kind" not in request and "action" in request:
371
+ # Back-compat: some callers use "action":"swap|send".
372
+ request = {**request, "kind": request.get("action")}
373
+ try:
374
+ req = ExecutionRequest.model_validate(request)
375
+ except ValidationError as exc:
376
+ return err("invalid_request", "execute.request validation failed", exc.errors())
377
+
378
+ store = IdempotencyStore.default()
379
+
380
+ tool_input = {
381
+ "request": req.model_dump(mode="json"),
382
+ "idempotency_key": idempotency_key,
383
+ }
384
+ preview_obj = build_execution_preview(tool_input)
385
+ preview_text = str(preview_obj.get("summary") or "").strip()
386
+
387
+ key = (idempotency_key or "").strip() or sha256_json(tool_input.get("request"))
388
+ prior = store.get(key)
389
+ if prior and not force:
390
+ # Ignore cached preflight errors (no side effects) so fixes/config changes can be retried.
391
+ if isinstance(prior, dict) and prior.get("ok") is False:
392
+ prior = None
393
+ else:
394
+ # Return the previous response, but annotate status for clarity.
395
+ try:
396
+ prior_result = prior.get("result") if isinstance(prior, dict) else None
397
+ if isinstance(prior_result, dict):
398
+ prior_result["status"] = "duplicate"
399
+ prior_result["idempotency_key"] = key
400
+ prior_result.setdefault("preview", preview_text)
401
+ except Exception:
402
+ pass
403
+ return prior
404
+
405
+ w = find_wallet_by_label(req.wallet_label)
406
+ if not w:
407
+ response = err("not_found", f"Unknown wallet_label: {req.wallet_label}")
408
+ store.put(key, tool_input, response)
409
+ return response
410
+
411
+ sender = normalize_address(w.get("address"))
412
+ pk = (
413
+ (w.get("private_key") or w.get("private_key_hex"))
414
+ if isinstance(w, dict)
415
+ else None
416
+ )
417
+ if not sender or not pk:
418
+ response = err(
419
+ "invalid_wallet",
420
+ "Wallet must include address and private_key_hex in wallets.json (local dev only)",
421
+ {"wallet_label": req.wallet_label},
422
+ )
423
+ store.put(key, tool_input, response)
424
+ return response
425
+
426
+ sign_callback = _make_sign_callback(pk)
427
+
428
+ effects: list[dict[str, Any]] = []
429
+
430
+ if req.kind == "swap":
431
+ rcpt = normalize_address(req.recipient) or sender
432
+ token_client = TokenClient()
433
+ brap_client = BRAPClient()
434
+ try:
435
+ from_q, from_meta = await _resolve_token_meta(
436
+ token_client, query=str(req.from_token)
437
+ )
438
+ to_q, to_meta = await _resolve_token_meta(
439
+ token_client, query=str(req.to_token)
440
+ )
441
+ except Exception as exc: # noqa: BLE001
442
+ response = err("token_error", str(exc))
443
+ store.put(key, tool_input, response)
444
+ return response
445
+
446
+ from_chain_id, from_token_addr = _select_token_chain(from_meta, query=from_q)
447
+ to_chain_id, to_token_addr = _select_token_chain(to_meta, query=to_q)
448
+ if from_chain_id is None or to_chain_id is None:
449
+ response = err(
450
+ "invalid_token",
451
+ "Could not resolve chain_id for one or more tokens",
452
+ {"from_chain_id": from_chain_id, "to_chain_id": to_chain_id},
453
+ )
454
+ store.put(key, tool_input, response)
455
+ return response
456
+ if not from_token_addr or not to_token_addr:
457
+ response = err(
458
+ "invalid_token",
459
+ "Could not resolve token address for one or more tokens",
460
+ {
461
+ "from_token_address": from_token_addr,
462
+ "to_token_address": to_token_addr,
463
+ },
464
+ )
465
+ store.put(key, tool_input, response)
466
+ return response
467
+
468
+ decimals = int(from_meta.get("decimals") or 18)
469
+ try:
470
+ amount_raw = parse_amount_to_raw(req.amount, decimals)
471
+ except ValueError as exc:
472
+ response = err("invalid_amount", str(exc))
473
+ store.put(key, tool_input, response)
474
+ return response
475
+
476
+ try:
477
+ quote_data = await brap_client.get_quote(
478
+ from_token=from_token_addr,
479
+ to_token=to_token_addr,
480
+ from_chain=from_chain_id,
481
+ to_chain=to_chain_id,
482
+ from_wallet=sender,
483
+ from_amount=str(amount_raw),
484
+ )
485
+ except Exception as exc: # noqa: BLE001
486
+ response = err("quote_error", str(exc))
487
+ store.put(key, tool_input, response)
488
+ return response
489
+
490
+ best_quote = (
491
+ quote_data.get("best_quote") if isinstance(quote_data, dict) else None
492
+ )
493
+ if not isinstance(best_quote, dict):
494
+ response = err(
495
+ "quote_error", "No best_quote returned", {"quote": quote_data}
496
+ )
497
+ store.put(key, tool_input, response)
498
+ return response
499
+
500
+ calldata = best_quote.get("calldata") or {}
501
+ if not isinstance(calldata, dict) or not calldata:
502
+ response = err(
503
+ "quote_error", "best_quote missing calldata", {"best_quote": best_quote}
504
+ )
505
+ store.put(key, tool_input, response)
506
+ return response
507
+
508
+ swap_tx = dict(calldata)
509
+ swap_tx["chainId"] = int(from_chain_id)
510
+ swap_tx["from"] = to_checksum_address(sender)
511
+
512
+ token_addr = from_token_addr
513
+ spender = swap_tx.get("to")
514
+ approve_amount = (
515
+ best_quote.get("input_amount")
516
+ or best_quote.get("inputAmount")
517
+ or best_quote.get("amount1")
518
+ or best_quote.get("amount")
519
+ )
520
+
521
+ if (
522
+ token_addr
523
+ and isinstance(token_addr, str)
524
+ and token_addr.strip()
525
+ and token_addr.lower() != ZERO_ADDRESS.lower()
526
+ and spender
527
+ and approve_amount is not None
528
+ ):
529
+ try:
530
+ need = int(approve_amount)
531
+ except Exception:
532
+ need = int(amount_raw)
533
+ ok_allow, approval_effects = await _ensure_allowance(
534
+ sign_callback=sign_callback,
535
+ chain_id=int(from_chain_id),
536
+ token_address=token_addr,
537
+ owner=to_checksum_address(sender),
538
+ spender=to_checksum_address(str(spender)),
539
+ amount=need,
540
+ )
541
+ effects.extend(approval_effects)
542
+ if not ok_allow:
543
+ response = ok(
544
+ {
545
+ "idempotency_key": key,
546
+ "status": "failed",
547
+ "dry_run": _is_dry_run(),
548
+ "kind": "swap",
549
+ "sender": sender,
550
+ "recipient": rcpt,
551
+ "preview": preview_text,
552
+ "effects": effects,
553
+ "raw": {"quote": quote_data},
554
+ }
555
+ )
556
+ store.put(key, tool_input, response)
557
+ return response
558
+
559
+ sent_ok, sent = await _broadcast(
560
+ sign_callback, swap_tx, chain_id=int(from_chain_id)
561
+ )
562
+ effects.append({"type": "tx", "label": "swap", **sent})
563
+
564
+ status = (
565
+ "confirmed"
566
+ if (sent_ok and not _is_dry_run())
567
+ else ("dry_run" if sent_ok else "failed")
568
+ )
569
+ response = ok(
570
+ {
571
+ "idempotency_key": key,
572
+ "status": status,
573
+ "dry_run": _is_dry_run(),
574
+ "kind": "swap",
575
+ "sender": sender,
576
+ "recipient": rcpt,
577
+ "preview": preview_text,
578
+ "effects": effects,
579
+ "raw": {"quote": quote_data, "best_quote": best_quote},
580
+ }
581
+ )
582
+ store.put(key, tool_input, response)
583
+
584
+ _annotate_profile(
585
+ address=sender,
586
+ label=req.wallet_label,
587
+ protocol="brap",
588
+ action="swap",
589
+ status=status,
590
+ chain_id=int(from_chain_id),
591
+ details={
592
+ "from_token": str(req.from_token),
593
+ "to_token": str(req.to_token),
594
+ "amount": req.amount,
595
+ },
596
+ idempotency_key=key,
597
+ )
598
+
599
+ return response
600
+
601
+ is_hl_deposit = req.kind == "hyperliquid_deposit"
602
+ recipient = (
603
+ normalize_address(HYPERLIQUID_BRIDGE_ADDRESS)
604
+ if is_hl_deposit
605
+ else normalize_address(req.recipient)
606
+ )
607
+ if not recipient:
608
+ response = err("invalid_request", "recipient is required for send")
609
+ store.put(key, tool_input, response)
610
+ return response
611
+
612
+ token_q = ARBITRUM_USDC_TOKEN_ID if is_hl_deposit else str(req.token or "").strip()
613
+ token_client = TokenClient()
614
+
615
+ if token_q.lower() == "native":
616
+ chain_id = int(req.chain_id or 0)
617
+ if chain_id <= 0:
618
+ response = err(
619
+ "invalid_request", "chain_id must be provided for native sends"
620
+ )
621
+ store.put(key, tool_input, response)
622
+ return response
623
+ # Native send uses 18 decimals.
624
+ try:
625
+ value = parse_amount_to_raw(req.amount, 18)
626
+ except ValueError as exc:
627
+ response = err("invalid_amount", str(exc))
628
+ store.put(key, tool_input, response)
629
+ return response
630
+
631
+ tx = {
632
+ "chainId": chain_id,
633
+ "from": to_checksum_address(sender),
634
+ "to": to_checksum_address(recipient),
635
+ "value": int(value),
636
+ }
637
+ sent_ok, sent = await _broadcast(sign_callback, tx, chain_id=chain_id)
638
+ effects.append({"type": "tx", "label": "send_native", **sent})
639
+ status = (
640
+ "confirmed"
641
+ if (sent_ok and not _is_dry_run())
642
+ else ("dry_run" if sent_ok else "failed")
643
+ )
644
+ response = ok(
645
+ {
646
+ "idempotency_key": key,
647
+ "status": status,
648
+ "dry_run": _is_dry_run(),
649
+ "kind": req.kind,
650
+ "sender": sender,
651
+ "recipient": recipient,
652
+ "preview": preview_text,
653
+ "effects": effects,
654
+ "raw": {"transaction": tx},
655
+ }
656
+ )
657
+ store.put(key, tool_input, response)
658
+ _annotate_profile(
659
+ address=sender,
660
+ label=req.wallet_label,
661
+ protocol="balance",
662
+ action="send_native",
663
+ status=status,
664
+ chain_id=chain_id,
665
+ details={"recipient": recipient, "amount": req.amount},
666
+ idempotency_key=key,
667
+ )
668
+
669
+ return response
670
+
671
+ try:
672
+ token_meta = await token_client.get_token_details(token_q)
673
+ except Exception as exc: # noqa: BLE001
674
+ response = err("token_error", str(exc))
675
+ store.put(key, tool_input, response)
676
+ return response
677
+
678
+ token_address = str(token_meta.get("address") or "").strip()
679
+ chain_id = _chain_id_from_token(token_meta)
680
+ if not token_address or chain_id is None:
681
+ response = err(
682
+ "invalid_token", "Token missing address/chain_id", {"token": token_meta}
683
+ )
684
+ store.put(key, tool_input, response)
685
+ return response
686
+ if is_hl_deposit:
687
+ if int(chain_id) != 42161:
688
+ response = err(
689
+ "invalid_token",
690
+ "Hyperliquid deposits require Arbitrum USDC (chain_id 42161)",
691
+ {"token": token_meta, "chain_id": chain_id},
692
+ )
693
+ store.put(key, tool_input, response)
694
+ return response
695
+ if _addr_lower(token_address) != _addr_lower(ARBITRUM_USDC_ADDRESS):
696
+ response = err(
697
+ "invalid_token",
698
+ "Resolved USDC token address does not match expected Arbitrum USDC",
699
+ {"token": token_meta, "expected": ARBITRUM_USDC_ADDRESS},
700
+ )
701
+ store.put(key, tool_input, response)
702
+ return response
703
+
704
+ decimals = int(token_meta.get("decimals") or 18)
705
+ try:
706
+ amount_raw = parse_amount_to_raw(req.amount, decimals)
707
+ except ValueError as exc:
708
+ response = err("invalid_amount", str(exc))
709
+ store.put(key, tool_input, response)
710
+ return response
711
+
712
+ w3 = Web3()
713
+ contract = w3.eth.contract(
714
+ address=to_checksum_address(token_address), abi=ERC20_ABI
715
+ )
716
+ data = contract.encode_abi(
717
+ "transfer", args=[to_checksum_address(recipient), int(amount_raw)]
718
+ )
719
+ tx = {
720
+ "chainId": int(chain_id),
721
+ "from": to_checksum_address(sender),
722
+ "to": to_checksum_address(token_address),
723
+ "data": data,
724
+ "value": 0,
725
+ }
726
+
727
+ sent_ok, sent = await _broadcast(sign_callback, tx, chain_id=int(chain_id))
728
+ effects.append(
729
+ {
730
+ "type": "tx",
731
+ "label": "hyperliquid_deposit" if is_hl_deposit else "send_erc20",
732
+ **sent,
733
+ }
734
+ )
735
+
736
+ status = (
737
+ "confirmed"
738
+ if (sent_ok and not _is_dry_run())
739
+ else ("dry_run" if sent_ok else "failed")
740
+ )
741
+ response = ok(
742
+ {
743
+ "idempotency_key": key,
744
+ "status": status,
745
+ "dry_run": _is_dry_run(),
746
+ "kind": req.kind,
747
+ "sender": sender,
748
+ "recipient": recipient,
749
+ "preview": preview_text,
750
+ "effects": effects,
751
+ "raw": {"token": token_meta, "transaction": tx},
752
+ }
753
+ )
754
+ store.put(key, tool_input, response)
755
+ _annotate_profile(
756
+ address=sender,
757
+ label=req.wallet_label,
758
+ protocol="hyperliquid" if is_hl_deposit else "balance",
759
+ action="hyperliquid_deposit" if is_hl_deposit else "send_erc20",
760
+ status=status,
761
+ chain_id=int(chain_id),
762
+ details={
763
+ "recipient": recipient,
764
+ "amount": req.amount,
765
+ "token": token_q,
766
+ },
767
+ idempotency_key=key,
768
+ )
769
+
770
+ return response