wayfinder-paths 0.1.29__py3-none-any.whl → 0.1.31__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 (43) hide show
  1. wayfinder_paths/adapters/boros_adapter/adapter.py +313 -12
  2. wayfinder_paths/adapters/boros_adapter/test_adapter.py +125 -14
  3. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +17 -3
  4. wayfinder_paths/adapters/hyperliquid_adapter/exchange.py +5 -5
  5. wayfinder_paths/adapters/hyperliquid_adapter/local_signer.py +2 -33
  6. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1 -1
  7. wayfinder_paths/adapters/hyperliquid_adapter/test_cancel_order.py +57 -0
  8. wayfinder_paths/adapters/hyperliquid_adapter/test_exchange_mid_prices.py +52 -0
  9. wayfinder_paths/adapters/hyperliquid_adapter/test_hyperliquid_sdk_live.py +64 -0
  10. wayfinder_paths/adapters/hyperliquid_adapter/util.py +9 -10
  11. wayfinder_paths/core/clients/PoolClient.py +1 -1
  12. wayfinder_paths/core/constants/hype_oft_abi.py +151 -0
  13. wayfinder_paths/core/strategies/Strategy.py +1 -2
  14. wayfinder_paths/mcp/tools/execute.py +48 -16
  15. wayfinder_paths/mcp/tools/hyperliquid.py +1 -13
  16. wayfinder_paths/mcp/tools/quotes.py +38 -124
  17. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +24 -0
  18. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +249 -29
  19. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +125 -15
  20. wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +57 -201
  21. wayfinder_paths/strategies/boros_hype_strategy/constants.py +1 -152
  22. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +29 -0
  23. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2 -0
  24. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/manifest.yaml +33 -0
  25. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2 -0
  26. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +23 -0
  27. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +2 -0
  28. wayfinder_paths/tests/test_manifests.py +93 -0
  29. wayfinder_paths/tests/test_mcp_balances.py +73 -0
  30. wayfinder_paths/tests/test_mcp_discovery.py +34 -0
  31. wayfinder_paths/tests/test_mcp_execute.py +146 -0
  32. wayfinder_paths/tests/test_mcp_hyperliquid_execute.py +69 -0
  33. wayfinder_paths/tests/test_mcp_idempotency_store.py +14 -0
  34. wayfinder_paths/tests/test_mcp_quote_swap.py +60 -0
  35. wayfinder_paths/tests/test_mcp_run_script.py +47 -0
  36. wayfinder_paths/tests/test_mcp_tokens.py +49 -0
  37. wayfinder_paths/tests/test_mcp_utils.py +35 -0
  38. wayfinder_paths/tests/test_mcp_wallets.py +38 -0
  39. {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/METADATA +2 -2
  40. {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/RECORD +42 -25
  41. {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/WHEEL +1 -1
  42. wayfinder_paths/core/types.py +0 -19
  43. {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/LICENSE +0 -0
@@ -0,0 +1,29 @@
1
+ schema_version: "0.1"
2
+ entrypoint: "wayfinder_paths.strategies.hyperlend_stable_yield_strategy.strategy.HyperlendStableYieldStrategy"
3
+ permissions:
4
+ policy: |
5
+ (wallet.id == 'FORMAT_WALLET_ID') AND (
6
+ # Allow HyperLend supply and withdraw
7
+ (action.type == 'hyperlend_supply') OR
8
+ (action.type == 'hyperlend_withdraw') OR
9
+ # Allow WHYPE wrap/unwrap
10
+ (action.type == 'whype_deposit') OR
11
+ (action.type == 'whype_withdraw') OR
12
+ # Allow swaps via supported routers (Enso, LiFi, PRJX)
13
+ (action.type == 'swap' AND action.chain_id == 999) OR
14
+ # Allow ERC20 approvals for routers
15
+ (action.type == 'erc20_approve') OR
16
+ # Allow withdrawals to main wallet
17
+ (action.type == 'erc20_transfer' AND action.to == main_wallet.address)
18
+ )
19
+ adapters:
20
+ - name: "BALANCE"
21
+ capabilities: ["wallet_read", "wallet_transfer"]
22
+ - name: "LEDGER"
23
+ capabilities: ["ledger.read", "ledger.write", "strategy.transactions"]
24
+ - name: "TOKEN"
25
+ capabilities: ["token.read"]
26
+ - name: "HYPERLEND"
27
+ capabilities: ["market.read", "supply", "withdraw", "rates"]
28
+ - name: "BRAP"
29
+ capabilities: ["swap.quote", "swap.execute"]
@@ -196,10 +196,12 @@ class HyperlendStableYieldStrategy(Strategy):
196
196
  main_wallet_signing_callback: Callable[[dict], Awaitable[str]] | None = None,
197
197
  strategy_wallet_signing_callback: Callable[[dict], Awaitable[str]]
198
198
  | None = None,
199
+ strategy_sign_typed_data: Callable[[dict], Awaitable[str]] | None = None,
199
200
  ):
200
201
  super().__init__(
201
202
  main_wallet_signing_callback=main_wallet_signing_callback,
202
203
  strategy_wallet_signing_callback=strategy_wallet_signing_callback,
204
+ strategy_sign_typed_data=strategy_sign_typed_data,
203
205
  )
204
206
  merged_config: dict[str, Any] = dict(config or {})
205
207
  if main_wallet is not None:
@@ -0,0 +1,33 @@
1
+ schema_version: "0.1"
2
+ entrypoint: "wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.MoonwellWstethLoopStrategy"
3
+ permissions:
4
+ policy: |
5
+ (wallet.id == 'FORMAT_WALLET_ID') AND (
6
+ # Allow Moonwell lending operations
7
+ (action.type == 'moonwell_mint') OR
8
+ (action.type == 'moonwell_redeem') OR
9
+ (action.type == 'moonwell_borrow') OR
10
+ (action.type == 'moonwell_repay') OR
11
+ (action.type == 'moonwell_enter_markets') OR
12
+ (action.type == 'moonwell_claim_rewards') OR
13
+ # Allow WETH wrap/unwrap
14
+ (action.type == 'weth_deposit') OR
15
+ (action.type == 'weth_withdraw') OR
16
+ # Allow swaps via Enso router
17
+ (action.type == 'swap' AND action.chain_id == 8453) OR
18
+ # Allow ERC20 approvals
19
+ (action.type == 'erc20_approve') OR
20
+ # Allow withdrawals to main wallet
21
+ (action.type == 'erc20_transfer' AND action.to == main_wallet.address)
22
+ )
23
+ adapters:
24
+ - name: "BALANCE"
25
+ capabilities: ["wallet_read", "wallet_transfer"]
26
+ - name: "LEDGER"
27
+ capabilities: ["ledger.read", "ledger.write", "strategy.transactions"]
28
+ - name: "TOKEN"
29
+ capabilities: ["token.read"]
30
+ - name: "MOONWELL"
31
+ capabilities: ["market.read", "supply", "withdraw", "borrow", "repay", "collateral"]
32
+ - name: "BRAP"
33
+ capabilities: ["swap.quote", "swap.execute"]
@@ -181,10 +181,12 @@ class MoonwellWstethLoopStrategy(Strategy):
181
181
  main_wallet_signing_callback: Callable[[dict], Awaitable[str]] | None = None,
182
182
  strategy_wallet_signing_callback: Callable[[dict], Awaitable[str]]
183
183
  | None = None,
184
+ strategy_sign_typed_data: Callable[[dict], Awaitable[str]] | None = None,
184
185
  ):
185
186
  super().__init__(
186
187
  main_wallet_signing_callback=main_wallet_signing_callback,
187
188
  strategy_wallet_signing_callback=strategy_wallet_signing_callback,
189
+ strategy_sign_typed_data=strategy_sign_typed_data,
188
190
  )
189
191
  merged_config: dict[str, Any] = dict(config or {})
190
192
  if main_wallet is not None:
@@ -0,0 +1,23 @@
1
+ schema_version: "0.1"
2
+ entrypoint: "wayfinder_paths.strategies.stablecoin_yield_strategy.strategy.StablecoinYieldStrategy"
3
+ permissions:
4
+ policy: |
5
+ (wallet.id == 'FORMAT_WALLET_ID') AND (
6
+ # Allow swaps via Enso router on Base
7
+ (action.type == 'swap' AND action.chain_id == 8453) OR
8
+ # Allow ERC20 approvals for routers
9
+ (action.type == 'erc20_approve') OR
10
+ # Allow withdrawals to main wallet
11
+ (action.type == 'erc20_transfer' AND action.to == main_wallet.address)
12
+ )
13
+ adapters:
14
+ - name: "BALANCE"
15
+ capabilities: ["wallet_read", "wallet_transfer"]
16
+ - name: "LEDGER"
17
+ capabilities: ["ledger.read", "ledger.write", "strategy.transactions"]
18
+ - name: "TOKEN"
19
+ capabilities: ["token.read"]
20
+ - name: "POOL"
21
+ capabilities: ["pool.read", "pool.search"]
22
+ - name: "BRAP"
23
+ capabilities: ["swap.quote", "swap.execute"]
@@ -155,10 +155,12 @@ class StablecoinYieldStrategy(Strategy):
155
155
  main_wallet_signing_callback: Callable[[dict], Awaitable[str]] | None = None,
156
156
  strategy_wallet_signing_callback: Callable[[dict], Awaitable[str]]
157
157
  | None = None,
158
+ strategy_sign_typed_data: Callable[[dict], Awaitable[str]] | None = None,
158
159
  ):
159
160
  super().__init__(
160
161
  main_wallet_signing_callback=main_wallet_signing_callback,
161
162
  strategy_wallet_signing_callback=strategy_wallet_signing_callback,
163
+ strategy_sign_typed_data=strategy_sign_typed_data,
162
164
  )
163
165
  merged_config: dict[str, Any] = dict(config or {})
164
166
  if main_wallet is not None:
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pytest
8
+ import yaml
9
+
10
+ from wayfinder_paths.core.engine.manifest import load_strategy_manifest
11
+
12
+
13
+ def _load_yaml(path: Path) -> dict[str, Any]:
14
+ data = yaml.safe_load(path.read_text())
15
+ assert isinstance(data, dict), f"manifest must be a mapping: {path}"
16
+ return data
17
+
18
+
19
+ def _import_entrypoint(entrypoint: str) -> None:
20
+ assert isinstance(entrypoint, str) and entrypoint.strip()
21
+ module_path, symbol = entrypoint.rsplit(".", 1)
22
+
23
+ last_exc: Exception | None = None
24
+ for candidate in (module_path, f"wayfinder_paths.{module_path}"):
25
+ try:
26
+ mod = importlib.import_module(candidate)
27
+ if not hasattr(mod, symbol):
28
+ raise AttributeError(f"{candidate} missing {symbol}")
29
+ return
30
+ except Exception as exc: # noqa: BLE001
31
+ last_exc = exc
32
+
33
+ raise AssertionError(f"Failed to import entrypoint: {entrypoint}") from last_exc
34
+
35
+
36
+ def test_all_adapters_have_manifest_yaml_and_entrypoint_imports():
37
+ adapters_dir = Path(__file__).parent.parent / "adapters"
38
+ if not adapters_dir.exists():
39
+ pytest.skip("Adapters directory not found")
40
+
41
+ missing: list[str] = []
42
+ for adapter_dir in sorted(adapters_dir.iterdir()):
43
+ if not adapter_dir.is_dir() or adapter_dir.name.startswith("_"):
44
+ continue
45
+ if not (adapter_dir / "adapter.py").exists():
46
+ continue
47
+
48
+ manifest_path = adapter_dir / "manifest.yaml"
49
+ if not manifest_path.exists():
50
+ missing.append(adapter_dir.name)
51
+ continue
52
+
53
+ manifest = _load_yaml(manifest_path)
54
+ assert manifest.get("schema_version"), (
55
+ f"Missing schema_version: {manifest_path}"
56
+ )
57
+ entrypoint = manifest.get("entrypoint")
58
+ assert entrypoint, f"Missing entrypoint: {manifest_path}"
59
+ _import_entrypoint(str(entrypoint))
60
+
61
+ caps = manifest.get("capabilities")
62
+ assert isinstance(caps, list), f"capabilities must be a list: {manifest_path}"
63
+
64
+ if missing:
65
+ pytest.fail(f"Adapters missing manifest.yaml: {', '.join(missing)}")
66
+
67
+
68
+ def test_all_strategies_have_manifest_yaml_and_validate():
69
+ strategies_dir = Path(__file__).parent.parent / "strategies"
70
+ if not strategies_dir.exists():
71
+ pytest.skip("Strategies directory not found")
72
+
73
+ missing: list[str] = []
74
+ for strat_dir in sorted(strategies_dir.iterdir()):
75
+ if not strat_dir.is_dir() or strat_dir.name.startswith("_"):
76
+ continue
77
+ if not (strat_dir / "strategy.py").exists():
78
+ continue
79
+
80
+ manifest_path = strat_dir / "manifest.yaml"
81
+ if not manifest_path.exists():
82
+ missing.append(strat_dir.name)
83
+ continue
84
+
85
+ # Use the canonical validator.
86
+ manifest = load_strategy_manifest(str(manifest_path))
87
+ assert manifest.schema_version
88
+ assert manifest.entrypoint
89
+
90
+ _import_entrypoint(manifest.entrypoint)
91
+
92
+ if missing:
93
+ pytest.fail(f"Strategies missing manifest.yaml: {', '.join(missing)}")
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import AsyncMock, patch
4
+
5
+ import pytest
6
+
7
+ from wayfinder_paths.mcp.tools.balances import _dedupe_ordered, balances
8
+
9
+
10
+ def test_dedupe_ordered_is_case_insensitive():
11
+ assert _dedupe_ordered(["ETH", "eth", "USDC", "usdc", "Eth"]) == ["ETH", "USDC"]
12
+
13
+
14
+ @pytest.mark.asyncio
15
+ async def test_balances_enriched_filters_solana_for_evm_wallet():
16
+ fake_client = AsyncMock()
17
+ fake_client.get_enriched_wallet_balances = AsyncMock(
18
+ return_value={
19
+ "balances": [
20
+ {"network": "base", "balanceUSD": 1.5},
21
+ {"network": "solana", "balanceUSD": 999.0},
22
+ {"network": "arbitrum", "balanceUSD": "2.0"},
23
+ ],
24
+ "total_balance_usd": 1002.5,
25
+ }
26
+ )
27
+
28
+ with patch(
29
+ "wayfinder_paths.mcp.tools.balances.BalanceClient", return_value=fake_client
30
+ ):
31
+ out = await balances(
32
+ "enriched",
33
+ wallet_address="0x000000000000000000000000000000000000dEaD",
34
+ include_solana=False,
35
+ )
36
+
37
+ assert out["ok"] is True
38
+ res = out["result"]
39
+ assert res["total_balance_usd"] == pytest.approx(3.5)
40
+ assert res["chain_breakdown"]["base"] == pytest.approx(1.5)
41
+ assert res["chain_breakdown"]["arbitrum"] == pytest.approx(2.0)
42
+ assert all(b["network"].lower() != "solana" for b in res["balances"])
43
+ assert res["filtered"]["original_count"] == 3
44
+ assert res["filtered"]["filtered_count"] == 2
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_balances_token_dedupes_and_returns_per_token_results():
49
+ fake_client = AsyncMock()
50
+
51
+ async def _get_token_balance(
52
+ *, token_id: str, wallet_address: str, human_readable: bool
53
+ ):
54
+ if token_id == "bad":
55
+ raise RuntimeError("boom")
56
+ return {"token_id": token_id, "balance": 1}
57
+
58
+ fake_client.get_token_balance = AsyncMock(side_effect=_get_token_balance)
59
+
60
+ with patch(
61
+ "wayfinder_paths.mcp.tools.balances.BalanceClient", return_value=fake_client
62
+ ):
63
+ out = await balances(
64
+ "token",
65
+ wallet_address="0x000000000000000000000000000000000000dEaD",
66
+ token_ids=["a", "A", "bad", "a"],
67
+ )
68
+
69
+ assert out["ok"] is True
70
+ rows = out["result"]["balances"]
71
+ assert [r["token_id"] for r in rows] == ["a", "bad"]
72
+ assert rows[0]["ok"] is True
73
+ assert rows[1]["ok"] is False
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from wayfinder_paths.mcp.tools.discovery import describe, discover
6
+
7
+
8
+ @pytest.mark.asyncio
9
+ async def test_discover_adapters_includes_hyperliquid():
10
+ out = await discover("adapter")
11
+ assert out["ok"] is True
12
+ items = out["result"]["items"]
13
+ names = {i["name"] for i in items}
14
+ assert "hyperliquid_adapter" in names
15
+
16
+
17
+ @pytest.mark.asyncio
18
+ async def test_discover_strategies_includes_basis_and_boros():
19
+ out = await discover("strategy")
20
+ assert out["ok"] is True
21
+ items = out["result"]["items"]
22
+ names = {i["name"] for i in items}
23
+ assert "boros_hype_strategy" in names
24
+ assert "basis_trading_strategy" in names
25
+
26
+
27
+ @pytest.mark.asyncio
28
+ async def test_describe_strategy_returns_manifest_and_readme_excerpt():
29
+ out = await describe("strategy", "boros_hype_strategy")
30
+ assert out["ok"] is True
31
+ res = out["result"]
32
+ assert res["kind"] == "strategy"
33
+ assert res["name"] == "boros_hype_strategy"
34
+ assert isinstance(res.get("manifest"), dict)
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ import pytest
7
+
8
+ from wayfinder_paths.mcp.tools.execute import execute
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_execute_validation_error_is_structured():
13
+ out = await execute(request={"kind": "swap", "wallet_label": "main"})
14
+ assert out["ok"] is False
15
+ assert out["error"]["code"] == "invalid_request"
16
+ assert isinstance(out["error"]["details"], list)
17
+
18
+
19
+ @pytest.mark.asyncio
20
+ async def test_execute_swap_dry_run_is_idempotent(tmp_path: Path, monkeypatch):
21
+ monkeypatch.setenv("DRY_RUN", "1")
22
+ monkeypatch.setenv("WAYFINDER_MCP_STATE_PATH", str(tmp_path / "mcp.sqlite3"))
23
+ monkeypatch.setenv("WAYFINDER_RUNS_DIR", str(tmp_path / "runs"))
24
+
25
+ wallet = {
26
+ "address": "0x000000000000000000000000000000000000dEaD",
27
+ "private_key_hex": "0x" + "11" * 32,
28
+ }
29
+
30
+ class FakeTokenClient:
31
+ async def get_token_details(self, query: str):
32
+ if query == "from":
33
+ return {
34
+ "token_id": "from",
35
+ "symbol": "FROM",
36
+ "decimals": 6,
37
+ "chain_id": 42161,
38
+ "address": "0x1111111111111111111111111111111111111111",
39
+ }
40
+ if query == "to":
41
+ return {
42
+ "token_id": "to",
43
+ "symbol": "TO",
44
+ "decimals": 6,
45
+ "chain_id": 42161,
46
+ "address": "0x2222222222222222222222222222222222222222",
47
+ }
48
+ raise AssertionError(f"unexpected token query: {query}")
49
+
50
+ async def get_gas_token(self, chain_code: str): # pragma: no cover
51
+ raise AssertionError("not used")
52
+
53
+ class FakeBRAPClient:
54
+ async def get_quote(self, **kwargs): # noqa: ANN003
55
+ assert kwargs["from_chain"] == 42161
56
+ assert kwargs["to_chain"] == 42161
57
+ assert kwargs["from_amount"] == "1000000"
58
+ return {
59
+ "quotes": [
60
+ {"provider": "brap_best"},
61
+ {"provider": "brap_alt"},
62
+ ],
63
+ "best_quote": {
64
+ "provider": "brap_best",
65
+ "input_amount": "1000000",
66
+ "calldata": {
67
+ "to": "0x" + "33" * 20,
68
+ "data": "0xdeadbeef",
69
+ "value": "0",
70
+ },
71
+ },
72
+ }
73
+
74
+ async def fake_ensure_allowance(**_kwargs): # noqa: ANN003
75
+ return True, {"tx_hash": "0xapprove"}
76
+
77
+ with (
78
+ patch(
79
+ "wayfinder_paths.mcp.tools.execute.find_wallet_by_label",
80
+ return_value=wallet,
81
+ ),
82
+ patch(
83
+ "wayfinder_paths.mcp.tools.execute.TokenClient",
84
+ return_value=FakeTokenClient(),
85
+ ),
86
+ patch(
87
+ "wayfinder_paths.mcp.tools.execute.BRAPClient",
88
+ return_value=FakeBRAPClient(),
89
+ ),
90
+ patch(
91
+ "wayfinder_paths.mcp.tools.execute.ensure_allowance",
92
+ new=AsyncMock(side_effect=fake_ensure_allowance),
93
+ ),
94
+ ):
95
+ req = {
96
+ "kind": "swap",
97
+ "wallet_label": "main",
98
+ "from_token": "from",
99
+ "to_token": "to",
100
+ "amount": "1",
101
+ "slippage_bps": 50,
102
+ }
103
+ out1 = await execute(request=req)
104
+ assert out1["ok"] is True
105
+ assert out1["result"]["status"] == "dry_run"
106
+ assert out1["result"]["kind"] == "swap"
107
+ assert any(e.get("label") == "approve" for e in out1["result"]["effects"])
108
+
109
+ out2 = await execute(request=req)
110
+ assert out2["ok"] is True
111
+ assert out2["result"]["status"] == "duplicate"
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_execute_does_not_cache_preflight_errors(tmp_path: Path, monkeypatch):
116
+ monkeypatch.setenv("DRY_RUN", "1")
117
+ monkeypatch.setenv("WAYFINDER_MCP_STATE_PATH", str(tmp_path / "mcp.sqlite3"))
118
+ monkeypatch.setenv("WAYFINDER_RUNS_DIR", str(tmp_path / "runs"))
119
+
120
+ req = {
121
+ "kind": "send",
122
+ "wallet_label": "main",
123
+ "token": "native",
124
+ "chain_id": 8453,
125
+ "recipient": "0x000000000000000000000000000000000000dEaD",
126
+ "amount": "0.01",
127
+ }
128
+
129
+ # First call: wallet missing -> ok=False error stored
130
+ with patch(
131
+ "wayfinder_paths.mcp.tools.execute.find_wallet_by_label", return_value=None
132
+ ):
133
+ out1 = await execute(request=req, idempotency_key="same")
134
+ assert out1["ok"] is False
135
+
136
+ # Second call with same idempotency key but now wallet exists -> should proceed
137
+ wallet = {
138
+ "address": "0x000000000000000000000000000000000000dEaD",
139
+ "private_key_hex": "0x" + "11" * 32,
140
+ }
141
+ with patch(
142
+ "wayfinder_paths.mcp.tools.execute.find_wallet_by_label", return_value=wallet
143
+ ):
144
+ out2 = await execute(request=req, idempotency_key="same")
145
+ assert out2["ok"] is True
146
+ assert out2["result"]["status"] == "dry_run"
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+
8
+ from wayfinder_paths.core.constants.hyperliquid import HYPE_FEE_WALLET
9
+ from wayfinder_paths.mcp.tools.hyperliquid import (
10
+ _resolve_builder_fee,
11
+ _resolve_perp_asset_id,
12
+ hyperliquid_execute,
13
+ )
14
+
15
+
16
+ def test_resolve_builder_fee_rejects_wrong_builder_wallet():
17
+ with pytest.raises(ValueError, match="config builder_fee\\.b must be"):
18
+ _resolve_builder_fee(
19
+ config={"builder_fee": {"b": "0x" + "00" * 20, "f": 10}},
20
+ builder_fee_tenths_bp=None,
21
+ )
22
+
23
+
24
+ def test_resolve_builder_fee_prefers_explicit_fee():
25
+ fee = _resolve_builder_fee(config={}, builder_fee_tenths_bp=7)
26
+ assert fee == {"b": HYPE_FEE_WALLET.lower(), "f": 7}
27
+
28
+
29
+ def test_resolve_perp_asset_id_accepts_coin_and_strips_perp_suffix():
30
+ class StubAdapter:
31
+ coin_to_asset = {"HYPE": 7}
32
+
33
+ ok, res = _resolve_perp_asset_id(StubAdapter(), coin="HYPE-perp", asset_id=None)
34
+ assert ok is True
35
+ assert res == 7
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_hyperliquid_execute_withdraw_dry_run_is_idempotent(
40
+ tmp_path: Path, monkeypatch
41
+ ):
42
+ monkeypatch.setenv("WAYFINDER_MCP_STATE_PATH", str(tmp_path / "mcp.sqlite3"))
43
+ monkeypatch.setenv("WAYFINDER_RUNS_DIR", str(tmp_path / "runs"))
44
+
45
+ wallet = {
46
+ "address": "0x000000000000000000000000000000000000dEaD",
47
+ "private_key_hex": "0x" + "11" * 32,
48
+ }
49
+
50
+ with (
51
+ patch(
52
+ "wayfinder_paths.mcp.tools.hyperliquid.find_wallet_by_label",
53
+ return_value=wallet,
54
+ ),
55
+ patch(
56
+ "wayfinder_paths.mcp.tools.hyperliquid.load_config_json", return_value={}
57
+ ),
58
+ ):
59
+ out1 = await hyperliquid_execute(
60
+ "withdraw", wallet_label="main", amount_usdc=10, dry_run=True
61
+ )
62
+ assert out1["ok"] is True
63
+ assert out1["result"]["status"] == "dry_run"
64
+
65
+ out2 = await hyperliquid_execute(
66
+ "withdraw", wallet_label="main", amount_usdc=10, dry_run=True
67
+ )
68
+ assert out2["ok"] is True
69
+ assert out2["result"]["status"] == "duplicate"
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from wayfinder_paths.mcp.state.store import IdempotencyStore
6
+
7
+
8
+ def test_idempotency_store_encodes_bytes(tmp_path: Path):
9
+ store = IdempotencyStore(tmp_path / "state.sqlite3")
10
+ store.put("k", {"req": b"\x01\x02"}, {"ok": True, "tx": b"\xaa"})
11
+ out = store.get("k")
12
+ assert out is not None
13
+ assert out["ok"] is True
14
+ assert out["tx"] == "aa"
@@ -163,3 +163,63 @@ async def test_quote_swap_can_include_calldata_when_requested():
163
163
  assert out["ok"] is True
164
164
  best = out["result"]["quote"]["best_quote"]
165
165
  assert best["calldata"] == calldata
166
+
167
+
168
+ @pytest.mark.asyncio
169
+ async def test_quote_swap_accepts_top_level_brap_shape():
170
+ fake_wallet = {"address": "0x000000000000000000000000000000000000dEaD"}
171
+
172
+ token_client = AsyncMock()
173
+ token_client.get_token_details = AsyncMock(
174
+ side_effect=[
175
+ {
176
+ "token_id": "usd-coin-arbitrum",
177
+ "asset_id": "usd-coin",
178
+ "symbol": "USDC",
179
+ "decimals": 6,
180
+ "chain_id": 42161,
181
+ "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
182
+ },
183
+ {
184
+ "token_id": "tether-arbitrum",
185
+ "asset_id": "tether",
186
+ "symbol": "USDT",
187
+ "decimals": 6,
188
+ "chain_id": 42161,
189
+ "address": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
190
+ },
191
+ ]
192
+ )
193
+
194
+ brap_client = AsyncMock()
195
+ brap_client.get_quote = AsyncMock(
196
+ return_value={
197
+ "quotes": [{"provider": "brap_best"}, {"provider": "brap_alt"}],
198
+ "best_quote": {
199
+ "provider": "brap_best",
200
+ "output_amount": "1",
201
+ "calldata": "0xabc",
202
+ },
203
+ }
204
+ )
205
+
206
+ with (
207
+ patch(
208
+ "wayfinder_paths.mcp.tools.quotes.find_wallet_by_label",
209
+ return_value=fake_wallet,
210
+ ),
211
+ patch(
212
+ "wayfinder_paths.mcp.tools.quotes.TokenClient", return_value=token_client
213
+ ),
214
+ patch("wayfinder_paths.mcp.tools.quotes.BRAPClient", return_value=brap_client),
215
+ ):
216
+ out = await quote_swap(
217
+ wallet_label="main",
218
+ from_token="usd-coin-arbitrum",
219
+ to_token="tether-arbitrum",
220
+ amount="1",
221
+ )
222
+
223
+ assert out["ok"] is True
224
+ assert out["result"]["quote"]["quote_count"] == 2
225
+ assert out["result"]["quote"]["providers"] == ["brap_best", "brap_alt"]
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from wayfinder_paths.mcp.tools.run_script import run_script
8
+
9
+
10
+ @pytest.mark.asyncio
11
+ async def test_run_script_rejects_outside_runs_dir(tmp_path: Path, monkeypatch):
12
+ runs_root = tmp_path / "runs"
13
+ runs_root.mkdir()
14
+ monkeypatch.setenv("WAYFINDER_RUNS_DIR", str(runs_root))
15
+ monkeypatch.setenv("WAYFINDER_MCP_STATE_PATH", str(tmp_path / "mcp.sqlite3"))
16
+
17
+ outside = tmp_path / "outside.py"
18
+ outside.write_text("print('nope')\n")
19
+
20
+ out = await run_script(script_path=str(outside), dry_run=True)
21
+ assert out["ok"] is False
22
+ assert out["error"]["code"] == "invalid_request"
23
+
24
+
25
+ @pytest.mark.asyncio
26
+ async def test_run_script_executes_and_is_idempotent(tmp_path: Path, monkeypatch):
27
+ runs_root = tmp_path / "runs"
28
+ runs_root.mkdir()
29
+ monkeypatch.setenv("WAYFINDER_RUNS_DIR", str(runs_root))
30
+ monkeypatch.setenv("WAYFINDER_MCP_STATE_PATH", str(tmp_path / "mcp.sqlite3"))
31
+
32
+ script = runs_root / "hello.py"
33
+ script.write_text(
34
+ "import os\n"
35
+ "print('DRY_RUN=' + os.getenv('DRY_RUN', ''))\n"
36
+ "print('PWD=' + os.getcwd())\n"
37
+ )
38
+
39
+ out1 = await run_script(script_path=str(script), dry_run=True, timeout_s=30)
40
+ assert out1["ok"] is True
41
+ assert out1["result"]["exit_code"] == 0
42
+ assert "DRY_RUN=1" in out1["result"]["stdout"]
43
+
44
+ out2 = await run_script(script_path=str(script), dry_run=True, timeout_s=30)
45
+ assert out2["ok"] is True
46
+ assert out2["result"]["status"] == "duplicate"
47
+ assert out2["result"]["idempotency_key"]