wayfinder-paths 0.1.9__py3-none-any.whl → 0.1.11__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 (54) hide show
  1. wayfinder_paths/adapters/balance_adapter/README.md +1 -2
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +4 -4
  3. wayfinder_paths/adapters/brap_adapter/adapter.py +139 -23
  4. wayfinder_paths/adapters/moonwell_adapter/README.md +174 -0
  5. wayfinder_paths/adapters/moonwell_adapter/__init__.py +7 -0
  6. wayfinder_paths/adapters/moonwell_adapter/adapter.py +1226 -0
  7. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +635 -0
  8. wayfinder_paths/core/clients/AuthClient.py +3 -0
  9. wayfinder_paths/core/clients/WayfinderClient.py +2 -2
  10. wayfinder_paths/core/constants/__init__.py +0 -2
  11. wayfinder_paths/core/constants/base.py +6 -2
  12. wayfinder_paths/core/constants/moonwell_abi.py +411 -0
  13. wayfinder_paths/core/engine/StrategyJob.py +3 -0
  14. wayfinder_paths/core/services/local_evm_txn.py +182 -217
  15. wayfinder_paths/core/services/local_token_txn.py +46 -26
  16. wayfinder_paths/core/strategies/descriptors.py +1 -1
  17. wayfinder_paths/core/utils/evm_helpers.py +0 -27
  18. wayfinder_paths/run_strategy.py +34 -74
  19. wayfinder_paths/scripts/create_strategy.py +2 -27
  20. wayfinder_paths/scripts/run_strategy.py +37 -7
  21. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +1 -1
  22. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +0 -15
  23. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +1 -1
  24. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +108 -0
  25. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/examples.json +11 -0
  26. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2975 -0
  27. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +886 -0
  28. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +0 -7
  29. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1 -1
  30. wayfinder_paths/templates/adapter/README.md +5 -21
  31. wayfinder_paths/templates/adapter/adapter.py +1 -2
  32. wayfinder_paths/templates/adapter/test_adapter.py +1 -1
  33. wayfinder_paths/templates/strategy/README.md +4 -21
  34. wayfinder_paths/tests/test_smoke_manifest.py +17 -2
  35. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/METADATA +60 -187
  36. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/RECORD +38 -45
  37. wayfinder_paths/CONFIG_GUIDE.md +0 -390
  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/config.example.json +0 -22
  46. wayfinder_paths/core/engine/manifest.py +0 -97
  47. wayfinder_paths/scripts/validate_manifests.py +0 -213
  48. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +0 -23
  49. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +0 -7
  50. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +0 -17
  51. wayfinder_paths/templates/adapter/manifest.yaml +0 -6
  52. wayfinder_paths/templates/strategy/manifest.yaml +0 -8
  53. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/LICENSE +0 -0
  54. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/WHEEL +0 -0
@@ -6,10 +6,6 @@ from eth_utils import to_checksum_address
6
6
  from loguru import logger
7
7
  from web3 import AsyncHTTPProvider, AsyncWeb3
8
8
 
9
- from wayfinder_paths.core.constants import (
10
- DEFAULT_GAS_ESTIMATE_FALLBACK,
11
- ONE_GWEI,
12
- )
13
9
  from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
14
10
  from wayfinder_paths.core.services.base import EvmTxn
15
11
  from wayfinder_paths.core.utils.evm_helpers import (
@@ -17,59 +13,31 @@ from wayfinder_paths.core.utils.evm_helpers import (
17
13
  resolve_rpc_url,
18
14
  )
19
15
 
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
16
+ SUGGESTED_GAS_PRICE_MULTIPLIER = 1.5
17
+ SUGGESTED_PRIORITY_FEE_MULTIPLIER = 1.5
18
+ MAX_BASE_FEE_GROWTH_MULTIPLIER = 2
19
+ GAS_LIMIT_BUFFER_MULTIPLIER = 1.5
20
+
21
+ # Chains that don't support EIP-1559 (London) and need legacy gas pricing
22
+ PRE_LONDON_GAS_CHAIN_IDS: set[int] = {56, 42161}
23
+
24
+
25
+ def _looks_like_revert_error(error: Any) -> bool:
26
+ msg = str(error).lower()
27
+ return any(
28
+ needle in msg
29
+ for needle in (
30
+ "execution reverted",
31
+ "revert",
32
+ "always failing transaction",
33
+ "gas required exceeds",
34
+ "out of gas",
35
+ "insufficient funds",
36
+ "transfer amount exceeds balance",
37
+ "insufficient balance",
38
+ "insufficient allowance",
39
+ )
40
+ )
73
41
 
74
42
 
75
43
  class LocalEvmTxn(EvmTxn):
@@ -83,24 +51,112 @@ class LocalEvmTxn(EvmTxn):
83
51
  """
84
52
 
85
53
  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
54
  self.config = config or {}
93
55
  self.logger = logger.bind(provider="LocalWalletProvider")
94
- self._nonce_manager = NonceManager()
95
56
 
96
57
  def get_web3(self, chain_id: int) -> AsyncWeb3:
97
- """
98
- Return an AsyncWeb3 configured for the requested chain.
99
-
100
- Callers are responsible for closing the provider session when finished.
101
- """
102
58
  rpc_url = self._resolve_rpc_url(chain_id)
103
- return AsyncWeb3(AsyncHTTPProvider(rpc_url))
59
+ w3 = AsyncWeb3(AsyncHTTPProvider(rpc_url))
60
+ return w3
61
+
62
+ def _validate_transaction(self, transaction: dict[str, Any]) -> dict[str, Any]:
63
+ tx = dict(transaction)
64
+
65
+ assert "from" in tx, "Transaction missing 'from' address"
66
+ assert "to" in tx, "Transaction missing 'to' address"
67
+ assert "chainId" in tx, "Transaction missing 'chainId'"
68
+
69
+ tx["from"] = to_checksum_address(tx["from"])
70
+ tx["to"] = to_checksum_address(tx["to"])
71
+ if "value" in tx:
72
+ tx["value"] = self._normalize_int(tx["value"])
73
+
74
+ tx.pop("gas", None)
75
+ tx.pop("gasPrice", None)
76
+ tx.pop("maxFeePerGas", None)
77
+ tx.pop("maxPriorityFeePerGas", None)
78
+ tx.pop("nonce", None)
79
+
80
+ return tx
81
+
82
+ async def _nonce_transaction(
83
+ self, transaction: dict[str, Any], w3: AsyncWeb3
84
+ ) -> dict[str, Any]:
85
+ transaction["nonce"] = await w3.eth.get_transaction_count(
86
+ transaction["from"], "pending"
87
+ )
88
+ return transaction
89
+
90
+ async def _gas_limit_transaction(
91
+ self, transaction: dict[str, Any], w3: AsyncWeb3
92
+ ) -> dict[str, Any]:
93
+ # Pop existing gas limit before estimating - if present, the node uses it as
94
+ # a ceiling and fails with "out of gas" instead of returning actual estimate
95
+ existing_gas = transaction.pop("gas", None)
96
+ try:
97
+ transaction.pop("gas", None) # Remove any existing gas limit
98
+ estimated = await w3.eth.estimate_gas(transaction)
99
+ transaction["gas"] = int(estimated * GAS_LIMIT_BUFFER_MULTIPLIER)
100
+ self.logger.debug(
101
+ f"Estimated gas with buffer: {estimated} -> {transaction['gas']}"
102
+ )
103
+ except Exception as exc: # noqa: BLE001
104
+ if _looks_like_revert_error(exc):
105
+ raise ValueError(
106
+ f"Gas estimation failed (tx likely to revert): {exc}"
107
+ ) from exc
108
+ self.logger.warning(f"Gas estimation failed. Reason: {exc}")
109
+ # Restore existing gas limit if estimation failed, otherwise error
110
+ if existing_gas is not None:
111
+ transaction["gas"] = existing_gas
112
+ else:
113
+ raise ValueError(
114
+ f"Gas estimation failed and no gas limit set: {exc}"
115
+ ) from exc
116
+
117
+ return transaction
118
+
119
+ async def _get_gas_price(self, w3: AsyncWeb3) -> int:
120
+ return await w3.eth.gas_price
121
+
122
+ async def _get_base_fee(self, w3: AsyncWeb3) -> int:
123
+ latest_block = await w3.eth.get_block("latest")
124
+ return latest_block.baseFeePerGas
125
+
126
+ async def _get_priority_fee(self, w3: AsyncWeb3) -> int:
127
+ lookback_blocks = 10
128
+ percentile = 80
129
+ fee_history = await w3.eth.fee_history(lookback_blocks, "latest", [percentile])
130
+ historical_priority_fees = [i[0] for i in fee_history.reward]
131
+ return sum(historical_priority_fees) // len(historical_priority_fees)
132
+
133
+ async def _gas_price_transaction(
134
+ self, transaction: dict[str, Any], chain_id: int, w3: AsyncWeb3
135
+ ) -> dict[str, Any]:
136
+ if chain_id in PRE_LONDON_GAS_CHAIN_IDS:
137
+ gas_price = await self._get_gas_price(w3)
138
+
139
+ transaction["gasPrice"] = int(gas_price * SUGGESTED_GAS_PRICE_MULTIPLIER)
140
+ elif chain_id == 999:
141
+ big_block_gas_price = await w3.hype.big_block_gas_price()
142
+
143
+ transaction["maxFeePerGas"] = int(
144
+ big_block_gas_price * SUGGESTED_PRIORITY_FEE_MULTIPLIER
145
+ )
146
+ transaction["maxPriorityFeePerGas"] = 0
147
+ else:
148
+ base_fee = await self._get_base_fee(w3)
149
+ priority_fee = await self._get_priority_fee(w3)
150
+
151
+ transaction["maxFeePerGas"] = int(
152
+ base_fee * MAX_BASE_FEE_GROWTH_MULTIPLIER
153
+ + priority_fee * SUGGESTED_PRIORITY_FEE_MULTIPLIER
154
+ )
155
+ transaction["maxPriorityFeePerGas"] = int(
156
+ priority_fee * SUGGESTED_PRIORITY_FEE_MULTIPLIER
157
+ )
158
+
159
+ return transaction
104
160
 
105
161
  async def broadcast_transaction(
106
162
  self,
@@ -108,157 +164,55 @@ class LocalEvmTxn(EvmTxn):
108
164
  *,
109
165
  wait_for_receipt: bool = True,
110
166
  timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
167
+ confirmations: int = 1,
111
168
  ) -> tuple[bool, Any]:
112
- """
113
- Sign and broadcast a transaction dict.
114
- """
115
169
  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
- )
170
+ chain_id = transaction["chainId"]
171
+ from_address = transaction["from"]
146
172
 
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}"
173
+ web3 = self.get_web3(chain_id)
174
+ try:
175
+ transaction = self._validate_transaction(transaction)
176
+ transaction = await self._nonce_transaction(transaction, web3)
177
+ transaction = await self._gas_limit_transaction(transaction, web3)
178
+ transaction = await self._gas_price_transaction(
179
+ transaction, chain_id, web3
180
+ )
181
+
182
+ signed_tx = self._sign_transaction(transaction, from_address)
183
+
184
+ tx_hash = await web3.eth.send_raw_transaction(signed_tx)
185
+ tx_hash_hex = tx_hash.hex()
186
+
187
+ result: dict[str, Any] = {"tx_hash": tx_hash_hex}
188
+ if wait_for_receipt:
189
+ receipt = await web3.eth.wait_for_transaction_receipt(
190
+ tx_hash, timeout=timeout
151
191
  )
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
232
- )
233
- # Update tx nonce and retry
234
- tx["nonce"] = await self._nonce_manager.get_next_nonce(
235
- checksum_from, tx["chainId"], w3
236
- )
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
192
+ result["receipt"] = self._format_receipt(receipt)
193
+ # Add block_number at top level for convenience
194
+ result["block_number"] = result["receipt"].get("blockNumber")
195
+
196
+ receipt_status = result["receipt"].get("status")
197
+ if receipt_status is not None and int(receipt_status) != 1:
198
+ return (
199
+ False,
200
+ f"Transaction reverted (status={receipt_status}): {tx_hash_hex}",
241
201
  )
242
202
 
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
203
+ # Wait for additional confirmations if requested
204
+ if confirmations > 0:
205
+ tx_block = result["receipt"].get("blockNumber")
206
+ if tx_block:
207
+ await self._wait_for_confirmations(
208
+ web3, tx_block, confirmations
255
209
  )
256
210
 
257
- return (True, result)
258
- # Re-raise if it's not a nonce error
259
- raise
211
+ return (True, result)
212
+
260
213
  finally:
261
- await self._close_web3(w3)
214
+ await self._close_web3(web3)
215
+
262
216
  except Exception as exc: # noqa: BLE001
263
217
  self.logger.error(f"Transaction broadcast failed: {exc}")
264
218
  return (False, f"Transaction broadcast failed: {exc}")
@@ -266,7 +220,6 @@ class LocalEvmTxn(EvmTxn):
266
220
  async def transaction_succeeded(
267
221
  self, tx_hash: str, chain_id: int, timeout: int = 120
268
222
  ) -> bool:
269
- """Return True if the transaction hash completed successfully on-chain."""
270
223
  w3 = self.get_web3(chain_id)
271
224
  try:
272
225
  receipt = await w3.eth.wait_for_transaction_receipt(
@@ -296,11 +249,23 @@ class LocalEvmTxn(EvmTxn):
296
249
  def _resolve_rpc_url(self, chain_id: int) -> str:
297
250
  return resolve_rpc_url(chain_id, self.config or {}, None)
298
251
 
299
- async def _close_web3(self, w3: AsyncWeb3) -> None:
252
+ async def _close_web3(self, web3: AsyncWeb3) -> None:
300
253
  try:
301
- await w3.provider.session.close()
302
- except Exception: # noqa: BLE001
303
- pass
254
+ if hasattr(web3.provider, "disconnect"):
255
+ await web3.provider.disconnect()
256
+ except Exception as e: # noqa: BLE001
257
+ self.logger.debug(f"Error disconnecting provider: {e}")
258
+
259
+ async def _wait_for_confirmations(
260
+ self, w3: AsyncWeb3, tx_block: int, confirmations: int
261
+ ) -> None:
262
+ """Wait until the transaction has the specified number of confirmations."""
263
+ target_block = tx_block + confirmations
264
+ while True:
265
+ current_block = await w3.eth.block_number
266
+ if current_block >= target_block:
267
+ break
268
+ await asyncio.sleep(1)
304
269
 
305
270
  def _format_receipt(self, receipt: Any) -> dict[str, Any]:
306
271
  tx_hash = getattr(receipt, "transactionHash", None)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  from decimal import ROUND_DOWN, Decimal
4
5
  from typing import Any
5
6
 
@@ -27,7 +28,6 @@ class LocalTokenTxnService(TokenTxn):
27
28
  self.wallet_provider = wallet_provider
28
29
  self.logger = logger.bind(service="DefaultEvmTransactionService")
29
30
  self.token_client = TokenClient()
30
- self.builder = _EvmTransactionBuilder(wallet_provider)
31
31
 
32
32
  async def build_send(
33
33
  self,
@@ -61,7 +61,7 @@ class LocalTokenTxnService(TokenTxn):
61
61
  amount_wei = self._to_base_units(amount, decimals)
62
62
 
63
63
  try:
64
- tx = await self.builder.build_send_transaction(
64
+ tx = await self.build_send_transaction(
65
65
  from_address=from_address,
66
66
  to_address=to_address,
67
67
  token_address=token_address,
@@ -93,7 +93,7 @@ class LocalTokenTxnService(TokenTxn):
93
93
  except (TypeError, ValueError) as exc:
94
94
  return False, str(exc)
95
95
 
96
- approve_tx = self.builder.build_erc20_approval_transaction(
96
+ approve_tx = self.build_erc20_approval_transaction(
97
97
  chain_id=chain_id,
98
98
  token_address=token_checksum,
99
99
  from_address=from_checksum,
@@ -104,28 +104,53 @@ class LocalTokenTxnService(TokenTxn):
104
104
  return True, approve_tx
105
105
 
106
106
  async def read_erc20_allowance(
107
- self, chain: Any, token_address: str, from_address: str, spender_address: str
107
+ self,
108
+ chain: Any,
109
+ token_address: str,
110
+ from_address: str,
111
+ spender_address: str,
112
+ max_retries: int = 3,
108
113
  ) -> dict[str, Any]:
109
114
  try:
110
115
  chain_id = self._chain_id(chain)
111
116
  except (TypeError, ValueError) as exc:
112
117
  return {"error": str(exc), "allowance": 0}
113
118
 
114
- w3 = self.get_web3(chain_id)
115
- try:
116
- contract = w3.eth.contract(
117
- address=to_checksum_address(token_address), abi=ERC20_APPROVAL_ABI
118
- )
119
- allowance = await contract.functions.allowance(
120
- to_checksum_address(from_address),
121
- to_checksum_address(spender_address),
122
- ).call()
123
- return (True, {"allowance": int(allowance)})
124
- except Exception as exc: # noqa: BLE001
125
- self.logger.error(f"Failed to read allowance: {exc}")
126
- return {"error": f"Allowance query failed: {exc}", "allowance": 0}
127
- finally:
128
- await self._close_web3(w3)
119
+ last_error = None
120
+ for attempt in range(max_retries):
121
+ w3 = self.wallet_provider.get_web3(chain_id)
122
+ try:
123
+ contract = w3.eth.contract(
124
+ address=to_checksum_address(token_address), abi=ERC20_APPROVAL_ABI
125
+ )
126
+ allowance = await contract.functions.allowance(
127
+ to_checksum_address(from_address),
128
+ to_checksum_address(spender_address),
129
+ ).call()
130
+ return {"allowance": int(allowance)}
131
+ except Exception as exc: # noqa: BLE001
132
+ last_error = exc
133
+ error_str = str(exc)
134
+ if "429" in error_str or "Too Many Requests" in error_str:
135
+ if attempt < max_retries - 1:
136
+ wait_time = 3.0 * (2**attempt) # 3, 6, 12 seconds
137
+ self.logger.warning(
138
+ f"Rate limited reading allowance, retrying in {wait_time}s..."
139
+ )
140
+ await asyncio.sleep(wait_time)
141
+ continue
142
+ self.logger.error(f"Failed to read allowance: {exc}")
143
+ return {"error": f"Allowance query failed: {exc}", "allowance": 0}
144
+ finally:
145
+ await self.wallet_provider._close_web3(w3)
146
+
147
+ self.logger.error(
148
+ f"Failed to read allowance after {max_retries} retries: {last_error}"
149
+ )
150
+ return {
151
+ "error": f"Allowance query failed after retries: {last_error}",
152
+ "allowance": 0,
153
+ }
129
154
 
130
155
  def _chain_id(self, chain: Any) -> int:
131
156
  if isinstance(chain, dict):
@@ -144,13 +169,6 @@ class LocalTokenTxnService(TokenTxn):
144
169
  )
145
170
  return int(quantized)
146
171
 
147
-
148
- class _EvmTransactionBuilder:
149
- """Helpers that only build transaction dictionaries for sends and approvals."""
150
-
151
- def __init__(self, wallet_provider: EvmTxn) -> None:
152
- self.wallet_provider = wallet_provider
153
-
154
172
  async def build_send_transaction(
155
173
  self,
156
174
  *,
@@ -200,6 +218,7 @@ class _EvmTransactionBuilder:
200
218
  web3: AsyncWeb3,
201
219
  ) -> dict[str, Any]:
202
220
  """Build an ERC20 approval transaction dict."""
221
+ del web3 # Use sync Web3 for encoding (AsyncContract doesn't have encodeABI)
203
222
  token_checksum = to_checksum_address(token_address)
204
223
  spender_checksum = to_checksum_address(spender)
205
224
  from_checksum = to_checksum_address(from_address)
@@ -208,6 +227,7 @@ class _EvmTransactionBuilder:
208
227
  # Use synchronous Web3 for encoding (encodeABI doesn't exist in web3.py v7)
209
228
  w3_sync = Web3()
210
229
  contract = w3_sync.eth.contract(address=token_checksum, abi=ERC20_APPROVAL_ABI)
230
+
211
231
  # In web3.py v7, use _encode_transaction_data to encode without network calls
212
232
  data = contract.functions.approve(
213
233
  spender_checksum, amount_int
@@ -66,7 +66,7 @@ class StratDescriptor(BaseModel):
66
66
 
67
67
  # risk indicators
68
68
  volatility: Volatility
69
- volatility_description_short: str
69
+ volatility_description: str
70
70
  directionality: Directionality
71
71
  directionality_description: str
72
72
  complexity: Complexity
@@ -11,7 +11,6 @@ from pathlib import Path
11
11
  from typing import Any
12
12
 
13
13
  from loguru import logger
14
- from web3 import AsyncHTTPProvider, AsyncWeb3
15
14
 
16
15
  from wayfinder_paths.core.constants.base import CHAIN_CODE_TO_ID
17
16
 
@@ -86,32 +85,6 @@ def resolve_rpc_url(
86
85
  raise ValueError("RPC URL not provided. Set strategy.rpc_urls in config.json.")
87
86
 
88
87
 
89
- async def get_next_nonce(
90
- from_address: str, rpc_url: str, use_latest: bool = False
91
- ) -> int:
92
- """
93
- Get the next nonce for the given address.
94
-
95
- Args:
96
- from_address: Address to get nonce for
97
- rpc_url: RPC URL to connect to
98
- use_latest: If True, use 'latest' block instead of 'pending'
99
-
100
- Returns:
101
- Next nonce as integer
102
- """
103
- w3 = AsyncWeb3(AsyncHTTPProvider(rpc_url))
104
- try:
105
- if use_latest:
106
- return await w3.eth.get_transaction_count(from_address, "latest")
107
- return await w3.eth.get_transaction_count(from_address)
108
- finally:
109
- try:
110
- await w3.provider.session.close()
111
- except Exception:
112
- pass
113
-
114
-
115
88
  def resolve_private_key_for_from_address(
116
89
  from_address: str, config: dict[str, Any]
117
90
  ) -> str | None: