wayfinder-paths 0.1.22__py3-none-any.whl → 0.1.24__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of wayfinder-paths might be problematic. Click here for more details.

Files changed (156) hide show
  1. wayfinder_paths/__init__.py +0 -4
  2. wayfinder_paths/adapters/balance_adapter/README.md +0 -1
  3. wayfinder_paths/adapters/balance_adapter/adapter.py +313 -167
  4. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  5. wayfinder_paths/adapters/balance_adapter/test_adapter.py +41 -124
  6. wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
  7. wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
  8. wayfinder_paths/adapters/boros_adapter/client.py +476 -0
  9. wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
  10. wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
  11. wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
  12. wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
  13. wayfinder_paths/adapters/boros_adapter/types.py +70 -0
  14. wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
  15. wayfinder_paths/adapters/brap_adapter/README.md +22 -75
  16. wayfinder_paths/adapters/brap_adapter/adapter.py +187 -576
  17. wayfinder_paths/adapters/brap_adapter/examples.json +21 -140
  18. wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
  19. wayfinder_paths/adapters/brap_adapter/test_adapter.py +6 -234
  20. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +180 -92
  21. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
  22. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +82 -14
  23. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
  24. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +586 -61
  25. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
  26. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
  27. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
  28. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
  29. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
  30. wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
  31. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
  32. wayfinder_paths/adapters/ledger_adapter/README.md +4 -1
  33. wayfinder_paths/adapters/ledger_adapter/adapter.py +3 -3
  34. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
  35. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
  36. wayfinder_paths/adapters/moonwell_adapter/adapter.py +649 -547
  37. wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
  38. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +160 -239
  39. wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
  40. wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
  41. wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
  42. wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
  43. wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
  44. wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
  45. wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
  46. wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
  47. wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
  48. wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
  49. wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
  50. wayfinder_paths/adapters/token_adapter/adapter.py +14 -0
  51. wayfinder_paths/adapters/token_adapter/examples.json +0 -4
  52. wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
  53. wayfinder_paths/conftest.py +24 -17
  54. wayfinder_paths/core/__init__.py +0 -3
  55. wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
  56. wayfinder_paths/core/adapters/models.py +17 -7
  57. wayfinder_paths/core/clients/BRAPClient.py +4 -1
  58. wayfinder_paths/core/clients/ClientManager.py +0 -7
  59. wayfinder_paths/core/clients/LedgerClient.py +196 -172
  60. wayfinder_paths/core/clients/TokenClient.py +47 -1
  61. wayfinder_paths/core/clients/WayfinderClient.py +1 -3
  62. wayfinder_paths/core/clients/__init__.py +0 -5
  63. wayfinder_paths/core/clients/protocols.py +21 -35
  64. wayfinder_paths/core/clients/test_ledger_client.py +448 -0
  65. wayfinder_paths/core/config.py +10 -162
  66. wayfinder_paths/core/constants/__init__.py +73 -2
  67. wayfinder_paths/core/constants/base.py +8 -17
  68. wayfinder_paths/core/constants/chains.py +36 -0
  69. wayfinder_paths/core/constants/contracts.py +52 -0
  70. wayfinder_paths/core/constants/erc20_abi.py +0 -1
  71. wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
  72. wayfinder_paths/core/constants/hyperliquid.py +16 -0
  73. wayfinder_paths/core/constants/moonwell_abi.py +0 -15
  74. wayfinder_paths/core/constants/tokens.py +9 -0
  75. wayfinder_paths/core/engine/manifest.py +66 -0
  76. wayfinder_paths/core/strategies/Strategy.py +0 -71
  77. wayfinder_paths/core/strategies/__init__.py +10 -1
  78. wayfinder_paths/core/strategies/opa_loop.py +167 -0
  79. wayfinder_paths/core/utils/evm_helpers.py +5 -15
  80. wayfinder_paths/core/utils/test_transaction.py +289 -0
  81. wayfinder_paths/core/utils/tokens.py +28 -0
  82. wayfinder_paths/core/utils/transaction.py +57 -8
  83. wayfinder_paths/core/utils/web3.py +8 -3
  84. wayfinder_paths/mcp/__init__.py +5 -0
  85. wayfinder_paths/mcp/preview.py +185 -0
  86. wayfinder_paths/mcp/scripting.py +84 -0
  87. wayfinder_paths/mcp/server.py +52 -0
  88. wayfinder_paths/mcp/state/profile_store.py +195 -0
  89. wayfinder_paths/mcp/state/store.py +89 -0
  90. wayfinder_paths/mcp/test_scripting.py +267 -0
  91. wayfinder_paths/mcp/tools/__init__.py +0 -0
  92. wayfinder_paths/mcp/tools/balances.py +290 -0
  93. wayfinder_paths/mcp/tools/discovery.py +158 -0
  94. wayfinder_paths/mcp/tools/execute.py +770 -0
  95. wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
  96. wayfinder_paths/mcp/tools/quotes.py +288 -0
  97. wayfinder_paths/mcp/tools/run_script.py +286 -0
  98. wayfinder_paths/mcp/tools/strategies.py +188 -0
  99. wayfinder_paths/mcp/tools/tokens.py +46 -0
  100. wayfinder_paths/mcp/tools/wallets.py +354 -0
  101. wayfinder_paths/mcp/utils.py +129 -0
  102. wayfinder_paths/policies/enso.py +1 -2
  103. wayfinder_paths/policies/hyper_evm.py +6 -3
  104. wayfinder_paths/policies/hyperlend.py +1 -2
  105. wayfinder_paths/policies/hyperliquid.py +1 -1
  106. wayfinder_paths/policies/lifi.py +18 -0
  107. wayfinder_paths/policies/moonwell.py +12 -7
  108. wayfinder_paths/policies/prjx.py +1 -3
  109. wayfinder_paths/policies/util.py +8 -2
  110. wayfinder_paths/run_strategy.py +97 -300
  111. wayfinder_paths/strategies/basis_trading_strategy/constants.py +3 -1
  112. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +47 -133
  113. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
  114. wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
  115. wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
  116. wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
  117. wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
  118. wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
  119. wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
  120. wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
  121. wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
  122. wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
  123. wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
  124. wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
  125. wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
  126. wayfinder_paths/{templates/strategy → strategies/boros_hype_strategy}/test_strategy.py +99 -63
  127. wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
  128. wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
  129. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +15 -23
  130. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +27 -62
  131. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +84 -58
  132. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
  133. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +69 -164
  134. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +43 -76
  135. wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
  136. wayfinder_paths/tests/test_test_coverage.py +1 -4
  137. wayfinder_paths-0.1.24.dist-info/METADATA +378 -0
  138. wayfinder_paths-0.1.24.dist-info/RECORD +185 -0
  139. {wayfinder_paths-0.1.22.dist-info → wayfinder_paths-0.1.24.dist-info}/WHEEL +1 -1
  140. wayfinder_paths/core/clients/WalletClient.py +0 -41
  141. wayfinder_paths/core/engine/StrategyJob.py +0 -110
  142. wayfinder_paths/core/services/test_local_evm_txn.py +0 -145
  143. wayfinder_paths/scripts/create_strategy.py +0 -139
  144. wayfinder_paths/scripts/make_wallets.py +0 -142
  145. wayfinder_paths/templates/adapter/README.md +0 -150
  146. wayfinder_paths/templates/adapter/adapter.py +0 -16
  147. wayfinder_paths/templates/adapter/examples.json +0 -8
  148. wayfinder_paths/templates/adapter/test_adapter.py +0 -30
  149. wayfinder_paths/templates/strategy/README.md +0 -186
  150. wayfinder_paths/templates/strategy/examples.json +0 -11
  151. wayfinder_paths/templates/strategy/strategy.py +0 -35
  152. wayfinder_paths/tests/test_smoke_manifest.py +0 -63
  153. wayfinder_paths-0.1.22.dist-info/METADATA +0 -355
  154. wayfinder_paths-0.1.22.dist-info/RECORD +0 -129
  155. /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
  156. {wayfinder_paths-0.1.22.dist-info → wayfinder_paths-0.1.24.dist-info}/LICENSE +0 -0
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ from loguru import logger
9
+
10
+
11
+ @dataclass
12
+ class OPAConfig:
13
+ max_iterations_per_tick: int = 4
14
+ max_steps_per_iteration: int = 5
15
+ max_total_steps_per_tick: int = 15
16
+
17
+
18
+ @dataclass
19
+ class PlanStep[TOp: Enum]:
20
+ op: TOp
21
+ priority: int
22
+ key: str
23
+ params: dict[str, Any] = field(default_factory=dict)
24
+ reason: str = ""
25
+
26
+ def __repr__(self) -> str:
27
+ return f"PlanStep({self.op.name}, priority={self.priority}, key={self.key!r})"
28
+
29
+
30
+ @dataclass
31
+ class Plan[TOp: Enum]:
32
+ steps: list[PlanStep[TOp]] = field(default_factory=list)
33
+ desired_state: dict[str, Any] = field(default_factory=dict)
34
+
35
+ def __bool__(self) -> bool:
36
+ return bool(self.steps)
37
+
38
+ def __len__(self) -> int:
39
+ return len(self.steps)
40
+
41
+
42
+ class OPALoopMixin[TInventory, TOp: Enum](ABC):
43
+ @property
44
+ @abstractmethod
45
+ def opa_config(self) -> OPAConfig: ...
46
+
47
+ @abstractmethod
48
+ async def observe(self) -> TInventory: ...
49
+
50
+ @abstractmethod
51
+ def plan(self, inventory: TInventory) -> Plan[TOp]: ...
52
+
53
+ @abstractmethod
54
+ async def execute_step(
55
+ self, step: PlanStep[TOp], inventory: TInventory
56
+ ) -> tuple[bool, str]: ...
57
+
58
+ @abstractmethod
59
+ def get_inventory_changing_ops(self) -> set[TOp]: ...
60
+
61
+ async def on_loop_start(self) -> tuple[bool, str] | None:
62
+ return None
63
+
64
+ async def on_step_executed(
65
+ self, step: PlanStep[TOp], success: bool, message: str
66
+ ) -> None:
67
+ return None
68
+
69
+ def should_stop_early(
70
+ self, inventory: TInventory, iteration: int
71
+ ) -> tuple[bool, str] | None:
72
+ return None
73
+
74
+ async def on_loop_end(
75
+ self, success: bool, messages: list[str], total_steps: int
76
+ ) -> None:
77
+ return None
78
+
79
+ async def run_opa_loop(self) -> tuple[bool, str, bool]:
80
+ loop_logger = logger.bind(loop="opa")
81
+
82
+ setup_result = await self.on_loop_start()
83
+ if setup_result is not None:
84
+ return (*setup_result, False)
85
+
86
+ total_steps = 0
87
+ messages: list[str] = []
88
+ rotated = False
89
+ config = self.opa_config
90
+
91
+ try:
92
+ for iteration in range(config.max_iterations_per_tick):
93
+ loop_logger.debug(
94
+ f"OPA iteration {iteration + 1}/{config.max_iterations_per_tick}"
95
+ )
96
+
97
+ # OBSERVE
98
+ try:
99
+ inventory = await self.observe()
100
+ except Exception as e:
101
+ loop_logger.error(f"Observe failed: {e}")
102
+ return (False, f"Failed to observe: {e}", rotated)
103
+
104
+ stop_result = self.should_stop_early(inventory, iteration)
105
+ if stop_result is not None:
106
+ await self.on_loop_end(stop_result[0], messages, total_steps)
107
+ return (*stop_result, rotated)
108
+
109
+ # PLAN
110
+ try:
111
+ plan = self.plan(inventory)
112
+ except Exception as e:
113
+ loop_logger.error(f"Plan failed: {e}")
114
+ return (False, f"Failed to plan: {e}", rotated)
115
+
116
+ if not plan.steps:
117
+ loop_logger.debug("Plan is empty, nothing to do")
118
+ break
119
+
120
+ loop_logger.debug(f"Plan has {len(plan.steps)} steps")
121
+
122
+ # ACT - execute steps up to limit
123
+ steps_this_iteration = 0
124
+ for step in plan.steps[: config.max_steps_per_iteration]:
125
+ if total_steps >= config.max_total_steps_per_tick:
126
+ loop_logger.warning(
127
+ f"Hit max total steps ({config.max_total_steps_per_tick})"
128
+ )
129
+ break
130
+
131
+ loop_logger.info(f"Executing step: {step.op.name} ({step.reason})")
132
+
133
+ try:
134
+ success, msg = await self.execute_step(step, inventory)
135
+ except Exception as e:
136
+ success = False
137
+ msg = f"Step {step.op.name} raised exception: {e}"
138
+ loop_logger.error(msg)
139
+
140
+ await self.on_step_executed(step, success, msg)
141
+ messages.append(f"{step.op.name}: {msg}")
142
+ total_steps += 1
143
+ steps_this_iteration += 1
144
+
145
+ if step.params.get("is_rotation"):
146
+ rotated = True
147
+
148
+ # Re-observe after inventory-changing ops (failed steps likely didn't change anything)
149
+ if success and step.op in self.get_inventory_changing_ops():
150
+ loop_logger.debug(
151
+ f"Step {step.op.name} changes inventory, re-observing"
152
+ )
153
+ break
154
+
155
+ if steps_this_iteration == 0:
156
+ break
157
+
158
+ except Exception as e:
159
+ loop_logger.error(f"OPA loop failed: {e}")
160
+ await self.on_loop_end(False, messages, total_steps)
161
+ return (False, f"OPA loop error: {e}", rotated)
162
+
163
+ final_message = "; ".join(messages) if messages else "No action needed"
164
+ loop_logger.info(f"OPA loop complete: {total_steps} steps executed")
165
+
166
+ await self.on_loop_end(True, messages, total_steps)
167
+ return (True, final_message, rotated)
@@ -4,25 +4,15 @@ from typing import Any
4
4
 
5
5
  from loguru import logger
6
6
 
7
- from wayfinder_paths.core.constants.base import CHAIN_CODE_TO_ID
7
+ from wayfinder_paths.core.constants.chains import CHAIN_CODE_TO_ID
8
8
 
9
9
 
10
- def chain_code_to_chain_id(chain_code: str | None) -> int | None:
11
- if not chain_code:
12
- return None
13
- return CHAIN_CODE_TO_ID.get(chain_code.lower())
14
-
15
-
16
- def resolve_chain_id(token_info: dict[str, Any], logger_instance=None) -> int | None:
17
- log = logger_instance or logger
10
+ def resolve_chain_id(token_info: dict[str, Any]) -> int | None:
18
11
  chain_meta = token_info.get("chain") or {}
19
12
  chain_id = chain_meta.get("id")
20
- try:
21
- if chain_id is not None:
22
- return int(chain_id)
23
- except (ValueError, TypeError):
24
- log.debug("Invalid chain_id in token_info.chain: %s", chain_id)
25
- return chain_code_to_chain_id(chain_meta.get("code"))
13
+ if chain_id is not None:
14
+ return int(chain_id)
15
+ return CHAIN_CODE_TO_ID.get(chain_meta.get("code").lower())
26
16
 
27
17
 
28
18
  def resolve_rpc_url(
@@ -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,10 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
1
4
  from web3 import AsyncWeb3
2
5
 
3
6
  from wayfinder_paths.core.constants.erc20_abi import ERC20_ABI
7
+ from wayfinder_paths.core.utils.transaction import send_transaction
4
8
  from wayfinder_paths.core.utils.web3 import web3_from_chain_id
5
9
 
6
10
  NATIVE_TOKEN_ADDRESSES: set = {
@@ -102,3 +106,27 @@ async def build_send_transaction(
102
106
  "data": data,
103
107
  "chainId": chain_id,
104
108
  }
109
+
110
+
111
+ async def ensure_allowance(
112
+ *,
113
+ token_address: str,
114
+ owner: str,
115
+ spender: str,
116
+ amount: int,
117
+ chain_id: int,
118
+ signing_callback: Callable,
119
+ approval_amount: int | None = None,
120
+ ) -> tuple[bool, Any]:
121
+ allowance = await get_token_allowance(token_address, chain_id, owner, spender)
122
+ if allowance >= amount:
123
+ return True, {}
124
+ approve_tx = await build_approve_transaction(
125
+ from_address=owner,
126
+ chain_id=chain_id,
127
+ token_address=token_address,
128
+ spender_address=spender,
129
+ amount=approval_amount if approval_amount is not None else amount,
130
+ )
131
+ txn_hash = await send_transaction(approve_tx, signing_callback)
132
+ return True, txn_hash
@@ -1,20 +1,25 @@
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
 
9
+ from wayfinder_paths.core.constants.base import (
10
+ SUGGESTED_GAS_PRICE_MULTIPLIER,
11
+ SUGGESTED_PRIORITY_FEE_MULTIPLIER,
12
+ )
13
+ from wayfinder_paths.core.constants.chains import (
14
+ CHAIN_ID_HYPEREVM,
15
+ PRE_EIP_1559_CHAIN_IDS,
16
+ )
7
17
  from wayfinder_paths.core.utils.web3 import (
8
18
  get_transaction_chain_id,
9
19
  web3_from_chain_id,
10
20
  web3s_from_chain_id,
11
21
  )
12
22
 
13
- PRE_EIP_1559_CHAIN_IDS: set = {56, 42161}
14
-
15
- SUGGESTED_PRIORITY_FEE_MULTIPLIER = 1.5
16
- SUGGESTED_GAS_PRICE_MULTIPLIER = 1.5
17
-
18
23
 
19
24
  def _get_transaction_from_address(transaction: dict) -> str:
20
25
  if "from" not in transaction:
@@ -71,7 +76,7 @@ async def gas_price_transaction(
71
76
  gas_price = max(gas_prices)
72
77
 
73
78
  transaction["gasPrice"] = int(gas_price * gas_price_multiplier)
74
- elif chain_id == 999:
79
+ elif chain_id == CHAIN_ID_HYPEREVM:
75
80
  # HyperEVM big blocks fetch base gas price from a different RPC method. Priority fee = 0 is # grandfathered in from Django, not sure what's right here.
76
81
  big_block_gas_prices = await asyncio.gather(
77
82
  *[web3.hype.big_block_gas_price() for web3 in web3s]
@@ -111,7 +116,7 @@ async def gas_limit_transaction(transaction: dict):
111
116
 
112
117
  async def _estimate_gas(web3: AsyncWeb3, transaction: dict) -> int:
113
118
  try:
114
- return await web3.eth.estimate_gas(transaction, block_identifier="pending")
119
+ return await web3.eth.estimate_gas(transaction, block_identifier="latest")
115
120
  except Exception as e:
116
121
  logger.info(
117
122
  f"Failed to estimate gas using {web3.provider.endpoint_uri}. Error: {e}"
@@ -174,7 +179,10 @@ async def wait_for_transaction_receipt(
174
179
  async def send_transaction(
175
180
  transaction: dict, sign_callback: Callable, wait_for_receipt=True
176
181
  ) -> str:
177
- logger.info(f"Broadcasting transaction {transaction.get('to', 'unknown')[:10]}...")
182
+ if sign_callback is None:
183
+ raise ValueError("sign_callback must be provided to send transaction")
184
+
185
+ logger.info(f"Broadcasting transaction {transaction}...")
178
186
  chain_id = get_transaction_chain_id(transaction)
179
187
  transaction = await gas_limit_transaction(transaction)
180
188
  transaction = await nonce_transaction(transaction)
@@ -187,4 +195,45 @@ async def send_transaction(
187
195
  return txn_hash
188
196
 
189
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
+
190
239
  # TODO: HypeEVM Big Blocks: Setting and detecting
@@ -5,8 +5,10 @@ from web3.middleware import ExtraDataToPOAMiddleware
5
5
  from web3.module import Module
6
6
 
7
7
  from wayfinder_paths.core.config import get_rpc_urls
8
-
9
- POA_MIDDLEWARE_CHAIN_IDS: set = {56, 137, 43114}
8
+ from wayfinder_paths.core.constants.chains import (
9
+ CHAIN_ID_HYPEREVM,
10
+ POA_MIDDLEWARE_CHAIN_IDS,
11
+ )
10
12
 
11
13
 
12
14
  class HyperModule(Module):
@@ -24,6 +26,9 @@ def _get_rpcs_for_chain_id(chain_id: int) -> list:
24
26
  rpcs = get_rpc_urls().get(str(chain_id))
25
27
  if rpcs is None:
26
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]
27
32
  return rpcs
28
33
 
29
34
 
@@ -31,7 +36,7 @@ def _get_web3(rpc: str, chain_id: int) -> AsyncWeb3:
31
36
  web3 = AsyncWeb3(AsyncHTTPProvider(rpc))
32
37
  if chain_id in POA_MIDDLEWARE_CHAIN_IDS:
33
38
  web3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
34
- if chain_id == 999:
39
+ if chain_id == CHAIN_ID_HYPEREVM:
35
40
  web3.attach_modules({"hype": (HyperModule)})
36
41
  return web3
37
42
 
@@ -0,0 +1,5 @@
1
+ """Wayfinder Paths MCP server package.
2
+
3
+ This package is intentionally isolated from the core SDK so that importing
4
+ `wayfinder_paths` does not require MCP dependencies.
5
+ """