wayfinder-paths 0.1.8__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 +6 -15
- wayfinder_paths/adapters/balance_adapter/README.md +1 -2
- wayfinder_paths/adapters/balance_adapter/adapter.py +4 -4
- wayfinder_paths/adapters/brap_adapter/README.md +1 -1
- wayfinder_paths/adapters/brap_adapter/adapter.py +139 -74
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +0 -7
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +0 -54
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +1 -1
- wayfinder_paths/adapters/ledger_adapter/README.md +1 -1
- 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/adapters/pool_adapter/README.md +1 -77
- wayfinder_paths/adapters/pool_adapter/adapter.py +0 -122
- wayfinder_paths/adapters/pool_adapter/examples.json +0 -57
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +0 -86
- wayfinder_paths/adapters/token_adapter/README.md +1 -1
- wayfinder_paths/core/clients/ClientManager.py +1 -22
- wayfinder_paths/core/clients/WalletClient.py +0 -8
- wayfinder_paths/core/clients/WayfinderClient.py +7 -12
- wayfinder_paths/core/clients/__init__.py +0 -8
- wayfinder_paths/core/clients/protocols.py +0 -60
- wayfinder_paths/core/config.py +5 -45
- 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/services/base.py +7 -1
- wayfinder_paths/core/services/local_evm_txn.py +223 -222
- wayfinder_paths/core/services/local_token_txn.py +103 -92
- wayfinder_paths/core/services/web3_service.py +0 -2
- wayfinder_paths/core/settings.py +8 -8
- wayfinder_paths/core/strategies/Strategy.py +1 -5
- wayfinder_paths/core/strategies/descriptors.py +1 -1
- wayfinder_paths/core/utils/evm_helpers.py +7 -12
- wayfinder_paths/core/wallets/README.md +3 -6
- wayfinder_paths/run_strategy.py +62 -105
- wayfinder_paths/scripts/create_strategy.py +2 -27
- wayfinder_paths/scripts/make_wallets.py +1 -25
- wayfinder_paths/scripts/run_strategy.py +37 -9
- wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1 -3
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +87 -138
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +96 -58
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +2 -17
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +4 -1
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +107 -29
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +53 -14
- 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 +2 -7
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +0 -4
- 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/templates/strategy/test_strategy.py +0 -4
- wayfinder_paths/tests/test_smoke_manifest.py +17 -2
- {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/METADATA +64 -201
- {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/RECORD +64 -71
- 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/clients/SimulationClient.py +0 -192
- wayfinder_paths/core/clients/TransactionClient.py +0 -63
- 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.8.dist-info → wayfinder_paths-0.1.10.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/WHEEL +0 -0
|
@@ -7,8 +7,6 @@ from loguru import logger
|
|
|
7
7
|
from web3 import AsyncHTTPProvider, AsyncWeb3, Web3
|
|
8
8
|
|
|
9
9
|
from wayfinder_paths.core.constants import (
|
|
10
|
-
DEFAULT_GAS_ESTIMATE_FALLBACK,
|
|
11
|
-
ONE_GWEI,
|
|
12
10
|
ZERO_ADDRESS,
|
|
13
11
|
)
|
|
14
12
|
from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
|
|
@@ -22,111 +20,97 @@ from wayfinder_paths.core.utils.evm_helpers import (
|
|
|
22
20
|
resolve_rpc_url,
|
|
23
21
|
)
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
+
)
|
|
78
48
|
|
|
79
49
|
|
|
80
50
|
class LocalEvmTxn(EvmTxn):
|
|
81
51
|
"""
|
|
82
|
-
Local wallet provider using private keys stored in config or
|
|
52
|
+
Local wallet provider using private keys stored in config.json or wallets.json.
|
|
83
53
|
|
|
84
54
|
This provider implements the current default behavior:
|
|
85
|
-
- Resolves private keys from config or
|
|
55
|
+
- Resolves private keys from config.json or wallets.json
|
|
86
56
|
- Signs transactions using eth_account
|
|
87
57
|
- Broadcasts transactions via RPC
|
|
88
58
|
"""
|
|
89
59
|
|
|
90
60
|
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
61
|
self.config = config or {}
|
|
98
62
|
self.logger = logger.bind(provider="LocalWalletProvider")
|
|
99
|
-
|
|
63
|
+
# Cache web3 instances per chain to avoid load balancer inconsistency
|
|
64
|
+
self._web3_cache: dict[int, AsyncWeb3] = {}
|
|
100
65
|
|
|
101
66
|
def get_web3(self, chain_id: int) -> AsyncWeb3:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
Callers are responsible for closing the provider session when finished.
|
|
106
|
-
"""
|
|
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]
|
|
107
70
|
rpc_url = self._resolve_rpc_url(chain_id)
|
|
108
|
-
|
|
71
|
+
w3 = AsyncWeb3(AsyncHTTPProvider(rpc_url))
|
|
72
|
+
self._web3_cache[chain_id] = w3
|
|
73
|
+
return w3
|
|
109
74
|
|
|
110
75
|
async def get_balance(
|
|
111
76
|
self,
|
|
112
77
|
address: str,
|
|
113
78
|
token_address: str | None,
|
|
114
79
|
chain_id: int,
|
|
80
|
+
block_identifier: int | str | None = None,
|
|
115
81
|
) -> tuple[bool, Any]:
|
|
116
82
|
"""
|
|
117
|
-
Get balance for an address
|
|
83
|
+
Get balance for an address at a specific block.
|
|
84
|
+
|
|
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)
|
|
118
97
|
"""
|
|
119
98
|
w3 = self.get_web3(chain_id)
|
|
120
99
|
try:
|
|
121
100
|
checksum_addr = to_checksum_address(address)
|
|
101
|
+
block_id = block_identifier if block_identifier is not None else "latest"
|
|
122
102
|
|
|
123
103
|
if not token_address or token_address.lower() == ZERO_ADDRESS:
|
|
124
|
-
balance = await w3.eth.get_balance(
|
|
104
|
+
balance = await w3.eth.get_balance(
|
|
105
|
+
checksum_addr, block_identifier=block_id
|
|
106
|
+
)
|
|
125
107
|
return (True, int(balance))
|
|
126
108
|
|
|
127
109
|
token_checksum = to_checksum_address(token_address)
|
|
128
110
|
contract = w3.eth.contract(address=token_checksum, abi=ERC20_MINIMAL_ABI)
|
|
129
|
-
balance = await contract.functions.balanceOf(checksum_addr).call(
|
|
111
|
+
balance = await contract.functions.balanceOf(checksum_addr).call(
|
|
112
|
+
block_identifier=block_id
|
|
113
|
+
)
|
|
130
114
|
return (True, int(balance))
|
|
131
115
|
|
|
132
116
|
except Exception as exc: # noqa: BLE001
|
|
@@ -145,9 +129,6 @@ class LocalEvmTxn(EvmTxn):
|
|
|
145
129
|
wait_for_receipt: bool = True,
|
|
146
130
|
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
147
131
|
) -> tuple[bool, Any]:
|
|
148
|
-
"""
|
|
149
|
-
Approve a spender to spend tokens on behalf of from_address.
|
|
150
|
-
"""
|
|
151
132
|
try:
|
|
152
133
|
token_checksum = to_checksum_address(token_address)
|
|
153
134
|
spender_checksum = to_checksum_address(spender)
|
|
@@ -158,9 +139,8 @@ class LocalEvmTxn(EvmTxn):
|
|
|
158
139
|
contract = w3_sync.eth.contract(
|
|
159
140
|
address=token_checksum, abi=ERC20_APPROVAL_ABI
|
|
160
141
|
)
|
|
161
|
-
transaction_data = contract.
|
|
162
|
-
|
|
163
|
-
args=[spender_checksum, amount_int],
|
|
142
|
+
transaction_data = contract.encode_abi(
|
|
143
|
+
"approve", args=[spender_checksum, amount_int]
|
|
164
144
|
)
|
|
165
145
|
|
|
166
146
|
approve_txn = {
|
|
@@ -168,8 +148,6 @@ class LocalEvmTxn(EvmTxn):
|
|
|
168
148
|
"chainId": int(chain_id),
|
|
169
149
|
"to": token_checksum,
|
|
170
150
|
"data": transaction_data,
|
|
171
|
-
"value": 0,
|
|
172
|
-
"gas": ERC20_APPROVAL_GAS_LIMIT,
|
|
173
151
|
}
|
|
174
152
|
|
|
175
153
|
return await self.broadcast_transaction(
|
|
@@ -181,163 +159,173 @@ class LocalEvmTxn(EvmTxn):
|
|
|
181
159
|
self.logger.error(f"ERC20 approval failed: {exc}")
|
|
182
160
|
return (False, f"ERC20 approval failed: {exc}")
|
|
183
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
|
|
260
|
+
|
|
184
261
|
async def broadcast_transaction(
|
|
185
262
|
self,
|
|
186
263
|
transaction: dict[str, Any],
|
|
187
264
|
*,
|
|
188
265
|
wait_for_receipt: bool = True,
|
|
189
266
|
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
267
|
+
confirmations: int = 0,
|
|
190
268
|
) -> tuple[bool, Any]:
|
|
191
|
-
"""
|
|
192
|
-
Sign and broadcast a transaction dict.
|
|
193
|
-
"""
|
|
194
269
|
try:
|
|
195
|
-
|
|
196
|
-
from_address =
|
|
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
|
-
)
|
|
270
|
+
chain_id = transaction["chainId"]
|
|
271
|
+
from_address = transaction["from"]
|
|
225
272
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
230
291
|
)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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,
|
|
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}",
|
|
248
301
|
)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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"]
|
|
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)
|
|
261
308
|
)
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
309
|
+
self.logger.debug(
|
|
310
|
+
f"Transaction {tx_hash_hex} receipt status: {receipt_status} (type: {type(receipt_status).__name__})"
|
|
281
311
|
)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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
|
|
334
322
|
)
|
|
335
323
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
raise
|
|
324
|
+
return (True, result)
|
|
325
|
+
|
|
339
326
|
finally:
|
|
340
327
|
await self._close_web3(w3)
|
|
328
|
+
|
|
341
329
|
except Exception as exc: # noqa: BLE001
|
|
342
330
|
self.logger.error(f"Transaction broadcast failed: {exc}")
|
|
343
331
|
return (False, f"Transaction broadcast failed: {exc}")
|
|
@@ -345,7 +333,6 @@ class LocalEvmTxn(EvmTxn):
|
|
|
345
333
|
async def transaction_succeeded(
|
|
346
334
|
self, tx_hash: str, chain_id: int, timeout: int = 120
|
|
347
335
|
) -> bool:
|
|
348
|
-
"""Return True if the transaction hash completed successfully on-chain."""
|
|
349
336
|
w3 = self.get_web3(chain_id)
|
|
350
337
|
try:
|
|
351
338
|
receipt = await w3.eth.wait_for_transaction_receipt(
|
|
@@ -376,11 +363,25 @@ class LocalEvmTxn(EvmTxn):
|
|
|
376
363
|
return resolve_rpc_url(chain_id, self.config or {}, None)
|
|
377
364
|
|
|
378
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
|
|
379
369
|
try:
|
|
380
370
|
await w3.provider.session.close()
|
|
381
371
|
except Exception: # noqa: BLE001
|
|
382
372
|
pass
|
|
383
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
|
+
|
|
384
385
|
def _format_receipt(self, receipt: Any) -> dict[str, Any]:
|
|
385
386
|
tx_hash = getattr(receipt, "transactionHash", None)
|
|
386
387
|
if hasattr(tx_hash, "hex"):
|