wayfinder-paths 0.1.30__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.
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +15 -1
- wayfinder_paths/adapters/hyperliquid_adapter/exchange.py +3 -3
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1 -1
- wayfinder_paths/adapters/hyperliquid_adapter/test_cancel_order.py +57 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_exchange_mid_prices.py +52 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_hyperliquid_sdk_live.py +64 -0
- wayfinder_paths/adapters/hyperliquid_adapter/util.py +9 -10
- wayfinder_paths/core/clients/PoolClient.py +1 -1
- wayfinder_paths/mcp/tools/execute.py +48 -16
- wayfinder_paths/mcp/tools/hyperliquid.py +1 -13
- wayfinder_paths/mcp/tools/quotes.py +42 -7
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +24 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +248 -27
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +125 -15
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +29 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/manifest.yaml +33 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +23 -0
- wayfinder_paths/tests/test_manifests.py +93 -0
- wayfinder_paths/tests/test_mcp_balances.py +73 -0
- wayfinder_paths/tests/test_mcp_discovery.py +34 -0
- wayfinder_paths/tests/test_mcp_execute.py +146 -0
- wayfinder_paths/tests/test_mcp_hyperliquid_execute.py +69 -0
- wayfinder_paths/tests/test_mcp_idempotency_store.py +14 -0
- wayfinder_paths/tests/test_mcp_quote_swap.py +60 -0
- wayfinder_paths/tests/test_mcp_run_script.py +47 -0
- wayfinder_paths/tests/test_mcp_tokens.py +49 -0
- wayfinder_paths/tests/test_mcp_utils.py +35 -0
- wayfinder_paths/tests/test_mcp_wallets.py +38 -0
- {wayfinder_paths-0.1.30.dist-info → wayfinder_paths-0.1.31.dist-info}/METADATA +2 -2
- {wayfinder_paths-0.1.30.dist-info → wayfinder_paths-0.1.31.dist-info}/RECORD +32 -15
- {wayfinder_paths-0.1.30.dist-info → wayfinder_paths-0.1.31.dist-info}/WHEEL +1 -1
- {wayfinder_paths-0.1.30.dist-info → wayfinder_paths-0.1.31.dist-info}/LICENSE +0 -0
|
@@ -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"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from wayfinder_paths.mcp.tools.tokens import tokens
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.asyncio
|
|
11
|
+
async def test_tokens_resolve_requires_query():
|
|
12
|
+
out = await tokens("resolve", query=None)
|
|
13
|
+
assert out["ok"] is False
|
|
14
|
+
assert out["error"]["code"] == "invalid_request"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.asyncio
|
|
18
|
+
async def test_tokens_resolve_happy_path():
|
|
19
|
+
fake_client = AsyncMock()
|
|
20
|
+
fake_client.get_token_details = AsyncMock(return_value={"symbol": "USDC"})
|
|
21
|
+
|
|
22
|
+
with patch(
|
|
23
|
+
"wayfinder_paths.mcp.tools.tokens.TokenClient", return_value=fake_client
|
|
24
|
+
):
|
|
25
|
+
out = await tokens("resolve", query="usd-coin-arbitrum")
|
|
26
|
+
|
|
27
|
+
assert out["ok"] is True
|
|
28
|
+
assert out["result"]["token"]["symbol"] == "USDC"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
async def test_tokens_gas_requires_chain_code():
|
|
33
|
+
out = await tokens("gas", chain_code=None)
|
|
34
|
+
assert out["ok"] is False
|
|
35
|
+
assert out["error"]["code"] == "invalid_request"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_tokens_fuzzy_happy_path():
|
|
40
|
+
fake_client = AsyncMock()
|
|
41
|
+
fake_client.fuzzy_search = AsyncMock(return_value={"results": [{"id": "foo"}]})
|
|
42
|
+
|
|
43
|
+
with patch(
|
|
44
|
+
"wayfinder_paths.mcp.tools.tokens.TokenClient", return_value=fake_client
|
|
45
|
+
):
|
|
46
|
+
out = await tokens("fuzzy", query="usd", chain_code="arbitrum")
|
|
47
|
+
|
|
48
|
+
assert out["ok"] is True
|
|
49
|
+
assert out["result"]["results"][0]["id"] == "foo"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from wayfinder_paths.mcp.utils import parse_amount_to_raw, repo_root, sha256_json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_repo_root_finds_pyproject():
|
|
9
|
+
root = repo_root()
|
|
10
|
+
assert (root / "pyproject.toml").exists()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_parse_amount_to_raw_scales_and_floors():
|
|
14
|
+
assert parse_amount_to_raw("1", 6) == 1_000_000
|
|
15
|
+
# Flooring: 1.23456789 with 6 decimals -> 1_234_567
|
|
16
|
+
assert parse_amount_to_raw("1.23456789", 6) == 1_234_567
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_parse_amount_to_raw_rejects_non_positive():
|
|
20
|
+
with pytest.raises(ValueError, match="positive"):
|
|
21
|
+
parse_amount_to_raw("0", 18)
|
|
22
|
+
with pytest.raises(ValueError, match="positive"):
|
|
23
|
+
parse_amount_to_raw("-1", 18)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_parse_amount_to_raw_rejects_too_small_after_scaling():
|
|
27
|
+
with pytest.raises(ValueError, match="too small"):
|
|
28
|
+
parse_amount_to_raw("0.0000001", 6)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_sha256_json_is_stable_for_key_order():
|
|
32
|
+
a = sha256_json({"b": 2, "a": 1})
|
|
33
|
+
b = sha256_json({"a": 1, "b": 2})
|
|
34
|
+
assert a == b
|
|
35
|
+
assert a.startswith("sha256:")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from wayfinder_paths.mcp.tools.wallets import _resolve_wallet_address, wallets
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_resolve_wallet_address_prefers_explicit_address():
|
|
12
|
+
addr, lbl = _resolve_wallet_address(
|
|
13
|
+
wallet_label="main", wallet_address="0x000000000000000000000000000000000000dEaD"
|
|
14
|
+
)
|
|
15
|
+
assert addr == "0x000000000000000000000000000000000000dEaD"
|
|
16
|
+
assert lbl is None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.asyncio
|
|
20
|
+
async def test_wallets_discover_portfolio_requires_confirmation_when_many_protocols():
|
|
21
|
+
store = SimpleNamespace(
|
|
22
|
+
get_protocols_for_wallet=lambda _addr: ["hyperliquid", "pendle", "moonwell"]
|
|
23
|
+
) # noqa: E501
|
|
24
|
+
|
|
25
|
+
with patch(
|
|
26
|
+
"wayfinder_paths.mcp.tools.wallets.WalletProfileStore.default",
|
|
27
|
+
return_value=store,
|
|
28
|
+
):
|
|
29
|
+
out = await wallets(
|
|
30
|
+
"discover_portfolio",
|
|
31
|
+
wallet_address="0x000000000000000000000000000000000000dEaD",
|
|
32
|
+
parallel=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert out["ok"] is True
|
|
36
|
+
res = out["result"]
|
|
37
|
+
assert res["requires_confirmation"] is True
|
|
38
|
+
assert set(res["protocols_to_query"]) == {"hyperliquid", "pendle", "moonwell"}
|