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.

Files changed (80) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +6 -15
  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/README.md +1 -1
  5. wayfinder_paths/adapters/brap_adapter/adapter.py +139 -74
  6. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +0 -7
  7. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +0 -54
  8. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +1 -1
  9. wayfinder_paths/adapters/ledger_adapter/README.md +1 -1
  10. wayfinder_paths/adapters/moonwell_adapter/README.md +174 -0
  11. wayfinder_paths/adapters/moonwell_adapter/__init__.py +7 -0
  12. wayfinder_paths/adapters/moonwell_adapter/adapter.py +1226 -0
  13. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +635 -0
  14. wayfinder_paths/adapters/pool_adapter/README.md +1 -77
  15. wayfinder_paths/adapters/pool_adapter/adapter.py +0 -122
  16. wayfinder_paths/adapters/pool_adapter/examples.json +0 -57
  17. wayfinder_paths/adapters/pool_adapter/test_adapter.py +0 -86
  18. wayfinder_paths/adapters/token_adapter/README.md +1 -1
  19. wayfinder_paths/core/clients/ClientManager.py +1 -22
  20. wayfinder_paths/core/clients/WalletClient.py +0 -8
  21. wayfinder_paths/core/clients/WayfinderClient.py +7 -12
  22. wayfinder_paths/core/clients/__init__.py +0 -8
  23. wayfinder_paths/core/clients/protocols.py +0 -60
  24. wayfinder_paths/core/config.py +5 -45
  25. wayfinder_paths/core/constants/__init__.py +0 -2
  26. wayfinder_paths/core/constants/base.py +6 -2
  27. wayfinder_paths/core/constants/moonwell_abi.py +411 -0
  28. wayfinder_paths/core/services/base.py +7 -1
  29. wayfinder_paths/core/services/local_evm_txn.py +223 -222
  30. wayfinder_paths/core/services/local_token_txn.py +103 -92
  31. wayfinder_paths/core/services/web3_service.py +0 -2
  32. wayfinder_paths/core/settings.py +8 -8
  33. wayfinder_paths/core/strategies/Strategy.py +1 -5
  34. wayfinder_paths/core/strategies/descriptors.py +1 -1
  35. wayfinder_paths/core/utils/evm_helpers.py +7 -12
  36. wayfinder_paths/core/wallets/README.md +3 -6
  37. wayfinder_paths/run_strategy.py +62 -105
  38. wayfinder_paths/scripts/create_strategy.py +2 -27
  39. wayfinder_paths/scripts/make_wallets.py +1 -25
  40. wayfinder_paths/scripts/run_strategy.py +37 -9
  41. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1 -3
  42. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +87 -138
  43. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +96 -58
  44. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +2 -17
  45. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +4 -1
  46. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +107 -29
  47. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +53 -14
  48. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +108 -0
  49. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/examples.json +11 -0
  50. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2975 -0
  51. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +886 -0
  52. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +0 -7
  53. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +2 -7
  54. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +0 -4
  55. wayfinder_paths/templates/adapter/README.md +5 -21
  56. wayfinder_paths/templates/adapter/adapter.py +1 -2
  57. wayfinder_paths/templates/adapter/test_adapter.py +1 -1
  58. wayfinder_paths/templates/strategy/README.md +4 -21
  59. wayfinder_paths/templates/strategy/test_strategy.py +0 -4
  60. wayfinder_paths/tests/test_smoke_manifest.py +17 -2
  61. {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/METADATA +64 -201
  62. {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/RECORD +64 -71
  63. wayfinder_paths/adapters/balance_adapter/manifest.yaml +0 -8
  64. wayfinder_paths/adapters/brap_adapter/manifest.yaml +0 -11
  65. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +0 -10
  66. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +0 -8
  67. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +0 -11
  68. wayfinder_paths/adapters/pool_adapter/manifest.yaml +0 -10
  69. wayfinder_paths/adapters/token_adapter/manifest.yaml +0 -6
  70. wayfinder_paths/core/clients/SimulationClient.py +0 -192
  71. wayfinder_paths/core/clients/TransactionClient.py +0 -63
  72. wayfinder_paths/core/engine/manifest.py +0 -97
  73. wayfinder_paths/scripts/validate_manifests.py +0 -213
  74. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +0 -23
  75. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +0 -7
  76. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +0 -17
  77. wayfinder_paths/templates/adapter/manifest.yaml +0 -6
  78. wayfinder_paths/templates/strategy/manifest.yaml +0 -8
  79. {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/LICENSE +0 -0
  80. {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
- # 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
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 environment variables.
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 environment
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
- self._nonce_manager = NonceManager()
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
- Return an AsyncWeb3 configured for the requested chain.
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
- return AsyncWeb3(AsyncHTTPProvider(rpc_url))
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 (native or ERC20 token).
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(checksum_addr)
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.encodeABI(
162
- fn_name="approve",
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
- 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
- )
270
+ chain_id = transaction["chainId"]
271
+ from_address = transaction["from"]
225
272
 
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}"
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
- 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,
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
- 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"]
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
- 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
309
+ self.logger.debug(
310
+ f"Transaction {tx_hash_hex} receipt status: {receipt_status} (type: {type(receipt_status).__name__})"
281
311
  )
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
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
- return (True, result)
337
- # Re-raise if it's not a nonce error
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"):