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.
- wayfinder_paths/__init__.py +0 -4
- wayfinder_paths/adapters/balance_adapter/README.md +0 -1
- wayfinder_paths/adapters/balance_adapter/adapter.py +313 -167
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +41 -124
- 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/README.md +22 -75
- wayfinder_paths/adapters/brap_adapter/adapter.py +187 -576
- wayfinder_paths/adapters/brap_adapter/examples.json +21 -140
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +6 -234
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +180 -92
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +82 -14
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +586 -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/README.md +4 -1
- wayfinder_paths/adapters/ledger_adapter/adapter.py +3 -3
- 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 +649 -547
- wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +160 -239
- 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/adapter.py +14 -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/__init__.py +0 -3
- wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
- wayfinder_paths/core/adapters/models.py +17 -7
- wayfinder_paths/core/clients/BRAPClient.py +4 -1
- wayfinder_paths/core/clients/ClientManager.py +0 -7
- wayfinder_paths/core/clients/LedgerClient.py +196 -172
- wayfinder_paths/core/clients/TokenClient.py +47 -1
- wayfinder_paths/core/clients/WayfinderClient.py +1 -3
- wayfinder_paths/core/clients/__init__.py +0 -5
- wayfinder_paths/core/clients/protocols.py +21 -35
- wayfinder_paths/core/clients/test_ledger_client.py +448 -0
- wayfinder_paths/core/config.py +10 -162
- wayfinder_paths/core/constants/__init__.py +73 -2
- wayfinder_paths/core/constants/base.py +8 -17
- wayfinder_paths/core/constants/chains.py +36 -0
- wayfinder_paths/core/constants/contracts.py +52 -0
- 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/constants/tokens.py +9 -0
- wayfinder_paths/core/engine/manifest.py +66 -0
- wayfinder_paths/core/strategies/Strategy.py +0 -71
- wayfinder_paths/core/strategies/__init__.py +10 -1
- wayfinder_paths/core/strategies/opa_loop.py +167 -0
- wayfinder_paths/core/utils/evm_helpers.py +5 -15
- wayfinder_paths/core/utils/test_transaction.py +289 -0
- wayfinder_paths/core/utils/tokens.py +28 -0
- wayfinder_paths/core/utils/transaction.py +57 -8
- wayfinder_paths/core/utils/web3.py +8 -3
- 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/enso.py +1 -2
- wayfinder_paths/policies/hyper_evm.py +6 -3
- wayfinder_paths/policies/hyperlend.py +1 -2
- wayfinder_paths/policies/hyperliquid.py +1 -1
- wayfinder_paths/policies/lifi.py +18 -0
- wayfinder_paths/policies/moonwell.py +12 -7
- wayfinder_paths/policies/prjx.py +1 -3
- wayfinder_paths/policies/util.py +8 -2
- wayfinder_paths/run_strategy.py +97 -300
- wayfinder_paths/strategies/basis_trading_strategy/constants.py +3 -1
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +47 -133
- 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/{templates/strategy → strategies/boros_hype_strategy}/test_strategy.py +99 -63
- 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 +15 -23
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +27 -62
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +84 -58
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +69 -164
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +43 -76
- 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.22.dist-info → wayfinder_paths-0.1.24.dist-info}/WHEEL +1 -1
- wayfinder_paths/core/clients/WalletClient.py +0 -41
- wayfinder_paths/core/engine/StrategyJob.py +0 -110
- wayfinder_paths/core/services/test_local_evm_txn.py +0 -145
- wayfinder_paths/scripts/create_strategy.py +0 -139
- wayfinder_paths/scripts/make_wallets.py +0 -142
- wayfinder_paths/templates/adapter/README.md +0 -150
- wayfinder_paths/templates/adapter/adapter.py +0 -16
- wayfinder_paths/templates/adapter/examples.json +0 -8
- wayfinder_paths/templates/adapter/test_adapter.py +0 -30
- wayfinder_paths/templates/strategy/README.md +0 -186
- wayfinder_paths/templates/strategy/examples.json +0 -11
- wayfinder_paths/templates/strategy/strategy.py +0 -35
- wayfinder_paths/tests/test_smoke_manifest.py +0 -63
- wayfinder_paths-0.1.22.dist-info/METADATA +0 -355
- wayfinder_paths-0.1.22.dist-info/RECORD +0 -129
- /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
- {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.
|
|
7
|
+
from wayfinder_paths.core.constants.chains import CHAIN_CODE_TO_ID
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 ==
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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 ==
|
|
39
|
+
if chain_id == CHAIN_ID_HYPEREVM:
|
|
35
40
|
web3.attach_modules({"hype": (HyperModule)})
|
|
36
41
|
return web3
|
|
37
42
|
|