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.

Files changed (53) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +1 -1
  2. wayfinder_paths/adapters/balance_adapter/README.md +1 -2
  3. wayfinder_paths/adapters/balance_adapter/adapter.py +4 -4
  4. wayfinder_paths/adapters/brap_adapter/adapter.py +139 -23
  5. wayfinder_paths/adapters/moonwell_adapter/README.md +174 -0
  6. wayfinder_paths/adapters/moonwell_adapter/__init__.py +7 -0
  7. wayfinder_paths/adapters/moonwell_adapter/adapter.py +1226 -0
  8. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +635 -0
  9. wayfinder_paths/core/clients/AuthClient.py +3 -0
  10. wayfinder_paths/core/clients/WayfinderClient.py +2 -2
  11. wayfinder_paths/core/constants/__init__.py +0 -2
  12. wayfinder_paths/core/constants/base.py +6 -2
  13. wayfinder_paths/core/constants/moonwell_abi.py +411 -0
  14. wayfinder_paths/core/engine/StrategyJob.py +3 -0
  15. wayfinder_paths/core/services/base.py +55 -0
  16. wayfinder_paths/core/services/local_evm_txn.py +288 -208
  17. wayfinder_paths/core/services/local_token_txn.py +46 -26
  18. wayfinder_paths/core/strategies/descriptors.py +1 -1
  19. wayfinder_paths/run_strategy.py +34 -74
  20. wayfinder_paths/scripts/create_strategy.py +2 -27
  21. wayfinder_paths/scripts/run_strategy.py +37 -7
  22. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +1 -1
  23. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +0 -15
  24. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +1 -1
  25. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +108 -0
  26. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/examples.json +11 -0
  27. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2975 -0
  28. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +886 -0
  29. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +0 -7
  30. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1 -1
  31. wayfinder_paths/templates/adapter/README.md +5 -21
  32. wayfinder_paths/templates/adapter/adapter.py +1 -2
  33. wayfinder_paths/templates/adapter/test_adapter.py +1 -1
  34. wayfinder_paths/templates/strategy/README.md +4 -21
  35. wayfinder_paths/tests/test_smoke_manifest.py +17 -2
  36. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.10.dist-info}/METADATA +60 -187
  37. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.10.dist-info}/RECORD +39 -44
  38. wayfinder_paths/adapters/balance_adapter/manifest.yaml +0 -8
  39. wayfinder_paths/adapters/brap_adapter/manifest.yaml +0 -11
  40. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +0 -10
  41. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +0 -8
  42. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +0 -11
  43. wayfinder_paths/adapters/pool_adapter/manifest.yaml +0 -10
  44. wayfinder_paths/adapters/token_adapter/manifest.yaml +0 -6
  45. wayfinder_paths/core/engine/manifest.py +0 -97
  46. wayfinder_paths/scripts/validate_manifests.py +0 -213
  47. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +0 -23
  48. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +0 -7
  49. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +0 -17
  50. wayfinder_paths/templates/adapter/manifest.yaml +0 -6
  51. wayfinder_paths/templates/strategy/manifest.yaml +0 -8
  52. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.10.dist-info}/LICENSE +0 -0
  53. {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
- DEFAULT_GAS_ESTIMATE_FALLBACK,
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
- # Gas management constants for ERC20 approval transactions
21
- ERC20_APPROVAL_GAS_LIMIT = 120_000
22
- MAX_FEE_PER_GAS_RATE = 1.2
23
-
24
-
25
- class NonceManager:
26
- """
27
- Thread-safe nonce manager to track and increment nonces per address/chain.
28
- Prevents nonce conflicts when multiple transactions are sent in quick succession.
29
- """
30
-
31
- def __init__(self):
32
- # Dictionary: (address, chain_id) -> current_nonce
33
- self._nonces: dict[tuple[str, int], int] = {}
34
- self._lock: asyncio.Lock | None = None
35
-
36
- def _get_lock(self) -> asyncio.Lock:
37
- """Get or create the async lock."""
38
- if self._lock is None:
39
- self._lock = asyncio.Lock()
40
- return self._lock
41
-
42
- async def get_next_nonce(self, address: str, chain_id: int, w3: AsyncWeb3) -> int:
43
- """
44
- Get the next nonce for an address on a chain.
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
- self._nonce_manager = NonceManager()
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
- Return an AsyncWeb3 configured for the requested chain.
83
+ Get balance for an address at a specific block.
99
84
 
100
- Callers are responsible for closing the provider session when finished.
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
- rpc_url = self._resolve_rpc_url(chain_id)
103
- return AsyncWeb3(AsyncHTTPProvider(rpc_url))
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
- tx = dict(transaction)
117
- from_address = tx.get("from")
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
- if "data" in tx and isinstance(tx["data"], str):
148
- calldata = tx["data"]
149
- tx["data"] = (
150
- calldata if calldata.startswith("0x") else f"0x{calldata}"
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
- if "gas" in tx:
154
- tx["gas"] = self._normalize_int(tx["gas"])
155
- else:
156
- estimate_request = {
157
- "to": tx.get("to"),
158
- "from": tx["from"],
159
- "value": tx.get("value", 0),
160
- "data": tx.get("data", "0x"),
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
- # Update tx nonce and retry
234
- tx["nonce"] = await self._nonce_manager.get_next_nonce(
235
- checksum_from, tx["chainId"], w3
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
- signed_tx = self._sign_transaction(tx, checksum_from)
238
- tx_hash = await w3.eth.send_raw_transaction(signed_tx)
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
- result: dict[str, Any] = {"tx_hash": tx_hash_hex}
244
- if wait_for_receipt:
245
- receipt = await w3.eth.wait_for_transaction_receipt(
246
- tx_hash, timeout=timeout
247
- )
248
- result["receipt"] = self._format_receipt(receipt)
249
- # Sync again after successful receipt
250
- chain_nonce = await w3.eth.get_transaction_count(
251
- checksum_from, "latest"
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
- return (True, result)
258
- # Re-raise if it's not a nonce error
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"):