wayfinder-paths 0.1.7__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/CONFIG_GUIDE.md +399 -0
- wayfinder_paths/__init__.py +22 -0
- wayfinder_paths/abis/generic/erc20.json +383 -0
- wayfinder_paths/adapters/__init__.py +0 -0
- wayfinder_paths/adapters/balance_adapter/README.md +94 -0
- wayfinder_paths/adapters/balance_adapter/adapter.py +238 -0
- wayfinder_paths/adapters/balance_adapter/examples.json +6 -0
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +59 -0
- wayfinder_paths/adapters/brap_adapter/README.md +249 -0
- wayfinder_paths/adapters/brap_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/brap_adapter/adapter.py +726 -0
- wayfinder_paths/adapters/brap_adapter/examples.json +175 -0
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +11 -0
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +286 -0
- wayfinder_paths/adapters/hyperlend_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +305 -0
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +10 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +274 -0
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
- wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
- wayfinder_paths/adapters/ledger_adapter/README.md +145 -0
- wayfinder_paths/adapters/ledger_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/ledger_adapter/adapter.py +289 -0
- wayfinder_paths/adapters/ledger_adapter/examples.json +137 -0
- wayfinder_paths/adapters/ledger_adapter/manifest.yaml +11 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +205 -0
- wayfinder_paths/adapters/pool_adapter/README.md +206 -0
- wayfinder_paths/adapters/pool_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/pool_adapter/adapter.py +282 -0
- wayfinder_paths/adapters/pool_adapter/examples.json +143 -0
- wayfinder_paths/adapters/pool_adapter/manifest.yaml +10 -0
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +220 -0
- wayfinder_paths/adapters/token_adapter/README.md +101 -0
- wayfinder_paths/adapters/token_adapter/__init__.py +3 -0
- wayfinder_paths/adapters/token_adapter/adapter.py +96 -0
- wayfinder_paths/adapters/token_adapter/examples.json +26 -0
- wayfinder_paths/adapters/token_adapter/manifest.yaml +6 -0
- wayfinder_paths/adapters/token_adapter/test_adapter.py +125 -0
- wayfinder_paths/config.example.json +22 -0
- wayfinder_paths/conftest.py +31 -0
- wayfinder_paths/core/__init__.py +18 -0
- wayfinder_paths/core/adapters/BaseAdapter.py +65 -0
- wayfinder_paths/core/adapters/__init__.py +5 -0
- wayfinder_paths/core/adapters/base.py +5 -0
- wayfinder_paths/core/adapters/models.py +46 -0
- wayfinder_paths/core/analytics/__init__.py +11 -0
- wayfinder_paths/core/analytics/bootstrap.py +57 -0
- wayfinder_paths/core/analytics/stats.py +48 -0
- wayfinder_paths/core/analytics/test_analytics.py +170 -0
- wayfinder_paths/core/clients/AuthClient.py +83 -0
- wayfinder_paths/core/clients/BRAPClient.py +109 -0
- wayfinder_paths/core/clients/ClientManager.py +210 -0
- wayfinder_paths/core/clients/HyperlendClient.py +192 -0
- wayfinder_paths/core/clients/LedgerClient.py +443 -0
- wayfinder_paths/core/clients/PoolClient.py +128 -0
- wayfinder_paths/core/clients/SimulationClient.py +192 -0
- wayfinder_paths/core/clients/TokenClient.py +89 -0
- wayfinder_paths/core/clients/TransactionClient.py +63 -0
- wayfinder_paths/core/clients/WalletClient.py +94 -0
- wayfinder_paths/core/clients/WayfinderClient.py +269 -0
- wayfinder_paths/core/clients/__init__.py +48 -0
- wayfinder_paths/core/clients/protocols.py +392 -0
- wayfinder_paths/core/clients/sdk_example.py +110 -0
- wayfinder_paths/core/config.py +458 -0
- wayfinder_paths/core/constants/__init__.py +26 -0
- wayfinder_paths/core/constants/base.py +42 -0
- wayfinder_paths/core/constants/erc20_abi.py +118 -0
- wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
- wayfinder_paths/core/engine/StrategyJob.py +188 -0
- wayfinder_paths/core/engine/__init__.py +5 -0
- wayfinder_paths/core/engine/manifest.py +97 -0
- wayfinder_paths/core/services/__init__.py +0 -0
- wayfinder_paths/core/services/base.py +179 -0
- wayfinder_paths/core/services/local_evm_txn.py +430 -0
- wayfinder_paths/core/services/local_token_txn.py +231 -0
- wayfinder_paths/core/services/web3_service.py +45 -0
- wayfinder_paths/core/settings.py +61 -0
- wayfinder_paths/core/strategies/Strategy.py +280 -0
- wayfinder_paths/core/strategies/__init__.py +5 -0
- wayfinder_paths/core/strategies/base.py +7 -0
- wayfinder_paths/core/strategies/descriptors.py +81 -0
- wayfinder_paths/core/utils/__init__.py +1 -0
- wayfinder_paths/core/utils/evm_helpers.py +206 -0
- wayfinder_paths/core/utils/wallets.py +77 -0
- wayfinder_paths/core/wallets/README.md +91 -0
- wayfinder_paths/core/wallets/WalletManager.py +56 -0
- wayfinder_paths/core/wallets/__init__.py +7 -0
- wayfinder_paths/policies/enso.py +17 -0
- wayfinder_paths/policies/erc20.py +34 -0
- wayfinder_paths/policies/evm.py +21 -0
- wayfinder_paths/policies/hyper_evm.py +19 -0
- wayfinder_paths/policies/hyperlend.py +12 -0
- wayfinder_paths/policies/hyperliquid.py +30 -0
- wayfinder_paths/policies/moonwell.py +54 -0
- wayfinder_paths/policies/prjx.py +30 -0
- wayfinder_paths/policies/util.py +27 -0
- wayfinder_paths/run_strategy.py +411 -0
- wayfinder_paths/scripts/__init__.py +0 -0
- wayfinder_paths/scripts/create_strategy.py +181 -0
- wayfinder_paths/scripts/make_wallets.py +169 -0
- wayfinder_paths/scripts/run_strategy.py +124 -0
- wayfinder_paths/scripts/validate_manifests.py +213 -0
- wayfinder_paths/strategies/__init__.py +0 -0
- wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
- wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
- wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
- wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
- wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
- wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
- wayfinder_paths/strategies/config.py +85 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +100 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +8 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2270 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +352 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +96 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/examples.json +17 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1810 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +520 -0
- wayfinder_paths/templates/adapter/README.md +105 -0
- wayfinder_paths/templates/adapter/adapter.py +26 -0
- wayfinder_paths/templates/adapter/examples.json +8 -0
- wayfinder_paths/templates/adapter/manifest.yaml +6 -0
- wayfinder_paths/templates/adapter/test_adapter.py +49 -0
- wayfinder_paths/templates/strategy/README.md +153 -0
- wayfinder_paths/templates/strategy/examples.json +11 -0
- wayfinder_paths/templates/strategy/manifest.yaml +8 -0
- wayfinder_paths/templates/strategy/strategy.py +57 -0
- wayfinder_paths/templates/strategy/test_strategy.py +197 -0
- wayfinder_paths/tests/__init__.py +0 -0
- wayfinder_paths/tests/test_smoke_manifest.py +48 -0
- wayfinder_paths/tests/test_test_coverage.py +212 -0
- wayfinder_paths/tests/test_utils.py +64 -0
- wayfinder_paths-0.1.7.dist-info/LICENSE +21 -0
- wayfinder_paths-0.1.7.dist-info/METADATA +777 -0
- wayfinder_paths-0.1.7.dist-info/RECORD +149 -0
- wayfinder_paths-0.1.7.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from web3 import AsyncWeb3
|
|
5
|
+
|
|
6
|
+
from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TokenTxn(ABC):
|
|
10
|
+
"""Interface describing high-level EVM transaction builders."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def build_send(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
token_id: str,
|
|
17
|
+
amount: float,
|
|
18
|
+
from_address: str,
|
|
19
|
+
to_address: str,
|
|
20
|
+
token_info: dict[str, Any] | None = None,
|
|
21
|
+
) -> tuple[bool, dict[str, Any] | str]:
|
|
22
|
+
"""Build raw transaction data for sending tokens."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def build_erc20_approve(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
chain_id: int,
|
|
29
|
+
token_address: str,
|
|
30
|
+
from_address: str,
|
|
31
|
+
spender: str,
|
|
32
|
+
amount: int,
|
|
33
|
+
) -> tuple[bool, dict[str, Any] | str]:
|
|
34
|
+
"""Build raw ERC20 approve transaction data."""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def read_erc20_allowance(
|
|
38
|
+
self, chain: Any, token_address: str, from_address: str, spender_address: str
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
"""Read allowance granted for a spender."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EvmTxn(ABC):
|
|
44
|
+
"""
|
|
45
|
+
Abstract base class for wallet providers.
|
|
46
|
+
|
|
47
|
+
This interface abstracts all blockchain interactions needed by adapters so the
|
|
48
|
+
rest of the codebase never touches raw web3 primitives. Implementations
|
|
49
|
+
are responsible for RPC resolution, gas estimation, signing, broadcasting and
|
|
50
|
+
transaction confirmations.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
async def get_balance(
|
|
55
|
+
self,
|
|
56
|
+
address: str,
|
|
57
|
+
token_address: str | None,
|
|
58
|
+
chain_id: int,
|
|
59
|
+
) -> tuple[bool, Any]:
|
|
60
|
+
"""
|
|
61
|
+
Get balance for an address.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
address: Address to query balance for
|
|
65
|
+
token_address: ERC20 token address, or None for native token
|
|
66
|
+
chain_id: Chain ID
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Tuple of (success, balance_integer_or_error_message)
|
|
70
|
+
"""
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
async def approve_token(
|
|
75
|
+
self,
|
|
76
|
+
token_address: str,
|
|
77
|
+
spender: str,
|
|
78
|
+
amount: int,
|
|
79
|
+
from_address: str,
|
|
80
|
+
chain_id: int,
|
|
81
|
+
wait_for_receipt: bool = True,
|
|
82
|
+
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
83
|
+
) -> tuple[bool, Any]:
|
|
84
|
+
"""
|
|
85
|
+
Approve a spender to spend tokens on behalf of from_address.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
token_address: ERC20 token contract address
|
|
89
|
+
spender: Address being approved to spend tokens
|
|
90
|
+
amount: Amount to approve (in token units, not human-readable)
|
|
91
|
+
from_address: Address approving the tokens
|
|
92
|
+
chain_id: Chain ID
|
|
93
|
+
wait_for_receipt: Whether to wait for the transaction receipt
|
|
94
|
+
timeout: Receipt timeout in seconds
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Tuple of (success, transaction_result_dict_or_error_message)
|
|
98
|
+
Transaction result should include 'tx_hash' and optionally 'receipt'
|
|
99
|
+
"""
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
async def broadcast_transaction(
|
|
104
|
+
self,
|
|
105
|
+
transaction: dict[str, Any],
|
|
106
|
+
*,
|
|
107
|
+
wait_for_receipt: bool = True,
|
|
108
|
+
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
109
|
+
) -> tuple[bool, Any]:
|
|
110
|
+
"""
|
|
111
|
+
Sign and broadcast a transaction dict.
|
|
112
|
+
|
|
113
|
+
Providers must handle gas estimation, gas pricing, nonce selection, signing
|
|
114
|
+
and submission internally so callers can simply pass the transaction data.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
transaction: Dictionary describing the transaction (to, data, value, etc.)
|
|
118
|
+
wait_for_receipt: Whether to wait for the transaction receipt
|
|
119
|
+
timeout: Receipt timeout in seconds
|
|
120
|
+
"""
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
async def transaction_succeeded(
|
|
125
|
+
self, tx_hash: str, chain_id: int, timeout: int = 120
|
|
126
|
+
) -> bool:
|
|
127
|
+
"""
|
|
128
|
+
Check if a transaction hash succeeded on-chain.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
tx_hash: Transaction hash to inspect
|
|
132
|
+
chain_id: Chain ID where the transaction was broadcast
|
|
133
|
+
timeout: Maximum seconds to wait for a receipt
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Boolean indicating whether the transaction completed successfully.
|
|
137
|
+
"""
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
def get_web3(self, chain_id: int) -> AsyncWeb3:
|
|
142
|
+
"""
|
|
143
|
+
Return an AsyncWeb3 instance configured for the given chain.
|
|
144
|
+
|
|
145
|
+
Implementations may create new instances per call or pull from an internal
|
|
146
|
+
cache, but they must document whether the caller is responsible for closing
|
|
147
|
+
the underlying HTTP session.
|
|
148
|
+
"""
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Web3Service(ABC):
|
|
153
|
+
"""Facade that exposes low-level wallet access and higher-level EVM helpers."""
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
@abstractmethod
|
|
157
|
+
def evm_transactions(self) -> EvmTxn:
|
|
158
|
+
"""Return the wallet provider responsible for RPC, signing, and broadcasting."""
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
@abstractmethod
|
|
162
|
+
def token_transactions(self) -> TokenTxn:
|
|
163
|
+
"""Returns TokenTxn, for sends and swaps of any token"""
|
|
164
|
+
|
|
165
|
+
async def broadcast_transaction(
|
|
166
|
+
self,
|
|
167
|
+
transaction: dict[str, Any],
|
|
168
|
+
*,
|
|
169
|
+
wait_for_receipt: bool = True,
|
|
170
|
+
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
171
|
+
) -> tuple[bool, Any]:
|
|
172
|
+
"""Proxy convenience wrapper to underlying wallet provider."""
|
|
173
|
+
return await self.evm_transactions.broadcast_transaction(
|
|
174
|
+
transaction, wait_for_receipt=wait_for_receipt, timeout=timeout
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def get_web3(self, chain_id: int):
|
|
178
|
+
"""Expose underlying web3 provider for ABI encoding helpers."""
|
|
179
|
+
return self.evm_transactions.get_web3(chain_id)
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from eth_account import Account
|
|
5
|
+
from eth_utils import to_checksum_address
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from web3 import AsyncHTTPProvider, AsyncWeb3, Web3
|
|
8
|
+
|
|
9
|
+
from wayfinder_paths.core.constants import (
|
|
10
|
+
DEFAULT_GAS_ESTIMATE_FALLBACK,
|
|
11
|
+
ONE_GWEI,
|
|
12
|
+
ZERO_ADDRESS,
|
|
13
|
+
)
|
|
14
|
+
from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
|
|
15
|
+
from wayfinder_paths.core.constants.erc20_abi import (
|
|
16
|
+
ERC20_APPROVAL_ABI,
|
|
17
|
+
ERC20_MINIMAL_ABI,
|
|
18
|
+
)
|
|
19
|
+
from wayfinder_paths.core.services.base import EvmTxn
|
|
20
|
+
from wayfinder_paths.core.utils.evm_helpers import (
|
|
21
|
+
resolve_private_key_for_from_address,
|
|
22
|
+
resolve_rpc_url,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Gas management constants for ERC20 approval transactions
|
|
26
|
+
ERC20_APPROVAL_GAS_LIMIT = 120_000
|
|
27
|
+
MAX_FEE_PER_GAS_RATE = 1.2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NonceManager:
|
|
31
|
+
"""
|
|
32
|
+
Thread-safe nonce manager to track and increment nonces per address/chain.
|
|
33
|
+
Prevents nonce conflicts when multiple transactions are sent in quick succession.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
# Dictionary: (address, chain_id) -> current_nonce
|
|
38
|
+
self._nonces: dict[tuple[str, int], int] = {}
|
|
39
|
+
self._lock: asyncio.Lock | None = None
|
|
40
|
+
|
|
41
|
+
def _get_lock(self) -> asyncio.Lock:
|
|
42
|
+
"""Get or create the async lock."""
|
|
43
|
+
if self._lock is None:
|
|
44
|
+
self._lock = asyncio.Lock()
|
|
45
|
+
return self._lock
|
|
46
|
+
|
|
47
|
+
async def get_next_nonce(self, address: str, chain_id: int, w3: AsyncWeb3) -> int:
|
|
48
|
+
"""
|
|
49
|
+
Get the next nonce for an address on a chain.
|
|
50
|
+
Tracks nonces locally and syncs with chain when needed.
|
|
51
|
+
"""
|
|
52
|
+
async with self._get_lock():
|
|
53
|
+
key = (address.lower(), chain_id)
|
|
54
|
+
|
|
55
|
+
# If we don't have a tracked nonce, fetch from chain
|
|
56
|
+
if key not in self._nonces:
|
|
57
|
+
chain_nonce = await w3.eth.get_transaction_count(address, "pending")
|
|
58
|
+
self._nonces[key] = chain_nonce
|
|
59
|
+
return chain_nonce
|
|
60
|
+
|
|
61
|
+
# Return the tracked nonce and increment for next time
|
|
62
|
+
current_nonce = self._nonces[key]
|
|
63
|
+
self._nonces[key] = current_nonce + 1
|
|
64
|
+
return current_nonce
|
|
65
|
+
|
|
66
|
+
async def sync_nonce(self, address: str, chain_id: int, chain_nonce: int) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Sync the tracked nonce with the chain nonce.
|
|
69
|
+
Used when we detect a mismatch or after a transaction fails.
|
|
70
|
+
"""
|
|
71
|
+
async with self._get_lock():
|
|
72
|
+
key = (address.lower(), chain_id)
|
|
73
|
+
# Use the higher of the two to avoid going backwards
|
|
74
|
+
if key in self._nonces:
|
|
75
|
+
self._nonces[key] = max(self._nonces[key], chain_nonce)
|
|
76
|
+
else:
|
|
77
|
+
self._nonces[key] = chain_nonce
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LocalEvmTxn(EvmTxn):
|
|
81
|
+
"""
|
|
82
|
+
Local wallet provider using private keys stored in config or environment variables.
|
|
83
|
+
|
|
84
|
+
This provider implements the current default behavior:
|
|
85
|
+
- Resolves private keys from config or environment
|
|
86
|
+
- Signs transactions using eth_account
|
|
87
|
+
- Broadcasts transactions via RPC
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
91
|
+
"""
|
|
92
|
+
Initialize local wallet provider.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
config: Configuration dictionary containing wallet information
|
|
96
|
+
"""
|
|
97
|
+
self.config = config or {}
|
|
98
|
+
self.logger = logger.bind(provider="LocalWalletProvider")
|
|
99
|
+
self._nonce_manager = NonceManager()
|
|
100
|
+
|
|
101
|
+
def get_web3(self, chain_id: int) -> AsyncWeb3:
|
|
102
|
+
"""
|
|
103
|
+
Return an AsyncWeb3 configured for the requested chain.
|
|
104
|
+
|
|
105
|
+
Callers are responsible for closing the provider session when finished.
|
|
106
|
+
"""
|
|
107
|
+
rpc_url = self._resolve_rpc_url(chain_id)
|
|
108
|
+
return AsyncWeb3(AsyncHTTPProvider(rpc_url))
|
|
109
|
+
|
|
110
|
+
async def get_balance(
|
|
111
|
+
self,
|
|
112
|
+
address: str,
|
|
113
|
+
token_address: str | None,
|
|
114
|
+
chain_id: int,
|
|
115
|
+
) -> tuple[bool, Any]:
|
|
116
|
+
"""
|
|
117
|
+
Get balance for an address (native or ERC20 token).
|
|
118
|
+
"""
|
|
119
|
+
w3 = self.get_web3(chain_id)
|
|
120
|
+
try:
|
|
121
|
+
checksum_addr = to_checksum_address(address)
|
|
122
|
+
|
|
123
|
+
if not token_address or token_address.lower() == ZERO_ADDRESS:
|
|
124
|
+
balance = await w3.eth.get_balance(checksum_addr)
|
|
125
|
+
return (True, int(balance))
|
|
126
|
+
|
|
127
|
+
token_checksum = to_checksum_address(token_address)
|
|
128
|
+
contract = w3.eth.contract(address=token_checksum, abi=ERC20_MINIMAL_ABI)
|
|
129
|
+
balance = await contract.functions.balanceOf(checksum_addr).call()
|
|
130
|
+
return (True, int(balance))
|
|
131
|
+
|
|
132
|
+
except Exception as exc: # noqa: BLE001
|
|
133
|
+
self.logger.error(f"Failed to get balance: {exc}")
|
|
134
|
+
return (False, f"Balance query failed: {exc}")
|
|
135
|
+
finally:
|
|
136
|
+
await self._close_web3(w3)
|
|
137
|
+
|
|
138
|
+
async def approve_token(
|
|
139
|
+
self,
|
|
140
|
+
token_address: str,
|
|
141
|
+
spender: str,
|
|
142
|
+
amount: int,
|
|
143
|
+
from_address: str,
|
|
144
|
+
chain_id: int,
|
|
145
|
+
wait_for_receipt: bool = True,
|
|
146
|
+
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
147
|
+
) -> tuple[bool, Any]:
|
|
148
|
+
"""
|
|
149
|
+
Approve a spender to spend tokens on behalf of from_address.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
token_checksum = to_checksum_address(token_address)
|
|
153
|
+
spender_checksum = to_checksum_address(spender)
|
|
154
|
+
from_checksum = to_checksum_address(from_address)
|
|
155
|
+
amount_int = int(amount)
|
|
156
|
+
|
|
157
|
+
w3_sync = Web3()
|
|
158
|
+
contract = w3_sync.eth.contract(
|
|
159
|
+
address=token_checksum, abi=ERC20_APPROVAL_ABI
|
|
160
|
+
)
|
|
161
|
+
transaction_data = contract.encodeABI(
|
|
162
|
+
fn_name="approve",
|
|
163
|
+
args=[spender_checksum, amount_int],
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
approve_txn = {
|
|
167
|
+
"from": from_checksum,
|
|
168
|
+
"chainId": int(chain_id),
|
|
169
|
+
"to": token_checksum,
|
|
170
|
+
"data": transaction_data,
|
|
171
|
+
"value": 0,
|
|
172
|
+
"gas": ERC20_APPROVAL_GAS_LIMIT,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return await self.broadcast_transaction(
|
|
176
|
+
approve_txn,
|
|
177
|
+
wait_for_receipt=wait_for_receipt,
|
|
178
|
+
timeout=timeout,
|
|
179
|
+
)
|
|
180
|
+
except Exception as exc: # noqa: BLE001
|
|
181
|
+
self.logger.error(f"ERC20 approval failed: {exc}")
|
|
182
|
+
return (False, f"ERC20 approval failed: {exc}")
|
|
183
|
+
|
|
184
|
+
async def broadcast_transaction(
|
|
185
|
+
self,
|
|
186
|
+
transaction: dict[str, Any],
|
|
187
|
+
*,
|
|
188
|
+
wait_for_receipt: bool = True,
|
|
189
|
+
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
190
|
+
) -> tuple[bool, Any]:
|
|
191
|
+
"""
|
|
192
|
+
Sign and broadcast a transaction dict.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
tx = dict(transaction)
|
|
196
|
+
from_address = tx.get("from")
|
|
197
|
+
if not from_address:
|
|
198
|
+
return (False, "Transaction missing 'from' address")
|
|
199
|
+
checksum_from = to_checksum_address(from_address)
|
|
200
|
+
tx["from"] = checksum_from
|
|
201
|
+
|
|
202
|
+
chain_id = tx.get("chainId") or tx.get("chain_id")
|
|
203
|
+
if chain_id is None:
|
|
204
|
+
return (False, "Transaction missing chainId")
|
|
205
|
+
tx["chainId"] = int(chain_id)
|
|
206
|
+
|
|
207
|
+
w3 = self.get_web3(tx["chainId"])
|
|
208
|
+
try:
|
|
209
|
+
if "value" in tx:
|
|
210
|
+
tx["value"] = self._normalize_int(tx["value"])
|
|
211
|
+
else:
|
|
212
|
+
tx["value"] = 0
|
|
213
|
+
|
|
214
|
+
if "nonce" in tx:
|
|
215
|
+
tx["nonce"] = self._normalize_int(tx["nonce"])
|
|
216
|
+
# Sync our tracked nonce with the provided nonce
|
|
217
|
+
await self._nonce_manager.sync_nonce(
|
|
218
|
+
checksum_from, tx["chainId"], tx["nonce"]
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
# Use nonce manager to get and track the next nonce
|
|
222
|
+
tx["nonce"] = await self._nonce_manager.get_next_nonce(
|
|
223
|
+
checksum_from, tx["chainId"], w3
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if "data" in tx and isinstance(tx["data"], str):
|
|
227
|
+
calldata = tx["data"]
|
|
228
|
+
tx["data"] = (
|
|
229
|
+
calldata if calldata.startswith("0x") else f"0x{calldata}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if "gas" in tx:
|
|
233
|
+
tx["gas"] = self._normalize_int(tx["gas"])
|
|
234
|
+
else:
|
|
235
|
+
estimate_request = {
|
|
236
|
+
"to": tx.get("to"),
|
|
237
|
+
"from": tx["from"],
|
|
238
|
+
"value": tx.get("value", 0),
|
|
239
|
+
"data": tx.get("data", "0x"),
|
|
240
|
+
}
|
|
241
|
+
try:
|
|
242
|
+
tx["gas"] = await w3.eth.estimate_gas(estimate_request)
|
|
243
|
+
except Exception as exc: # noqa: BLE001
|
|
244
|
+
self.logger.warning(
|
|
245
|
+
"Gas estimation failed; using fallback %s. Reason: %s",
|
|
246
|
+
DEFAULT_GAS_ESTIMATE_FALLBACK,
|
|
247
|
+
exc,
|
|
248
|
+
)
|
|
249
|
+
tx["gas"] = DEFAULT_GAS_ESTIMATE_FALLBACK
|
|
250
|
+
|
|
251
|
+
if "maxFeePerGas" in tx or "maxPriorityFeePerGas" in tx:
|
|
252
|
+
if "maxFeePerGas" in tx:
|
|
253
|
+
tx["maxFeePerGas"] = self._normalize_int(tx["maxFeePerGas"])
|
|
254
|
+
else:
|
|
255
|
+
base = await w3.eth.gas_price
|
|
256
|
+
tx["maxFeePerGas"] = int(base * 2)
|
|
257
|
+
|
|
258
|
+
if "maxPriorityFeePerGas" in tx:
|
|
259
|
+
tx["maxPriorityFeePerGas"] = self._normalize_int(
|
|
260
|
+
tx["maxPriorityFeePerGas"]
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
tx["maxPriorityFeePerGas"] = int(ONE_GWEI)
|
|
264
|
+
tx["type"] = 2
|
|
265
|
+
else:
|
|
266
|
+
if "gasPrice" in tx:
|
|
267
|
+
tx["gasPrice"] = self._normalize_int(tx["gasPrice"])
|
|
268
|
+
else:
|
|
269
|
+
gas_price = await w3.eth.gas_price
|
|
270
|
+
tx["gasPrice"] = int(gas_price)
|
|
271
|
+
|
|
272
|
+
signed_tx = self._sign_transaction(tx, checksum_from)
|
|
273
|
+
try:
|
|
274
|
+
tx_hash = await w3.eth.send_raw_transaction(signed_tx)
|
|
275
|
+
tx_hash_hex = tx_hash.hex() if hasattr(tx_hash, "hex") else tx_hash
|
|
276
|
+
|
|
277
|
+
result: dict[str, Any] = {"tx_hash": tx_hash_hex}
|
|
278
|
+
if wait_for_receipt:
|
|
279
|
+
receipt = await w3.eth.wait_for_transaction_receipt(
|
|
280
|
+
tx_hash, timeout=timeout
|
|
281
|
+
)
|
|
282
|
+
result["receipt"] = self._format_receipt(receipt)
|
|
283
|
+
# After successful receipt, sync nonce from chain to ensure accuracy
|
|
284
|
+
chain_nonce = await w3.eth.get_transaction_count(
|
|
285
|
+
checksum_from, "latest"
|
|
286
|
+
)
|
|
287
|
+
await self._nonce_manager.sync_nonce(
|
|
288
|
+
checksum_from, tx["chainId"], chain_nonce
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return (True, result)
|
|
292
|
+
except Exception as send_exc:
|
|
293
|
+
# If transaction fails due to nonce error, sync with chain and retry once
|
|
294
|
+
# Handle both string errors and dict errors (like {'code': -32000, 'message': '...'})
|
|
295
|
+
error_msg = str(send_exc)
|
|
296
|
+
if isinstance(send_exc, dict):
|
|
297
|
+
error_msg = send_exc.get("message", str(send_exc))
|
|
298
|
+
elif hasattr(send_exc, "message"):
|
|
299
|
+
error_msg = str(send_exc.message)
|
|
300
|
+
|
|
301
|
+
if "nonce" in error_msg.lower() and "too low" in error_msg.lower():
|
|
302
|
+
self.logger.warning(
|
|
303
|
+
f"Nonce error detected, syncing with chain: {error_msg}"
|
|
304
|
+
)
|
|
305
|
+
# Sync with chain nonce
|
|
306
|
+
chain_nonce = await w3.eth.get_transaction_count(
|
|
307
|
+
checksum_from, "pending"
|
|
308
|
+
)
|
|
309
|
+
await self._nonce_manager.sync_nonce(
|
|
310
|
+
checksum_from, tx["chainId"], chain_nonce
|
|
311
|
+
)
|
|
312
|
+
# Update tx nonce and retry
|
|
313
|
+
tx["nonce"] = await self._nonce_manager.get_next_nonce(
|
|
314
|
+
checksum_from, tx["chainId"], w3
|
|
315
|
+
)
|
|
316
|
+
signed_tx = self._sign_transaction(tx, checksum_from)
|
|
317
|
+
tx_hash = await w3.eth.send_raw_transaction(signed_tx)
|
|
318
|
+
tx_hash_hex = (
|
|
319
|
+
tx_hash.hex() if hasattr(tx_hash, "hex") else tx_hash
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
result: dict[str, Any] = {"tx_hash": tx_hash_hex}
|
|
323
|
+
if wait_for_receipt:
|
|
324
|
+
receipt = await w3.eth.wait_for_transaction_receipt(
|
|
325
|
+
tx_hash, timeout=timeout
|
|
326
|
+
)
|
|
327
|
+
result["receipt"] = self._format_receipt(receipt)
|
|
328
|
+
# Sync again after successful receipt
|
|
329
|
+
chain_nonce = await w3.eth.get_transaction_count(
|
|
330
|
+
checksum_from, "latest"
|
|
331
|
+
)
|
|
332
|
+
await self._nonce_manager.sync_nonce(
|
|
333
|
+
checksum_from, tx["chainId"], chain_nonce
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return (True, result)
|
|
337
|
+
# Re-raise if it's not a nonce error
|
|
338
|
+
raise
|
|
339
|
+
finally:
|
|
340
|
+
await self._close_web3(w3)
|
|
341
|
+
except Exception as exc: # noqa: BLE001
|
|
342
|
+
self.logger.error(f"Transaction broadcast failed: {exc}")
|
|
343
|
+
return (False, f"Transaction broadcast failed: {exc}")
|
|
344
|
+
|
|
345
|
+
async def transaction_succeeded(
|
|
346
|
+
self, tx_hash: str, chain_id: int, timeout: int = 120
|
|
347
|
+
) -> bool:
|
|
348
|
+
"""Return True if the transaction hash completed successfully on-chain."""
|
|
349
|
+
w3 = self.get_web3(chain_id)
|
|
350
|
+
try:
|
|
351
|
+
receipt = await w3.eth.wait_for_transaction_receipt(
|
|
352
|
+
tx_hash, timeout=timeout
|
|
353
|
+
)
|
|
354
|
+
status = getattr(receipt, "status", None)
|
|
355
|
+
if status is None and isinstance(receipt, dict):
|
|
356
|
+
status = receipt.get("status")
|
|
357
|
+
return status == 1
|
|
358
|
+
except Exception as exc: # noqa: BLE001
|
|
359
|
+
self.logger.warning(
|
|
360
|
+
f"Failed to confirm transaction {tx_hash} on chain {chain_id}: {exc}"
|
|
361
|
+
)
|
|
362
|
+
return False
|
|
363
|
+
finally:
|
|
364
|
+
await self._close_web3(w3)
|
|
365
|
+
|
|
366
|
+
def _sign_transaction(
|
|
367
|
+
self, transaction: dict[str, Any], from_address: str
|
|
368
|
+
) -> bytes:
|
|
369
|
+
private_key = resolve_private_key_for_from_address(from_address, self.config)
|
|
370
|
+
if not private_key:
|
|
371
|
+
raise ValueError(f"No private key available for address {from_address}")
|
|
372
|
+
signed = Account.sign_transaction(transaction, private_key)
|
|
373
|
+
return signed.raw_transaction
|
|
374
|
+
|
|
375
|
+
def _resolve_rpc_url(self, chain_id: int) -> str:
|
|
376
|
+
return resolve_rpc_url(chain_id, self.config or {}, None)
|
|
377
|
+
|
|
378
|
+
async def _close_web3(self, w3: AsyncWeb3) -> None:
|
|
379
|
+
try:
|
|
380
|
+
await w3.provider.session.close()
|
|
381
|
+
except Exception: # noqa: BLE001
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
def _format_receipt(self, receipt: Any) -> dict[str, Any]:
|
|
385
|
+
tx_hash = getattr(receipt, "transactionHash", None)
|
|
386
|
+
if hasattr(tx_hash, "hex"):
|
|
387
|
+
tx_hash = tx_hash.hex()
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
"transactionHash": tx_hash,
|
|
391
|
+
"status": (
|
|
392
|
+
getattr(receipt, "status", None)
|
|
393
|
+
if not isinstance(receipt, dict)
|
|
394
|
+
else receipt.get("status")
|
|
395
|
+
),
|
|
396
|
+
"blockNumber": (
|
|
397
|
+
getattr(receipt, "blockNumber", None)
|
|
398
|
+
if not isinstance(receipt, dict)
|
|
399
|
+
else receipt.get("blockNumber")
|
|
400
|
+
),
|
|
401
|
+
"gasUsed": (
|
|
402
|
+
getattr(receipt, "gasUsed", None)
|
|
403
|
+
if not isinstance(receipt, dict)
|
|
404
|
+
else receipt.get("gasUsed")
|
|
405
|
+
),
|
|
406
|
+
"logs": (
|
|
407
|
+
[
|
|
408
|
+
dict(log_entry) if not isinstance(log_entry, dict) else log_entry
|
|
409
|
+
for log_entry in getattr(receipt, "logs", [])
|
|
410
|
+
]
|
|
411
|
+
if hasattr(receipt, "logs")
|
|
412
|
+
else receipt.get("logs")
|
|
413
|
+
if isinstance(receipt, dict)
|
|
414
|
+
else []
|
|
415
|
+
),
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
def _normalize_int(self, value: Any) -> int:
|
|
419
|
+
if isinstance(value, int):
|
|
420
|
+
return value
|
|
421
|
+
if isinstance(value, float):
|
|
422
|
+
return int(value)
|
|
423
|
+
if isinstance(value, str):
|
|
424
|
+
if value.startswith("0x"):
|
|
425
|
+
return int(value, 16)
|
|
426
|
+
try:
|
|
427
|
+
return int(value)
|
|
428
|
+
except ValueError:
|
|
429
|
+
return int(float(value))
|
|
430
|
+
raise ValueError(f"Unable to convert value '{value}' to int")
|