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.
- wayfinder_paths/adapters/balance_adapter/adapter.py +250 -0
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -11
- wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
- wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
- wayfinder_paths/adapters/boros_adapter/client.py +476 -0
- wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
- wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
- wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
- wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
- wayfinder_paths/adapters/boros_adapter/types.py +70 -0
- wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
- wayfinder_paths/adapters/brap_adapter/adapter.py +1 -1
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +161 -26
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +77 -13
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +585 -61
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
- wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
- wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +592 -400
- wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +126 -219
- wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
- wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
- wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
- wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
- wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
- wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
- wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
- wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
- wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
- wayfinder_paths/adapters/token_adapter/examples.json +0 -4
- wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
- wayfinder_paths/conftest.py +24 -17
- wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
- wayfinder_paths/core/adapters/models.py +17 -7
- wayfinder_paths/core/clients/BRAPClient.py +1 -1
- wayfinder_paths/core/clients/TokenClient.py +47 -1
- wayfinder_paths/core/clients/WayfinderClient.py +1 -2
- wayfinder_paths/core/clients/protocols.py +21 -22
- wayfinder_paths/core/clients/test_ledger_client.py +448 -0
- wayfinder_paths/core/config.py +12 -0
- wayfinder_paths/core/constants/__init__.py +15 -0
- wayfinder_paths/core/constants/base.py +6 -1
- wayfinder_paths/core/constants/contracts.py +39 -26
- wayfinder_paths/core/constants/erc20_abi.py +0 -1
- wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
- wayfinder_paths/core/constants/hyperliquid.py +16 -0
- wayfinder_paths/core/constants/moonwell_abi.py +0 -15
- wayfinder_paths/core/engine/manifest.py +66 -0
- wayfinder_paths/core/strategies/Strategy.py +0 -61
- wayfinder_paths/core/strategies/__init__.py +10 -1
- wayfinder_paths/core/strategies/opa_loop.py +167 -0
- wayfinder_paths/core/utils/test_transaction.py +289 -0
- wayfinder_paths/core/utils/transaction.py +44 -1
- wayfinder_paths/core/utils/web3.py +3 -0
- wayfinder_paths/mcp/__init__.py +5 -0
- wayfinder_paths/mcp/preview.py +185 -0
- wayfinder_paths/mcp/scripting.py +84 -0
- wayfinder_paths/mcp/server.py +52 -0
- wayfinder_paths/mcp/state/profile_store.py +195 -0
- wayfinder_paths/mcp/state/store.py +89 -0
- wayfinder_paths/mcp/test_scripting.py +267 -0
- wayfinder_paths/mcp/tools/__init__.py +0 -0
- wayfinder_paths/mcp/tools/balances.py +290 -0
- wayfinder_paths/mcp/tools/discovery.py +158 -0
- wayfinder_paths/mcp/tools/execute.py +770 -0
- wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
- wayfinder_paths/mcp/tools/quotes.py +288 -0
- wayfinder_paths/mcp/tools/run_script.py +286 -0
- wayfinder_paths/mcp/tools/strategies.py +188 -0
- wayfinder_paths/mcp/tools/tokens.py +46 -0
- wayfinder_paths/mcp/tools/wallets.py +354 -0
- wayfinder_paths/mcp/utils.py +129 -0
- wayfinder_paths/policies/hyperliquid.py +1 -1
- wayfinder_paths/policies/lifi.py +18 -0
- wayfinder_paths/policies/util.py +8 -2
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +28 -119
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
- wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
- wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
- wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
- wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
- wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
- wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
- wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
- wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
- wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
- wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
- wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
- wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
- wayfinder_paths/strategies/boros_hype_strategy/test_strategy.py +202 -0
- wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
- wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +3 -12
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +7 -29
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +63 -40
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +0 -34
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +11 -34
- wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
- wayfinder_paths/tests/test_test_coverage.py +1 -4
- wayfinder_paths-0.1.24.dist-info/METADATA +378 -0
- wayfinder_paths-0.1.24.dist-info/RECORD +185 -0
- {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.24.dist-info}/WHEEL +1 -1
- wayfinder_paths/scripts/create_strategy.py +0 -139
- wayfinder_paths/scripts/make_wallets.py +0 -142
- wayfinder_paths-0.1.23.dist-info/METADATA +0 -354
- wayfinder_paths-0.1.23.dist-info/RECORD +0 -120
- /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
- {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.24.dist-info}/LICENSE +0 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from web3 import AsyncWeb3
|
|
6
|
+
|
|
7
|
+
from wayfinder_paths.core.constants import SUPPORTED_CHAINS
|
|
8
|
+
from wayfinder_paths.core.utils.transaction import (
|
|
9
|
+
PRE_EIP_1559_CHAIN_IDS,
|
|
10
|
+
_get_transaction_from_address,
|
|
11
|
+
gas_limit_transaction,
|
|
12
|
+
gas_price_transaction,
|
|
13
|
+
nonce_transaction,
|
|
14
|
+
)
|
|
15
|
+
from wayfinder_paths.core.utils.web3 import get_transaction_chain_id
|
|
16
|
+
|
|
17
|
+
RANDOM_USER_0 = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def for_every_chain_id(async_f):
|
|
21
|
+
return asyncio.gather(*[async_f(chain_id) for chain_id in SUPPORTED_CHAINS])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestGetChainId:
|
|
25
|
+
def test_valid_chain_id(self):
|
|
26
|
+
transaction = {"chainId": 1}
|
|
27
|
+
result = get_transaction_chain_id(transaction)
|
|
28
|
+
assert result == 1
|
|
29
|
+
|
|
30
|
+
def test_chain_id_as_string(self):
|
|
31
|
+
transaction = {"chainId": "1"}
|
|
32
|
+
result = get_transaction_chain_id(transaction)
|
|
33
|
+
assert result == 1
|
|
34
|
+
|
|
35
|
+
def test_empty_transaction(self):
|
|
36
|
+
transaction = {}
|
|
37
|
+
with pytest.raises(ValueError, match="Transaction does not contain chainId"):
|
|
38
|
+
get_transaction_chain_id(transaction)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestGetFromAddress:
|
|
42
|
+
def test_valid_checksum_address(self):
|
|
43
|
+
transaction = {"from": RANDOM_USER_0}
|
|
44
|
+
result = _get_transaction_from_address(transaction)
|
|
45
|
+
assert result == RANDOM_USER_0
|
|
46
|
+
assert AsyncWeb3.is_checksum_address(result)
|
|
47
|
+
|
|
48
|
+
def test_lowercase_address_converted_to_checksum(self):
|
|
49
|
+
lowercase_address = RANDOM_USER_0.lower()
|
|
50
|
+
transaction = {"from": lowercase_address}
|
|
51
|
+
result = _get_transaction_from_address(transaction)
|
|
52
|
+
assert AsyncWeb3.is_checksum_address(result)
|
|
53
|
+
assert result == RANDOM_USER_0
|
|
54
|
+
|
|
55
|
+
def test_empty_transaction(self):
|
|
56
|
+
transaction = {}
|
|
57
|
+
with pytest.raises(
|
|
58
|
+
ValueError, match="Transaction does not contain from address"
|
|
59
|
+
):
|
|
60
|
+
_get_transaction_from_address(transaction)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
class TestNonceTransaction:
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def mock_web3(self):
|
|
67
|
+
web3 = MagicMock()
|
|
68
|
+
web3.eth = MagicMock()
|
|
69
|
+
web3.eth.get_transaction_count = AsyncMock()
|
|
70
|
+
return web3
|
|
71
|
+
|
|
72
|
+
async def test_noncing_on_mainnet(self):
|
|
73
|
+
transaction = {
|
|
74
|
+
"from": RANDOM_USER_0,
|
|
75
|
+
"chainId": 1,
|
|
76
|
+
}
|
|
77
|
+
result = await nonce_transaction(transaction)
|
|
78
|
+
assert result["nonce"] >= 0
|
|
79
|
+
|
|
80
|
+
async def test_noncing_on_all_chains(self):
|
|
81
|
+
async def test_noncing(chain_id):
|
|
82
|
+
transaction = {
|
|
83
|
+
"from": RANDOM_USER_0,
|
|
84
|
+
"chainId": chain_id,
|
|
85
|
+
}
|
|
86
|
+
result = await nonce_transaction(transaction)
|
|
87
|
+
assert "nonce" in result
|
|
88
|
+
assert result["nonce"] >= 0
|
|
89
|
+
|
|
90
|
+
await for_every_chain_id(test_noncing)
|
|
91
|
+
|
|
92
|
+
@patch("wayfinder_paths.core.utils.transaction.web3s_from_chain_id")
|
|
93
|
+
async def test_multiple_web3s_returns_max_nonce(self, mock_web3s_context):
|
|
94
|
+
mock_web3_1 = MagicMock()
|
|
95
|
+
mock_web3_1.eth = MagicMock()
|
|
96
|
+
mock_web3_1.eth.get_transaction_count = AsyncMock(return_value=5)
|
|
97
|
+
mock_web3_1.provider.disconnect = AsyncMock()
|
|
98
|
+
|
|
99
|
+
mock_web3_2 = MagicMock()
|
|
100
|
+
mock_web3_2.eth = MagicMock()
|
|
101
|
+
mock_web3_2.eth.get_transaction_count = AsyncMock(return_value=8)
|
|
102
|
+
mock_web3_2.provider.disconnect = AsyncMock()
|
|
103
|
+
|
|
104
|
+
mock_web3_3 = MagicMock()
|
|
105
|
+
mock_web3_3.eth = MagicMock()
|
|
106
|
+
mock_web3_3.eth.get_transaction_count = AsyncMock(return_value=6)
|
|
107
|
+
mock_web3_3.provider.disconnect = AsyncMock()
|
|
108
|
+
|
|
109
|
+
mock_web3s_context.return_value.__aenter__.return_value = [
|
|
110
|
+
mock_web3_1,
|
|
111
|
+
mock_web3_2,
|
|
112
|
+
mock_web3_3,
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
transaction = {
|
|
116
|
+
"from": RANDOM_USER_0,
|
|
117
|
+
"chainId": 1,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
result = await nonce_transaction(transaction)
|
|
121
|
+
|
|
122
|
+
assert result["nonce"] == 8
|
|
123
|
+
mock_web3_1.eth.get_transaction_count.assert_called_once()
|
|
124
|
+
mock_web3_2.eth.get_transaction_count.assert_called_once()
|
|
125
|
+
mock_web3_3.eth.get_transaction_count.assert_called_once()
|
|
126
|
+
|
|
127
|
+
@patch("wayfinder_paths.core.utils.transaction.web3s_from_chain_id")
|
|
128
|
+
async def test_preserves_all_existing_fields(self, mock_web3s_context, mock_web3):
|
|
129
|
+
mock_web3.eth.get_transaction_count.return_value = 5
|
|
130
|
+
mock_web3.provider.disconnect = AsyncMock()
|
|
131
|
+
mock_web3s_context.return_value.__aenter__.return_value = [mock_web3]
|
|
132
|
+
|
|
133
|
+
transaction = {
|
|
134
|
+
"from": RANDOM_USER_0,
|
|
135
|
+
"chainId": 1,
|
|
136
|
+
"to": RANDOM_USER_0,
|
|
137
|
+
"value": 100,
|
|
138
|
+
"gas": 21000,
|
|
139
|
+
"gasPrice": 1000000000,
|
|
140
|
+
"data": "0xabcd",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
result = await nonce_transaction(transaction)
|
|
144
|
+
|
|
145
|
+
assert result["nonce"] == 5
|
|
146
|
+
assert result["from"] == transaction["from"]
|
|
147
|
+
assert result["chainId"] == transaction["chainId"]
|
|
148
|
+
assert result["to"] == transaction["to"]
|
|
149
|
+
assert result["value"] == transaction["value"]
|
|
150
|
+
assert result["gas"] == transaction["gas"]
|
|
151
|
+
assert result["gasPrice"] == transaction["gasPrice"]
|
|
152
|
+
assert result["data"] == transaction["data"]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio
|
|
156
|
+
class TestGasPriceTransaction:
|
|
157
|
+
async def test_pricing_on_all_chains(self):
|
|
158
|
+
async def test_pricing(chain_id):
|
|
159
|
+
transaction = {
|
|
160
|
+
"chainId": chain_id,
|
|
161
|
+
}
|
|
162
|
+
result = await gas_price_transaction(transaction)
|
|
163
|
+
if chain_id in PRE_EIP_1559_CHAIN_IDS:
|
|
164
|
+
assert "maxFeePerGas" not in result
|
|
165
|
+
assert "maxPriorityFeePerGas" not in result
|
|
166
|
+
assert result["gasPrice"] > 0
|
|
167
|
+
elif chain_id == 999:
|
|
168
|
+
assert "gasPrice" not in result
|
|
169
|
+
assert result["maxFeePerGas"] > 0
|
|
170
|
+
assert result["maxPriorityFeePerGas"] == 0
|
|
171
|
+
else:
|
|
172
|
+
assert "gasPrice" not in result
|
|
173
|
+
assert result["maxFeePerGas"] > 0
|
|
174
|
+
assert result["maxPriorityFeePerGas"] > 0
|
|
175
|
+
|
|
176
|
+
await for_every_chain_id(test_pricing)
|
|
177
|
+
|
|
178
|
+
@patch("wayfinder_paths.core.utils.transaction.web3s_from_chain_id")
|
|
179
|
+
async def test_eip1559_with_custom_multiplier_and_max_aggregation(
|
|
180
|
+
self, mock_web3s_context
|
|
181
|
+
):
|
|
182
|
+
# Mock multiple web3 instances with different base fees and priority fees
|
|
183
|
+
mock_block_1 = MagicMock()
|
|
184
|
+
mock_block_1.baseFeePerGas = 30_000_000_000
|
|
185
|
+
mock_fee_history_1 = MagicMock()
|
|
186
|
+
mock_fee_history_1.reward = [[2_000_000_000] for _ in range(10)]
|
|
187
|
+
mock_web3_1 = MagicMock()
|
|
188
|
+
mock_web3_1.eth = MagicMock()
|
|
189
|
+
mock_web3_1.eth.get_block = AsyncMock(return_value=mock_block_1)
|
|
190
|
+
mock_web3_1.eth.fee_history = AsyncMock(return_value=mock_fee_history_1)
|
|
191
|
+
mock_web3_1.provider.disconnect = AsyncMock()
|
|
192
|
+
|
|
193
|
+
mock_block_2 = MagicMock()
|
|
194
|
+
mock_block_2.baseFeePerGas = 35_000_000_000
|
|
195
|
+
mock_fee_history_2 = MagicMock()
|
|
196
|
+
mock_fee_history_2.reward = [[3_000_000_000] for _ in range(10)]
|
|
197
|
+
mock_web3_2 = MagicMock()
|
|
198
|
+
mock_web3_2.eth = MagicMock()
|
|
199
|
+
mock_web3_2.eth.get_block = AsyncMock(return_value=mock_block_2)
|
|
200
|
+
mock_web3_2.eth.fee_history = AsyncMock(return_value=mock_fee_history_2)
|
|
201
|
+
mock_web3_2.provider.disconnect = AsyncMock()
|
|
202
|
+
|
|
203
|
+
mock_block_3 = MagicMock()
|
|
204
|
+
mock_block_3.baseFeePerGas = 32_000_000_000
|
|
205
|
+
mock_fee_history_3 = MagicMock()
|
|
206
|
+
mock_fee_history_3.reward = [[2_500_000_000] for _ in range(10)]
|
|
207
|
+
mock_web3_3 = MagicMock()
|
|
208
|
+
mock_web3_3.eth = MagicMock()
|
|
209
|
+
mock_web3_3.eth.get_block = AsyncMock(return_value=mock_block_3)
|
|
210
|
+
mock_web3_3.eth.fee_history = AsyncMock(return_value=mock_fee_history_3)
|
|
211
|
+
mock_web3_3.provider.disconnect = AsyncMock()
|
|
212
|
+
|
|
213
|
+
mock_web3s_context.return_value.__aenter__.return_value = [
|
|
214
|
+
mock_web3_1,
|
|
215
|
+
mock_web3_2,
|
|
216
|
+
mock_web3_3,
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
transaction = {"chainId": 1}
|
|
220
|
+
custom_priority_multiplier = 2.0
|
|
221
|
+
|
|
222
|
+
result = await gas_price_transaction(
|
|
223
|
+
transaction, priority_fee_multiplier=custom_priority_multiplier
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Should use max base fee (35 gwei) and max priority fee (3 gwei)
|
|
227
|
+
expected_max_priority_fee = int(3_000_000_000 * custom_priority_multiplier)
|
|
228
|
+
expected_max_fee = int(
|
|
229
|
+
35_000_000_000 * 2 + 3_000_000_000 * custom_priority_multiplier
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
assert result["maxPriorityFeePerGas"] == expected_max_priority_fee
|
|
233
|
+
assert result["maxFeePerGas"] == expected_max_fee
|
|
234
|
+
assert "gasPrice" not in result
|
|
235
|
+
|
|
236
|
+
@patch("wayfinder_paths.core.utils.transaction.web3s_from_chain_id")
|
|
237
|
+
async def test_non_eip1559_with_custom_multiplier_and_max_aggregation(
|
|
238
|
+
self, mock_web3s_context
|
|
239
|
+
):
|
|
240
|
+
# Mock multiple web3 instances with different gas prices
|
|
241
|
+
# gas_price is an awaitable property, so we need to make it a coroutine
|
|
242
|
+
mock_web3_1 = MagicMock()
|
|
243
|
+
mock_web3_1.eth = MagicMock()
|
|
244
|
+
mock_web3_1.eth.gas_price = AsyncMock(return_value=5_000_000_000)()
|
|
245
|
+
mock_web3_1.provider.disconnect = AsyncMock()
|
|
246
|
+
|
|
247
|
+
mock_web3_2 = MagicMock()
|
|
248
|
+
mock_web3_2.eth = MagicMock()
|
|
249
|
+
mock_web3_2.eth.gas_price = AsyncMock(return_value=8_000_000_000)()
|
|
250
|
+
mock_web3_2.provider.disconnect = AsyncMock()
|
|
251
|
+
|
|
252
|
+
mock_web3_3 = MagicMock()
|
|
253
|
+
mock_web3_3.eth = MagicMock()
|
|
254
|
+
mock_web3_3.eth.gas_price = AsyncMock(return_value=6_000_000_000)()
|
|
255
|
+
mock_web3_3.provider.disconnect = AsyncMock()
|
|
256
|
+
|
|
257
|
+
mock_web3s_context.return_value.__aenter__.return_value = [
|
|
258
|
+
mock_web3_1,
|
|
259
|
+
mock_web3_2,
|
|
260
|
+
mock_web3_3,
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
transaction = {"chainId": 56}
|
|
264
|
+
custom_gas_multiplier = 2.5
|
|
265
|
+
|
|
266
|
+
result = await gas_price_transaction(
|
|
267
|
+
transaction, gas_price_multiplier=custom_gas_multiplier
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Should use max gas price (8 gwei) * multiplier
|
|
271
|
+
expected_gas_price = int(8_000_000_000 * custom_gas_multiplier)
|
|
272
|
+
|
|
273
|
+
assert result["gasPrice"] == expected_gas_price
|
|
274
|
+
assert "maxFeePerGas" not in result
|
|
275
|
+
assert "maxPriorityFeePerGas" not in result
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@pytest.mark.asyncio
|
|
279
|
+
class TestGasLimitTransaction:
|
|
280
|
+
async def test_gas_limit_on_all_chains(self):
|
|
281
|
+
async def test_gas_limit(chain_id):
|
|
282
|
+
transaction = {
|
|
283
|
+
"chainId": chain_id,
|
|
284
|
+
}
|
|
285
|
+
result = await gas_limit_transaction(transaction)
|
|
286
|
+
assert "gas" in result
|
|
287
|
+
assert result["gas"] > 0
|
|
288
|
+
|
|
289
|
+
await for_every_chain_id(test_gas_limit)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from collections.abc import Callable
|
|
3
|
+
from typing import Any
|
|
3
4
|
|
|
5
|
+
from eth_account import Account
|
|
4
6
|
from loguru import logger
|
|
5
7
|
from web3 import AsyncWeb3
|
|
6
8
|
|
|
@@ -180,7 +182,7 @@ async def send_transaction(
|
|
|
180
182
|
if sign_callback is None:
|
|
181
183
|
raise ValueError("sign_callback must be provided to send transaction")
|
|
182
184
|
|
|
183
|
-
logger.info(f"Broadcasting transaction {transaction
|
|
185
|
+
logger.info(f"Broadcasting transaction {transaction}...")
|
|
184
186
|
chain_id = get_transaction_chain_id(transaction)
|
|
185
187
|
transaction = await gas_limit_transaction(transaction)
|
|
186
188
|
transaction = await nonce_transaction(transaction)
|
|
@@ -193,4 +195,45 @@ async def send_transaction(
|
|
|
193
195
|
return txn_hash
|
|
194
196
|
|
|
195
197
|
|
|
198
|
+
async def sign_and_send_transaction(
|
|
199
|
+
transaction: dict, private_key: str, wait_for_receipt: bool = True
|
|
200
|
+
) -> str:
|
|
201
|
+
account = Account.from_key(private_key)
|
|
202
|
+
|
|
203
|
+
async def sign_callback(tx: dict) -> bytes:
|
|
204
|
+
signed = account.sign_transaction(tx)
|
|
205
|
+
return signed.raw_transaction
|
|
206
|
+
|
|
207
|
+
return await send_transaction(transaction, sign_callback, wait_for_receipt)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def encode_call(
|
|
211
|
+
*,
|
|
212
|
+
target: str,
|
|
213
|
+
abi: list[dict[str, Any]],
|
|
214
|
+
fn_name: str,
|
|
215
|
+
args: list[Any],
|
|
216
|
+
from_address: str,
|
|
217
|
+
chain_id: int,
|
|
218
|
+
value: int = 0,
|
|
219
|
+
) -> dict[str, Any]:
|
|
220
|
+
async with web3_from_chain_id(chain_id) as web3:
|
|
221
|
+
contract = web3.eth.contract(address=target, abi=abi)
|
|
222
|
+
try:
|
|
223
|
+
tx_data = await getattr(contract.functions, fn_name)(
|
|
224
|
+
*args
|
|
225
|
+
).build_transaction({"from": from_address})
|
|
226
|
+
data = tx_data["data"]
|
|
227
|
+
except ValueError as exc:
|
|
228
|
+
raise ValueError(f"Failed to encode {fn_name}: {exc}") from exc
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
"chainId": int(chain_id),
|
|
232
|
+
"from": AsyncWeb3.to_checksum_address(from_address),
|
|
233
|
+
"to": AsyncWeb3.to_checksum_address(target),
|
|
234
|
+
"data": data,
|
|
235
|
+
"value": int(value),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
196
239
|
# TODO: HypeEVM Big Blocks: Setting and detecting
|
|
@@ -26,6 +26,9 @@ def _get_rpcs_for_chain_id(chain_id: int) -> list:
|
|
|
26
26
|
rpcs = get_rpc_urls().get(str(chain_id))
|
|
27
27
|
if rpcs is None:
|
|
28
28
|
raise ValueError(f"No RPCs configured for chain ID {chain_id}")
|
|
29
|
+
# Handle both string (single URL) and list (multiple URLs) formats
|
|
30
|
+
if isinstance(rpcs, str):
|
|
31
|
+
return [rpcs]
|
|
29
32
|
return rpcs
|
|
30
33
|
|
|
31
34
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from wayfinder_paths.core.constants.hyperliquid import (
|
|
8
|
+
ARBITRUM_USDC_TOKEN_ID,
|
|
9
|
+
HYPE_FEE_WALLET,
|
|
10
|
+
HYPERLIQUID_BRIDGE_ADDRESS,
|
|
11
|
+
)
|
|
12
|
+
from wayfinder_paths.mcp.utils import (
|
|
13
|
+
find_wallet_by_label,
|
|
14
|
+
normalize_address,
|
|
15
|
+
read_text_excerpt,
|
|
16
|
+
repo_root,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_execution_preview(tool_input: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
req = tool_input.get("request") if isinstance(tool_input, dict) else None
|
|
22
|
+
if not isinstance(req, dict):
|
|
23
|
+
return {
|
|
24
|
+
"summary": "Execute request missing 'request' object.",
|
|
25
|
+
"recipient_mismatch": False,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
kind = str(req.get("kind") or "").strip()
|
|
29
|
+
wallet_label = str(req.get("wallet_label") or "").strip()
|
|
30
|
+
w = find_wallet_by_label(wallet_label) if wallet_label else None
|
|
31
|
+
sender = normalize_address((w or {}).get("address")) if w else None
|
|
32
|
+
|
|
33
|
+
recipient = normalize_address(req.get("recipient"))
|
|
34
|
+
if kind == "swap":
|
|
35
|
+
recipient = recipient or sender
|
|
36
|
+
summary = (
|
|
37
|
+
"EXECUTE swap\n"
|
|
38
|
+
f"wallet_label: {wallet_label}\n"
|
|
39
|
+
f"from_token: {req.get('from_token')}\n"
|
|
40
|
+
f"to_token: {req.get('to_token')}\n"
|
|
41
|
+
f"amount: {req.get('amount')}\n"
|
|
42
|
+
f"slippage_bps: {req.get('slippage_bps')}\n"
|
|
43
|
+
f"sender: {sender or '(unknown)'}\n"
|
|
44
|
+
f"recipient: {recipient or '(unknown)'}"
|
|
45
|
+
)
|
|
46
|
+
elif kind == "hyperliquid_deposit":
|
|
47
|
+
recipient = normalize_address(HYPERLIQUID_BRIDGE_ADDRESS)
|
|
48
|
+
summary = (
|
|
49
|
+
"EXECUTE hyperliquid_deposit (Bridge2)\n"
|
|
50
|
+
f"wallet_label: {wallet_label}\n"
|
|
51
|
+
f"token: {ARBITRUM_USDC_TOKEN_ID}\n"
|
|
52
|
+
f"amount: {req.get('amount')}\n"
|
|
53
|
+
"chain_id: 42161\n"
|
|
54
|
+
f"sender: {sender or '(unknown)'}\n"
|
|
55
|
+
f"recipient: {recipient or '(missing)'}"
|
|
56
|
+
)
|
|
57
|
+
elif kind == "send":
|
|
58
|
+
summary = (
|
|
59
|
+
"EXECUTE send\n"
|
|
60
|
+
f"wallet_label: {wallet_label}\n"
|
|
61
|
+
f"token: {req.get('token')}\n"
|
|
62
|
+
f"amount: {req.get('amount')}\n"
|
|
63
|
+
f"chain_id: {req.get('chain_id')}\n"
|
|
64
|
+
f"sender: {sender or '(unknown)'}\n"
|
|
65
|
+
f"recipient: {recipient or '(missing)'}"
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
summary = f"EXECUTE {kind or '(unknown kind)'}\nwallet_label: {wallet_label}"
|
|
69
|
+
|
|
70
|
+
mismatch = bool(sender and recipient and sender.lower() != recipient.lower())
|
|
71
|
+
if kind == "hyperliquid_deposit":
|
|
72
|
+
mismatch = False # deposit recipient is fixed; mismatch is expected
|
|
73
|
+
return {"summary": summary, "recipient_mismatch": mismatch}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_run_script_preview(tool_input: dict[str, Any]) -> dict[str, Any]:
|
|
77
|
+
ti = tool_input if isinstance(tool_input, dict) else {}
|
|
78
|
+
path_raw = ti.get("script_path") or ti.get("path")
|
|
79
|
+
args = ti.get("args") if isinstance(ti.get("args"), list) else []
|
|
80
|
+
dry_run = bool(ti.get("dry_run", True))
|
|
81
|
+
|
|
82
|
+
if not isinstance(path_raw, str) or not path_raw.strip():
|
|
83
|
+
return {"summary": "RUN_SCRIPT missing script_path."}
|
|
84
|
+
|
|
85
|
+
root = repo_root()
|
|
86
|
+
p = Path(path_raw)
|
|
87
|
+
if not p.is_absolute():
|
|
88
|
+
p = root / p
|
|
89
|
+
resolved = p.resolve(strict=False)
|
|
90
|
+
|
|
91
|
+
rel = str(resolved)
|
|
92
|
+
try:
|
|
93
|
+
rel = str(resolved.relative_to(root))
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
sha = None
|
|
98
|
+
try:
|
|
99
|
+
if resolved.exists():
|
|
100
|
+
sha = hashlib.sha256(resolved.read_bytes()).hexdigest()
|
|
101
|
+
except Exception:
|
|
102
|
+
sha = None
|
|
103
|
+
|
|
104
|
+
excerpt = read_text_excerpt(resolved, max_chars=1200) if resolved.exists() else None
|
|
105
|
+
|
|
106
|
+
summary = (
|
|
107
|
+
"RUN_SCRIPT (executes local python)\n"
|
|
108
|
+
f"script_path: {rel}\n"
|
|
109
|
+
f"dry_run: {dry_run}\n"
|
|
110
|
+
f"args: {args or []}\n"
|
|
111
|
+
f"script_sha256: {(sha[:12] + '…') if sha else '(unavailable)'}"
|
|
112
|
+
)
|
|
113
|
+
if excerpt:
|
|
114
|
+
summary += "\n\n" + excerpt
|
|
115
|
+
else:
|
|
116
|
+
summary += "\n\n(no script contents available)"
|
|
117
|
+
|
|
118
|
+
return {"summary": summary}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_hyperliquid_execute_preview(tool_input: dict[str, Any]) -> dict[str, Any]:
|
|
122
|
+
# hyperliquid_execute uses direct parameters, not a 'request' wrapper
|
|
123
|
+
req = tool_input if isinstance(tool_input, dict) else {}
|
|
124
|
+
if not req:
|
|
125
|
+
return {"summary": "HYPERLIQUID_EXECUTE missing parameters."}
|
|
126
|
+
|
|
127
|
+
action = str(req.get("action") or "").strip()
|
|
128
|
+
wallet_label = str(req.get("wallet_label") or "").strip()
|
|
129
|
+
w = find_wallet_by_label(wallet_label) if wallet_label else None
|
|
130
|
+
sender = normalize_address((w or {}).get("address")) if w else None
|
|
131
|
+
|
|
132
|
+
dry_run = req.get("dry_run")
|
|
133
|
+
coin = req.get("coin")
|
|
134
|
+
asset_id = req.get("asset_id")
|
|
135
|
+
|
|
136
|
+
header = "HYPERLIQUID_EXECUTE\n"
|
|
137
|
+
base = (
|
|
138
|
+
f"action: {action or '(missing)'}\n"
|
|
139
|
+
f"wallet_label: {wallet_label}\n"
|
|
140
|
+
f"address: {sender or '(unknown)'}\n"
|
|
141
|
+
f"dry_run: {dry_run}\n"
|
|
142
|
+
f"coin: {coin}\n"
|
|
143
|
+
f"asset_id: {asset_id}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if action == "place_order":
|
|
147
|
+
details = (
|
|
148
|
+
"\n\nORDER\n"
|
|
149
|
+
f"order_type: {req.get('order_type')}\n"
|
|
150
|
+
f"is_buy: {req.get('is_buy')}\n"
|
|
151
|
+
f"size: {req.get('size')}\n"
|
|
152
|
+
f"usd_amount: {req.get('usd_amount')}\n"
|
|
153
|
+
f"usd_amount_kind: {req.get('usd_amount_kind')}\n"
|
|
154
|
+
f"price: {req.get('price')}\n"
|
|
155
|
+
f"slippage: {req.get('slippage')}\n"
|
|
156
|
+
f"reduce_only: {req.get('reduce_only')}\n"
|
|
157
|
+
f"cloid: {req.get('cloid')}\n"
|
|
158
|
+
f"leverage: {req.get('leverage')}\n"
|
|
159
|
+
f"is_cross: {req.get('is_cross')}\n"
|
|
160
|
+
f"builder_wallet: {HYPE_FEE_WALLET}\n"
|
|
161
|
+
f"builder_fee_tenths_bp: {req.get('builder_fee_tenths_bp') or '(from config/default)'}"
|
|
162
|
+
)
|
|
163
|
+
return {"summary": header + base + details}
|
|
164
|
+
|
|
165
|
+
if action == "cancel_order":
|
|
166
|
+
details = (
|
|
167
|
+
"\n\nCANCEL\n"
|
|
168
|
+
f"order_id: {req.get('order_id')}\n"
|
|
169
|
+
f"cancel_cloid: {req.get('cancel_cloid')}"
|
|
170
|
+
)
|
|
171
|
+
return {"summary": header + base + details}
|
|
172
|
+
|
|
173
|
+
if action == "update_leverage":
|
|
174
|
+
details = (
|
|
175
|
+
"\n\nLEVERAGE\n"
|
|
176
|
+
f"leverage: {req.get('leverage')}\n"
|
|
177
|
+
f"is_cross: {req.get('is_cross')}"
|
|
178
|
+
)
|
|
179
|
+
return {"summary": header + base + details}
|
|
180
|
+
|
|
181
|
+
if action == "withdraw":
|
|
182
|
+
details = f"\n\nWITHDRAW\namount_usdc: {req.get('amount_usdc')}"
|
|
183
|
+
return {"summary": header + base + details}
|
|
184
|
+
|
|
185
|
+
return {"summary": header + base}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from eth_account import Account
|
|
7
|
+
|
|
8
|
+
from wayfinder_paths.mcp.utils import find_wallet_by_label, load_config_json
|
|
9
|
+
|
|
10
|
+
# Known signing callback parameter names used by adapters
|
|
11
|
+
_SIGNING_CALLBACK_PARAMS = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"strategy_wallet_signing_callback",
|
|
14
|
+
"sign_callback",
|
|
15
|
+
"signing_callback",
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _make_sign_callback(private_key: str):
|
|
21
|
+
account = Account.from_key(private_key)
|
|
22
|
+
|
|
23
|
+
async def sign_callback(transaction: dict) -> bytes:
|
|
24
|
+
signed = account.sign_transaction(transaction)
|
|
25
|
+
return signed.raw_transaction
|
|
26
|
+
|
|
27
|
+
return sign_callback
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _detect_callback_params(adapter_class: type) -> set[str]:
|
|
31
|
+
try:
|
|
32
|
+
sig = inspect.signature(adapter_class.__init__)
|
|
33
|
+
except (ValueError, TypeError):
|
|
34
|
+
return set()
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
name
|
|
38
|
+
for name in sig.parameters
|
|
39
|
+
if name in _SIGNING_CALLBACK_PARAMS or name.endswith("_signing_callback")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_adapter[T](
|
|
44
|
+
adapter_class: type[T],
|
|
45
|
+
wallet_label: str | None = None,
|
|
46
|
+
*,
|
|
47
|
+
config_overrides: dict[str, Any] | None = None,
|
|
48
|
+
**kwargs: Any,
|
|
49
|
+
) -> T:
|
|
50
|
+
config = load_config_json()
|
|
51
|
+
|
|
52
|
+
if config_overrides:
|
|
53
|
+
config = {**config, **config_overrides}
|
|
54
|
+
|
|
55
|
+
sign_callback = None
|
|
56
|
+
if wallet_label:
|
|
57
|
+
wallet = find_wallet_by_label(wallet_label)
|
|
58
|
+
if not wallet:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Wallet '{wallet_label}' not found in wallets.json. "
|
|
61
|
+
"Run 'just create-wallets' or check WALLETS_PATH."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
private_key = wallet.get("private_key") or wallet.get("private_key_hex")
|
|
65
|
+
if not private_key:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Wallet '{wallet_label}' is missing private_key_hex. "
|
|
68
|
+
"Local signing requires a private key."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
config["strategy_wallet"] = wallet
|
|
72
|
+
sign_callback = _make_sign_callback(private_key)
|
|
73
|
+
|
|
74
|
+
callback_params = _detect_callback_params(adapter_class)
|
|
75
|
+
adapter_kwargs: dict[str, Any] = {"config": config}
|
|
76
|
+
|
|
77
|
+
if sign_callback and callback_params:
|
|
78
|
+
for param_name in callback_params:
|
|
79
|
+
if param_name not in kwargs:
|
|
80
|
+
adapter_kwargs[param_name] = sign_callback
|
|
81
|
+
|
|
82
|
+
adapter_kwargs.update(kwargs)
|
|
83
|
+
|
|
84
|
+
return adapter_class(**adapter_kwargs)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Wayfinder Paths MCP server (FastMCP).
|
|
2
|
+
|
|
3
|
+
Run locally (via Claude Code .mcp.json):
|
|
4
|
+
poetry run python -m wayfinder_paths.mcp.server
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from wayfinder_paths.mcp.tools.balances import balances
|
|
14
|
+
from wayfinder_paths.mcp.tools.discovery import describe, discover
|
|
15
|
+
from wayfinder_paths.mcp.tools.execute import execute
|
|
16
|
+
from wayfinder_paths.mcp.tools.hyperliquid import hyperliquid, hyperliquid_execute
|
|
17
|
+
from wayfinder_paths.mcp.tools.quotes import quote_swap
|
|
18
|
+
from wayfinder_paths.mcp.tools.run_script import run_script
|
|
19
|
+
from wayfinder_paths.mcp.tools.strategies import run_strategy
|
|
20
|
+
from wayfinder_paths.mcp.tools.tokens import tokens
|
|
21
|
+
from wayfinder_paths.mcp.tools.wallets import wallets
|
|
22
|
+
|
|
23
|
+
mcp = FastMCP("wayfinder")
|
|
24
|
+
|
|
25
|
+
mcp.tool()(discover)
|
|
26
|
+
mcp.tool()(describe)
|
|
27
|
+
mcp.tool()(wallets)
|
|
28
|
+
mcp.tool()(tokens)
|
|
29
|
+
mcp.tool()(balances)
|
|
30
|
+
mcp.tool()(quote_swap)
|
|
31
|
+
mcp.tool()(hyperliquid)
|
|
32
|
+
mcp.tool()(hyperliquid_execute)
|
|
33
|
+
mcp.tool()(run_strategy)
|
|
34
|
+
mcp.tool()(run_script)
|
|
35
|
+
mcp.tool()(execute)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main() -> None:
|
|
39
|
+
# FastMCP is sync, but our tools are async; the library handles that.
|
|
40
|
+
mcp.run()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
# Some environments complain if an event loop is already running.
|
|
45
|
+
# FastMCP handles stdio and tool execution; we just start it.
|
|
46
|
+
try:
|
|
47
|
+
main()
|
|
48
|
+
except RuntimeError as exc:
|
|
49
|
+
if "asyncio.run()" in str(exc) and asyncio.get_event_loop().is_running():
|
|
50
|
+
main()
|
|
51
|
+
else:
|
|
52
|
+
raise
|