wayfinder-paths 0.1.9__py3-none-any.whl → 0.1.10__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 +1 -1
- wayfinder_paths/adapters/balance_adapter/README.md +1 -2
- wayfinder_paths/adapters/balance_adapter/adapter.py +4 -4
- wayfinder_paths/adapters/brap_adapter/adapter.py +139 -23
- wayfinder_paths/adapters/moonwell_adapter/README.md +174 -0
- wayfinder_paths/adapters/moonwell_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +1226 -0
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +635 -0
- wayfinder_paths/core/clients/AuthClient.py +3 -0
- wayfinder_paths/core/clients/WayfinderClient.py +2 -2
- wayfinder_paths/core/constants/__init__.py +0 -2
- wayfinder_paths/core/constants/base.py +6 -2
- wayfinder_paths/core/constants/moonwell_abi.py +411 -0
- wayfinder_paths/core/engine/StrategyJob.py +3 -0
- wayfinder_paths/core/services/base.py +55 -0
- wayfinder_paths/core/services/local_evm_txn.py +288 -208
- wayfinder_paths/core/services/local_token_txn.py +46 -26
- wayfinder_paths/core/strategies/descriptors.py +1 -1
- wayfinder_paths/run_strategy.py +34 -74
- wayfinder_paths/scripts/create_strategy.py +2 -27
- wayfinder_paths/scripts/run_strategy.py +37 -7
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +1 -1
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +0 -15
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +1 -1
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +108 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/examples.json +11 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2975 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +886 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +0 -7
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1 -1
- wayfinder_paths/templates/adapter/README.md +5 -21
- wayfinder_paths/templates/adapter/adapter.py +1 -2
- wayfinder_paths/templates/adapter/test_adapter.py +1 -1
- wayfinder_paths/templates/strategy/README.md +4 -21
- wayfinder_paths/tests/test_smoke_manifest.py +17 -2
- {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.10.dist-info}/METADATA +60 -187
- {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.10.dist-info}/RECORD +39 -44
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +0 -8
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +0 -11
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +0 -10
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +0 -8
- wayfinder_paths/adapters/ledger_adapter/manifest.yaml +0 -11
- wayfinder_paths/adapters/pool_adapter/manifest.yaml +0 -10
- wayfinder_paths/adapters/token_adapter/manifest.yaml +0 -6
- wayfinder_paths/core/engine/manifest.py +0 -97
- wayfinder_paths/scripts/validate_manifests.py +0 -213
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +0 -23
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +0 -7
- wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +0 -17
- wayfinder_paths/templates/adapter/manifest.yaml +0 -6
- wayfinder_paths/templates/strategy/manifest.yaml +0 -8
- {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.10.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.10.dist-info}/WHEEL +0 -0
|
@@ -4,72 +4,47 @@ from typing import Any
|
|
|
4
4
|
from eth_account import Account
|
|
5
5
|
from eth_utils import to_checksum_address
|
|
6
6
|
from loguru import logger
|
|
7
|
-
from web3 import AsyncHTTPProvider, AsyncWeb3
|
|
7
|
+
from web3 import AsyncHTTPProvider, AsyncWeb3, Web3
|
|
8
8
|
|
|
9
9
|
from wayfinder_paths.core.constants import (
|
|
10
|
-
|
|
11
|
-
ONE_GWEI,
|
|
10
|
+
ZERO_ADDRESS,
|
|
12
11
|
)
|
|
13
12
|
from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
|
|
13
|
+
from wayfinder_paths.core.constants.erc20_abi import (
|
|
14
|
+
ERC20_APPROVAL_ABI,
|
|
15
|
+
ERC20_MINIMAL_ABI,
|
|
16
|
+
)
|
|
14
17
|
from wayfinder_paths.core.services.base import EvmTxn
|
|
15
18
|
from wayfinder_paths.core.utils.evm_helpers import (
|
|
16
19
|
resolve_private_key_for_from_address,
|
|
17
20
|
resolve_rpc_url,
|
|
18
21
|
)
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
Tracks nonces locally and syncs with chain when needed.
|
|
46
|
-
"""
|
|
47
|
-
async with self._get_lock():
|
|
48
|
-
key = (address.lower(), chain_id)
|
|
49
|
-
|
|
50
|
-
# If we don't have a tracked nonce, fetch from chain
|
|
51
|
-
if key not in self._nonces:
|
|
52
|
-
chain_nonce = await w3.eth.get_transaction_count(address, "pending")
|
|
53
|
-
self._nonces[key] = chain_nonce
|
|
54
|
-
return chain_nonce
|
|
55
|
-
|
|
56
|
-
# Return the tracked nonce and increment for next time
|
|
57
|
-
current_nonce = self._nonces[key]
|
|
58
|
-
self._nonces[key] = current_nonce + 1
|
|
59
|
-
return current_nonce
|
|
60
|
-
|
|
61
|
-
async def sync_nonce(self, address: str, chain_id: int, chain_nonce: int) -> None:
|
|
62
|
-
"""
|
|
63
|
-
Sync the tracked nonce with the chain nonce.
|
|
64
|
-
Used when we detect a mismatch or after a transaction fails.
|
|
65
|
-
"""
|
|
66
|
-
async with self._get_lock():
|
|
67
|
-
key = (address.lower(), chain_id)
|
|
68
|
-
# Use the higher of the two to avoid going backwards
|
|
69
|
-
if key in self._nonces:
|
|
70
|
-
self._nonces[key] = max(self._nonces[key], chain_nonce)
|
|
71
|
-
else:
|
|
72
|
-
self._nonces[key] = chain_nonce
|
|
23
|
+
SUGGESTED_GAS_PRICE_MULTIPLIER = 1.5
|
|
24
|
+
SUGGESTED_PRIORITY_FEE_MULTIPLIER = 1.5
|
|
25
|
+
MAX_BASE_FEE_GROWTH_MULTIPLIER = 2
|
|
26
|
+
GAS_LIMIT_BUFFER_MULTIPLIER = 1.5
|
|
27
|
+
|
|
28
|
+
# Chains that don't support EIP-1559 (London) and need legacy gas pricing
|
|
29
|
+
PRE_LONDON_GAS_CHAIN_IDS: set[int] = {56, 42161}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _looks_like_revert_error(error: Any) -> bool:
|
|
33
|
+
msg = str(error).lower()
|
|
34
|
+
return any(
|
|
35
|
+
needle in msg
|
|
36
|
+
for needle in (
|
|
37
|
+
"execution reverted",
|
|
38
|
+
"revert",
|
|
39
|
+
"always failing transaction",
|
|
40
|
+
"gas required exceeds",
|
|
41
|
+
"out of gas",
|
|
42
|
+
"insufficient funds",
|
|
43
|
+
"transfer amount exceeds balance",
|
|
44
|
+
"insufficient balance",
|
|
45
|
+
"insufficient allowance",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
73
48
|
|
|
74
49
|
|
|
75
50
|
class LocalEvmTxn(EvmTxn):
|
|
@@ -83,24 +58,205 @@ class LocalEvmTxn(EvmTxn):
|
|
|
83
58
|
"""
|
|
84
59
|
|
|
85
60
|
def __init__(self, config: dict[str, Any] | None = None):
|
|
86
|
-
"""
|
|
87
|
-
Initialize local wallet provider.
|
|
88
|
-
|
|
89
|
-
Args:
|
|
90
|
-
config: Configuration dictionary containing wallet information
|
|
91
|
-
"""
|
|
92
61
|
self.config = config or {}
|
|
93
62
|
self.logger = logger.bind(provider="LocalWalletProvider")
|
|
94
|
-
|
|
63
|
+
# Cache web3 instances per chain to avoid load balancer inconsistency
|
|
64
|
+
self._web3_cache: dict[int, AsyncWeb3] = {}
|
|
95
65
|
|
|
96
66
|
def get_web3(self, chain_id: int) -> AsyncWeb3:
|
|
67
|
+
# Reuse cached instance to ensure consistent RPC node for reads after writes
|
|
68
|
+
if chain_id in self._web3_cache:
|
|
69
|
+
return self._web3_cache[chain_id]
|
|
70
|
+
rpc_url = self._resolve_rpc_url(chain_id)
|
|
71
|
+
w3 = AsyncWeb3(AsyncHTTPProvider(rpc_url))
|
|
72
|
+
self._web3_cache[chain_id] = w3
|
|
73
|
+
return w3
|
|
74
|
+
|
|
75
|
+
async def get_balance(
|
|
76
|
+
self,
|
|
77
|
+
address: str,
|
|
78
|
+
token_address: str | None,
|
|
79
|
+
chain_id: int,
|
|
80
|
+
block_identifier: int | str | None = None,
|
|
81
|
+
) -> tuple[bool, Any]:
|
|
97
82
|
"""
|
|
98
|
-
|
|
83
|
+
Get balance for an address at a specific block.
|
|
99
84
|
|
|
100
|
-
|
|
85
|
+
Args:
|
|
86
|
+
address: Address to query balance for
|
|
87
|
+
token_address: ERC20 token address, or None for native token
|
|
88
|
+
chain_id: Chain ID
|
|
89
|
+
block_identifier: Block to query at. Can be:
|
|
90
|
+
- int: specific block number (for pinning to tx block)
|
|
91
|
+
- "safe": OP Stack safe block (data posted to L1)
|
|
92
|
+
- "finalized": fully finalized block
|
|
93
|
+
- None/"latest": current head (default, but avoid after txs)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Tuple of (success, balance_integer_or_error_message)
|
|
101
97
|
"""
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
w3 = self.get_web3(chain_id)
|
|
99
|
+
try:
|
|
100
|
+
checksum_addr = to_checksum_address(address)
|
|
101
|
+
block_id = block_identifier if block_identifier is not None else "latest"
|
|
102
|
+
|
|
103
|
+
if not token_address or token_address.lower() == ZERO_ADDRESS:
|
|
104
|
+
balance = await w3.eth.get_balance(
|
|
105
|
+
checksum_addr, block_identifier=block_id
|
|
106
|
+
)
|
|
107
|
+
return (True, int(balance))
|
|
108
|
+
|
|
109
|
+
token_checksum = to_checksum_address(token_address)
|
|
110
|
+
contract = w3.eth.contract(address=token_checksum, abi=ERC20_MINIMAL_ABI)
|
|
111
|
+
balance = await contract.functions.balanceOf(checksum_addr).call(
|
|
112
|
+
block_identifier=block_id
|
|
113
|
+
)
|
|
114
|
+
return (True, int(balance))
|
|
115
|
+
|
|
116
|
+
except Exception as exc: # noqa: BLE001
|
|
117
|
+
self.logger.error(f"Failed to get balance: {exc}")
|
|
118
|
+
return (False, f"Balance query failed: {exc}")
|
|
119
|
+
finally:
|
|
120
|
+
await self._close_web3(w3)
|
|
121
|
+
|
|
122
|
+
async def approve_token(
|
|
123
|
+
self,
|
|
124
|
+
token_address: str,
|
|
125
|
+
spender: str,
|
|
126
|
+
amount: int,
|
|
127
|
+
from_address: str,
|
|
128
|
+
chain_id: int,
|
|
129
|
+
wait_for_receipt: bool = True,
|
|
130
|
+
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
131
|
+
) -> tuple[bool, Any]:
|
|
132
|
+
try:
|
|
133
|
+
token_checksum = to_checksum_address(token_address)
|
|
134
|
+
spender_checksum = to_checksum_address(spender)
|
|
135
|
+
from_checksum = to_checksum_address(from_address)
|
|
136
|
+
amount_int = int(amount)
|
|
137
|
+
|
|
138
|
+
w3_sync = Web3()
|
|
139
|
+
contract = w3_sync.eth.contract(
|
|
140
|
+
address=token_checksum, abi=ERC20_APPROVAL_ABI
|
|
141
|
+
)
|
|
142
|
+
transaction_data = contract.encode_abi(
|
|
143
|
+
"approve", args=[spender_checksum, amount_int]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
approve_txn = {
|
|
147
|
+
"from": from_checksum,
|
|
148
|
+
"chainId": int(chain_id),
|
|
149
|
+
"to": token_checksum,
|
|
150
|
+
"data": transaction_data,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return await self.broadcast_transaction(
|
|
154
|
+
approve_txn,
|
|
155
|
+
wait_for_receipt=wait_for_receipt,
|
|
156
|
+
timeout=timeout,
|
|
157
|
+
)
|
|
158
|
+
except Exception as exc: # noqa: BLE001
|
|
159
|
+
self.logger.error(f"ERC20 approval failed: {exc}")
|
|
160
|
+
return (False, f"ERC20 approval failed: {exc}")
|
|
161
|
+
|
|
162
|
+
def _validate_transaction(self, transaction: dict[str, Any]) -> dict[str, Any]:
|
|
163
|
+
tx = dict(transaction)
|
|
164
|
+
|
|
165
|
+
assert "from" in tx, "Transaction missing 'from' address"
|
|
166
|
+
assert "to" in tx, "Transaction missing 'to' address"
|
|
167
|
+
assert "chainId" in tx, "Transaction missing 'chainId'"
|
|
168
|
+
|
|
169
|
+
tx["from"] = to_checksum_address(tx["from"])
|
|
170
|
+
tx["to"] = to_checksum_address(tx["to"])
|
|
171
|
+
if "value" in tx:
|
|
172
|
+
tx["value"] = self._normalize_int(tx["value"])
|
|
173
|
+
|
|
174
|
+
tx.pop("gas", None)
|
|
175
|
+
tx.pop("gasPrice", None)
|
|
176
|
+
tx.pop("maxFeePerGas", None)
|
|
177
|
+
tx.pop("maxPriorityFeePerGas", None)
|
|
178
|
+
tx.pop("nonce", None)
|
|
179
|
+
|
|
180
|
+
return tx
|
|
181
|
+
|
|
182
|
+
async def _nonce_transaction(
|
|
183
|
+
self, transaction: dict[str, Any], w3: AsyncWeb3
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
transaction["nonce"] = await w3.eth.get_transaction_count(
|
|
186
|
+
transaction["from"], "pending"
|
|
187
|
+
)
|
|
188
|
+
return transaction
|
|
189
|
+
|
|
190
|
+
async def _gas_limit_transaction(
|
|
191
|
+
self, transaction: dict[str, Any], w3: AsyncWeb3
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
# Pop existing gas limit before estimating - if present, the node uses it as
|
|
194
|
+
# a ceiling and fails with "out of gas" instead of returning actual estimate
|
|
195
|
+
existing_gas = transaction.pop("gas", None)
|
|
196
|
+
try:
|
|
197
|
+
transaction.pop("gas", None) # Remove any existing gas limit
|
|
198
|
+
estimated = await w3.eth.estimate_gas(transaction)
|
|
199
|
+
transaction["gas"] = int(estimated * GAS_LIMIT_BUFFER_MULTIPLIER)
|
|
200
|
+
self.logger.debug(
|
|
201
|
+
f"Estimated gas with buffer: {estimated} -> {transaction['gas']}"
|
|
202
|
+
)
|
|
203
|
+
except Exception as exc: # noqa: BLE001
|
|
204
|
+
if _looks_like_revert_error(exc):
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"Gas estimation failed (tx likely to revert): {exc}"
|
|
207
|
+
) from exc
|
|
208
|
+
self.logger.warning(f"Gas estimation failed. Reason: {exc}")
|
|
209
|
+
# Restore existing gas limit if estimation failed, otherwise error
|
|
210
|
+
if existing_gas is not None:
|
|
211
|
+
transaction["gas"] = existing_gas
|
|
212
|
+
else:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"Gas estimation failed and no gas limit set: {exc}"
|
|
215
|
+
) from exc
|
|
216
|
+
|
|
217
|
+
return transaction
|
|
218
|
+
|
|
219
|
+
async def _get_gas_price(self, w3: AsyncWeb3) -> int:
|
|
220
|
+
return await w3.eth.gas_price
|
|
221
|
+
|
|
222
|
+
async def _get_base_fee(self, w3: AsyncWeb3) -> int:
|
|
223
|
+
latest_block = await w3.eth.get_block("latest")
|
|
224
|
+
return latest_block.baseFeePerGas
|
|
225
|
+
|
|
226
|
+
async def _get_priority_fee(self, w3: AsyncWeb3) -> int:
|
|
227
|
+
lookback_blocks = 10
|
|
228
|
+
percentile = 80
|
|
229
|
+
fee_history = await w3.eth.fee_history(lookback_blocks, "latest", [percentile])
|
|
230
|
+
historical_priority_fees = [i[0] for i in fee_history.reward]
|
|
231
|
+
return sum(historical_priority_fees) // len(historical_priority_fees)
|
|
232
|
+
|
|
233
|
+
async def _gas_price_transaction(
|
|
234
|
+
self, transaction: dict[str, Any], chain_id: int, w3: AsyncWeb3
|
|
235
|
+
) -> dict[str, Any]:
|
|
236
|
+
if chain_id in PRE_LONDON_GAS_CHAIN_IDS:
|
|
237
|
+
gas_price = await self._get_gas_price(w3)
|
|
238
|
+
|
|
239
|
+
transaction["gasPrice"] = int(gas_price * SUGGESTED_GAS_PRICE_MULTIPLIER)
|
|
240
|
+
elif chain_id == 999:
|
|
241
|
+
big_block_gas_price = await w3.hype.big_block_gas_price()
|
|
242
|
+
|
|
243
|
+
transaction["maxFeePerGas"] = int(
|
|
244
|
+
big_block_gas_price * SUGGESTED_PRIORITY_FEE_MULTIPLIER
|
|
245
|
+
)
|
|
246
|
+
transaction["maxPriorityFeePerGas"] = 0
|
|
247
|
+
else:
|
|
248
|
+
base_fee = await self._get_base_fee(w3)
|
|
249
|
+
priority_fee = await self._get_priority_fee(w3)
|
|
250
|
+
|
|
251
|
+
transaction["maxFeePerGas"] = int(
|
|
252
|
+
base_fee * MAX_BASE_FEE_GROWTH_MULTIPLIER
|
|
253
|
+
+ priority_fee * SUGGESTED_PRIORITY_FEE_MULTIPLIER
|
|
254
|
+
)
|
|
255
|
+
transaction["maxPriorityFeePerGas"] = int(
|
|
256
|
+
priority_fee * SUGGESTED_PRIORITY_FEE_MULTIPLIER
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return transaction
|
|
104
260
|
|
|
105
261
|
async def broadcast_transaction(
|
|
106
262
|
self,
|
|
@@ -108,157 +264,68 @@ class LocalEvmTxn(EvmTxn):
|
|
|
108
264
|
*,
|
|
109
265
|
wait_for_receipt: bool = True,
|
|
110
266
|
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
267
|
+
confirmations: int = 0,
|
|
111
268
|
) -> tuple[bool, Any]:
|
|
112
|
-
"""
|
|
113
|
-
Sign and broadcast a transaction dict.
|
|
114
|
-
"""
|
|
115
269
|
try:
|
|
116
|
-
|
|
117
|
-
from_address =
|
|
118
|
-
if not from_address:
|
|
119
|
-
return (False, "Transaction missing 'from' address")
|
|
120
|
-
checksum_from = to_checksum_address(from_address)
|
|
121
|
-
tx["from"] = checksum_from
|
|
122
|
-
|
|
123
|
-
chain_id = tx.get("chainId") or tx.get("chain_id")
|
|
124
|
-
if chain_id is None:
|
|
125
|
-
return (False, "Transaction missing chainId")
|
|
126
|
-
tx["chainId"] = int(chain_id)
|
|
127
|
-
|
|
128
|
-
w3 = self.get_web3(tx["chainId"])
|
|
129
|
-
try:
|
|
130
|
-
if "value" in tx:
|
|
131
|
-
tx["value"] = self._normalize_int(tx["value"])
|
|
132
|
-
else:
|
|
133
|
-
tx["value"] = 0
|
|
134
|
-
|
|
135
|
-
if "nonce" in tx:
|
|
136
|
-
tx["nonce"] = self._normalize_int(tx["nonce"])
|
|
137
|
-
# Sync our tracked nonce with the provided nonce
|
|
138
|
-
await self._nonce_manager.sync_nonce(
|
|
139
|
-
checksum_from, tx["chainId"], tx["nonce"]
|
|
140
|
-
)
|
|
141
|
-
else:
|
|
142
|
-
# Use nonce manager to get and track the next nonce
|
|
143
|
-
tx["nonce"] = await self._nonce_manager.get_next_nonce(
|
|
144
|
-
checksum_from, tx["chainId"], w3
|
|
145
|
-
)
|
|
270
|
+
chain_id = transaction["chainId"]
|
|
271
|
+
from_address = transaction["from"]
|
|
146
272
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
273
|
+
w3 = self.get_web3(chain_id)
|
|
274
|
+
try:
|
|
275
|
+
transaction = self._validate_transaction(transaction)
|
|
276
|
+
transaction = await self._nonce_transaction(transaction, w3)
|
|
277
|
+
transaction = await self._gas_limit_transaction(transaction, w3)
|
|
278
|
+
transaction = await self._gas_price_transaction(
|
|
279
|
+
transaction, chain_id, w3
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
signed_tx = self._sign_transaction(transaction, from_address)
|
|
283
|
+
|
|
284
|
+
tx_hash = await w3.eth.send_raw_transaction(signed_tx)
|
|
285
|
+
tx_hash_hex = tx_hash.hex()
|
|
286
|
+
|
|
287
|
+
result: dict[str, Any] = {"tx_hash": tx_hash_hex}
|
|
288
|
+
if wait_for_receipt:
|
|
289
|
+
receipt = await w3.eth.wait_for_transaction_receipt(
|
|
290
|
+
tx_hash, timeout=timeout
|
|
151
291
|
)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
try:
|
|
163
|
-
tx["gas"] = await w3.eth.estimate_gas(estimate_request)
|
|
164
|
-
except Exception as exc: # noqa: BLE001
|
|
165
|
-
self.logger.warning(
|
|
166
|
-
"Gas estimation failed; using fallback %s. Reason: %s",
|
|
167
|
-
DEFAULT_GAS_ESTIMATE_FALLBACK,
|
|
168
|
-
exc,
|
|
169
|
-
)
|
|
170
|
-
tx["gas"] = DEFAULT_GAS_ESTIMATE_FALLBACK
|
|
171
|
-
|
|
172
|
-
if "maxFeePerGas" in tx or "maxPriorityFeePerGas" in tx:
|
|
173
|
-
if "maxFeePerGas" in tx:
|
|
174
|
-
tx["maxFeePerGas"] = self._normalize_int(tx["maxFeePerGas"])
|
|
175
|
-
else:
|
|
176
|
-
base = await w3.eth.gas_price
|
|
177
|
-
tx["maxFeePerGas"] = int(base * 2)
|
|
178
|
-
|
|
179
|
-
if "maxPriorityFeePerGas" in tx:
|
|
180
|
-
tx["maxPriorityFeePerGas"] = self._normalize_int(
|
|
181
|
-
tx["maxPriorityFeePerGas"]
|
|
182
|
-
)
|
|
183
|
-
else:
|
|
184
|
-
tx["maxPriorityFeePerGas"] = int(ONE_GWEI)
|
|
185
|
-
tx["type"] = 2
|
|
186
|
-
else:
|
|
187
|
-
if "gasPrice" in tx:
|
|
188
|
-
tx["gasPrice"] = self._normalize_int(tx["gasPrice"])
|
|
189
|
-
else:
|
|
190
|
-
gas_price = await w3.eth.gas_price
|
|
191
|
-
tx["gasPrice"] = int(gas_price)
|
|
192
|
-
|
|
193
|
-
signed_tx = self._sign_transaction(tx, checksum_from)
|
|
194
|
-
try:
|
|
195
|
-
tx_hash = await w3.eth.send_raw_transaction(signed_tx)
|
|
196
|
-
tx_hash_hex = tx_hash.hex() if hasattr(tx_hash, "hex") else tx_hash
|
|
197
|
-
|
|
198
|
-
result: dict[str, Any] = {"tx_hash": tx_hash_hex}
|
|
199
|
-
if wait_for_receipt:
|
|
200
|
-
receipt = await w3.eth.wait_for_transaction_receipt(
|
|
201
|
-
tx_hash, timeout=timeout
|
|
202
|
-
)
|
|
203
|
-
result["receipt"] = self._format_receipt(receipt)
|
|
204
|
-
# After successful receipt, sync nonce from chain to ensure accuracy
|
|
205
|
-
chain_nonce = await w3.eth.get_transaction_count(
|
|
206
|
-
checksum_from, "latest"
|
|
207
|
-
)
|
|
208
|
-
await self._nonce_manager.sync_nonce(
|
|
209
|
-
checksum_from, tx["chainId"], chain_nonce
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
return (True, result)
|
|
213
|
-
except Exception as send_exc:
|
|
214
|
-
# If transaction fails due to nonce error, sync with chain and retry once
|
|
215
|
-
# Handle both string errors and dict errors (like {'code': -32000, 'message': '...'})
|
|
216
|
-
error_msg = str(send_exc)
|
|
217
|
-
if isinstance(send_exc, dict):
|
|
218
|
-
error_msg = send_exc.get("message", str(send_exc))
|
|
219
|
-
elif hasattr(send_exc, "message"):
|
|
220
|
-
error_msg = str(send_exc.message)
|
|
221
|
-
|
|
222
|
-
if "nonce" in error_msg.lower() and "too low" in error_msg.lower():
|
|
223
|
-
self.logger.warning(
|
|
224
|
-
f"Nonce error detected, syncing with chain: {error_msg}"
|
|
225
|
-
)
|
|
226
|
-
# Sync with chain nonce
|
|
227
|
-
chain_nonce = await w3.eth.get_transaction_count(
|
|
228
|
-
checksum_from, "pending"
|
|
229
|
-
)
|
|
230
|
-
await self._nonce_manager.sync_nonce(
|
|
231
|
-
checksum_from, tx["chainId"], chain_nonce
|
|
292
|
+
result["receipt"] = self._format_receipt(receipt)
|
|
293
|
+
# Add block_number at top level for convenience
|
|
294
|
+
result["block_number"] = result["receipt"].get("blockNumber")
|
|
295
|
+
|
|
296
|
+
receipt_status = result["receipt"].get("status")
|
|
297
|
+
if receipt_status is not None and int(receipt_status) != 1:
|
|
298
|
+
return (
|
|
299
|
+
False,
|
|
300
|
+
f"Transaction reverted (status={receipt_status}): {tx_hash_hex}",
|
|
232
301
|
)
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
302
|
+
# Check if transaction reverted (status=0)
|
|
303
|
+
# Handle both dict-like and attribute access for web3.py receipts
|
|
304
|
+
receipt_status = (
|
|
305
|
+
receipt.get("status")
|
|
306
|
+
if hasattr(receipt, "get")
|
|
307
|
+
else getattr(receipt, "status", None)
|
|
236
308
|
)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
tx_hash_hex = (
|
|
240
|
-
tx_hash.hex() if hasattr(tx_hash, "hex") else tx_hash
|
|
309
|
+
self.logger.debug(
|
|
310
|
+
f"Transaction {tx_hash_hex} receipt status: {receipt_status} (type: {type(receipt_status).__name__})"
|
|
241
311
|
)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
)
|
|
253
|
-
await self._nonce_manager.sync_nonce(
|
|
254
|
-
checksum_from, tx["chainId"], chain_nonce
|
|
312
|
+
if receipt_status == 0:
|
|
313
|
+
self.logger.error(f"Transaction reverted: {tx_hash_hex}")
|
|
314
|
+
return (False, f"Transaction reverted: {tx_hash_hex}")
|
|
315
|
+
|
|
316
|
+
# Wait for additional confirmations if requested
|
|
317
|
+
if confirmations > 0:
|
|
318
|
+
tx_block = result["receipt"].get("blockNumber")
|
|
319
|
+
if tx_block:
|
|
320
|
+
await self._wait_for_confirmations(
|
|
321
|
+
w3, tx_block, confirmations
|
|
255
322
|
)
|
|
256
323
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
raise
|
|
324
|
+
return (True, result)
|
|
325
|
+
|
|
260
326
|
finally:
|
|
261
327
|
await self._close_web3(w3)
|
|
328
|
+
|
|
262
329
|
except Exception as exc: # noqa: BLE001
|
|
263
330
|
self.logger.error(f"Transaction broadcast failed: {exc}")
|
|
264
331
|
return (False, f"Transaction broadcast failed: {exc}")
|
|
@@ -266,7 +333,6 @@ class LocalEvmTxn(EvmTxn):
|
|
|
266
333
|
async def transaction_succeeded(
|
|
267
334
|
self, tx_hash: str, chain_id: int, timeout: int = 120
|
|
268
335
|
) -> bool:
|
|
269
|
-
"""Return True if the transaction hash completed successfully on-chain."""
|
|
270
336
|
w3 = self.get_web3(chain_id)
|
|
271
337
|
try:
|
|
272
338
|
receipt = await w3.eth.wait_for_transaction_receipt(
|
|
@@ -297,11 +363,25 @@ class LocalEvmTxn(EvmTxn):
|
|
|
297
363
|
return resolve_rpc_url(chain_id, self.config or {}, None)
|
|
298
364
|
|
|
299
365
|
async def _close_web3(self, w3: AsyncWeb3) -> None:
|
|
366
|
+
# Don't close cached connections - we want to reuse them for consistency
|
|
367
|
+
if w3 in self._web3_cache.values():
|
|
368
|
+
return
|
|
300
369
|
try:
|
|
301
370
|
await w3.provider.session.close()
|
|
302
371
|
except Exception: # noqa: BLE001
|
|
303
372
|
pass
|
|
304
373
|
|
|
374
|
+
async def _wait_for_confirmations(
|
|
375
|
+
self, w3: AsyncWeb3, tx_block: int, confirmations: int
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Wait until the transaction has the specified number of confirmations."""
|
|
378
|
+
target_block = tx_block + confirmations
|
|
379
|
+
while True:
|
|
380
|
+
current_block = await w3.eth.block_number
|
|
381
|
+
if current_block >= target_block:
|
|
382
|
+
break
|
|
383
|
+
await asyncio.sleep(1)
|
|
384
|
+
|
|
305
385
|
def _format_receipt(self, receipt: Any) -> dict[str, Any]:
|
|
306
386
|
tx_hash = getattr(receipt, "transactionHash", None)
|
|
307
387
|
if hasattr(tx_hash, "hex"):
|