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.
- wayfinder_paths/adapters/boros_adapter/adapter.py +313 -12
- wayfinder_paths/adapters/boros_adapter/test_adapter.py +125 -14
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +17 -3
- wayfinder_paths/adapters/hyperliquid_adapter/exchange.py +5 -5
- wayfinder_paths/adapters/hyperliquid_adapter/local_signer.py +2 -33
- 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/core/constants/hype_oft_abi.py +151 -0
- wayfinder_paths/core/strategies/Strategy.py +1 -2
- wayfinder_paths/mcp/tools/execute.py +48 -16
- wayfinder_paths/mcp/tools/hyperliquid.py +1 -13
- wayfinder_paths/mcp/tools/quotes.py +38 -124
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +24 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +249 -29
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +125 -15
- wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +57 -201
- wayfinder_paths/strategies/boros_hype_strategy/constants.py +1 -152
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +29 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/manifest.yaml +33 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +23 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +2 -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.29.dist-info → wayfinder_paths-0.1.31.dist-info}/METADATA +2 -2
- {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/RECORD +42 -25
- {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/WHEEL +1 -1
- wayfinder_paths/core/types.py +0 -19
- {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"]
|