wayfinder-paths 0.1.7__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 (149) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +399 -0
  2. wayfinder_paths/__init__.py +22 -0
  3. wayfinder_paths/abis/generic/erc20.json +383 -0
  4. wayfinder_paths/adapters/__init__.py +0 -0
  5. wayfinder_paths/adapters/balance_adapter/README.md +94 -0
  6. wayfinder_paths/adapters/balance_adapter/adapter.py +238 -0
  7. wayfinder_paths/adapters/balance_adapter/examples.json +6 -0
  8. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  9. wayfinder_paths/adapters/balance_adapter/test_adapter.py +59 -0
  10. wayfinder_paths/adapters/brap_adapter/README.md +249 -0
  11. wayfinder_paths/adapters/brap_adapter/__init__.py +7 -0
  12. wayfinder_paths/adapters/brap_adapter/adapter.py +726 -0
  13. wayfinder_paths/adapters/brap_adapter/examples.json +175 -0
  14. wayfinder_paths/adapters/brap_adapter/manifest.yaml +11 -0
  15. wayfinder_paths/adapters/brap_adapter/test_adapter.py +286 -0
  16. wayfinder_paths/adapters/hyperlend_adapter/__init__.py +7 -0
  17. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +305 -0
  18. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +10 -0
  19. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +274 -0
  20. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
  21. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
  22. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
  23. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
  24. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
  25. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
  26. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
  27. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
  28. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
  29. wayfinder_paths/adapters/ledger_adapter/README.md +145 -0
  30. wayfinder_paths/adapters/ledger_adapter/__init__.py +7 -0
  31. wayfinder_paths/adapters/ledger_adapter/adapter.py +289 -0
  32. wayfinder_paths/adapters/ledger_adapter/examples.json +137 -0
  33. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +11 -0
  34. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +205 -0
  35. wayfinder_paths/adapters/pool_adapter/README.md +206 -0
  36. wayfinder_paths/adapters/pool_adapter/__init__.py +7 -0
  37. wayfinder_paths/adapters/pool_adapter/adapter.py +282 -0
  38. wayfinder_paths/adapters/pool_adapter/examples.json +143 -0
  39. wayfinder_paths/adapters/pool_adapter/manifest.yaml +10 -0
  40. wayfinder_paths/adapters/pool_adapter/test_adapter.py +220 -0
  41. wayfinder_paths/adapters/token_adapter/README.md +101 -0
  42. wayfinder_paths/adapters/token_adapter/__init__.py +3 -0
  43. wayfinder_paths/adapters/token_adapter/adapter.py +96 -0
  44. wayfinder_paths/adapters/token_adapter/examples.json +26 -0
  45. wayfinder_paths/adapters/token_adapter/manifest.yaml +6 -0
  46. wayfinder_paths/adapters/token_adapter/test_adapter.py +125 -0
  47. wayfinder_paths/config.example.json +22 -0
  48. wayfinder_paths/conftest.py +31 -0
  49. wayfinder_paths/core/__init__.py +18 -0
  50. wayfinder_paths/core/adapters/BaseAdapter.py +65 -0
  51. wayfinder_paths/core/adapters/__init__.py +5 -0
  52. wayfinder_paths/core/adapters/base.py +5 -0
  53. wayfinder_paths/core/adapters/models.py +46 -0
  54. wayfinder_paths/core/analytics/__init__.py +11 -0
  55. wayfinder_paths/core/analytics/bootstrap.py +57 -0
  56. wayfinder_paths/core/analytics/stats.py +48 -0
  57. wayfinder_paths/core/analytics/test_analytics.py +170 -0
  58. wayfinder_paths/core/clients/AuthClient.py +83 -0
  59. wayfinder_paths/core/clients/BRAPClient.py +109 -0
  60. wayfinder_paths/core/clients/ClientManager.py +210 -0
  61. wayfinder_paths/core/clients/HyperlendClient.py +192 -0
  62. wayfinder_paths/core/clients/LedgerClient.py +443 -0
  63. wayfinder_paths/core/clients/PoolClient.py +128 -0
  64. wayfinder_paths/core/clients/SimulationClient.py +192 -0
  65. wayfinder_paths/core/clients/TokenClient.py +89 -0
  66. wayfinder_paths/core/clients/TransactionClient.py +63 -0
  67. wayfinder_paths/core/clients/WalletClient.py +94 -0
  68. wayfinder_paths/core/clients/WayfinderClient.py +269 -0
  69. wayfinder_paths/core/clients/__init__.py +48 -0
  70. wayfinder_paths/core/clients/protocols.py +392 -0
  71. wayfinder_paths/core/clients/sdk_example.py +110 -0
  72. wayfinder_paths/core/config.py +458 -0
  73. wayfinder_paths/core/constants/__init__.py +26 -0
  74. wayfinder_paths/core/constants/base.py +42 -0
  75. wayfinder_paths/core/constants/erc20_abi.py +118 -0
  76. wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
  77. wayfinder_paths/core/engine/StrategyJob.py +188 -0
  78. wayfinder_paths/core/engine/__init__.py +5 -0
  79. wayfinder_paths/core/engine/manifest.py +97 -0
  80. wayfinder_paths/core/services/__init__.py +0 -0
  81. wayfinder_paths/core/services/base.py +179 -0
  82. wayfinder_paths/core/services/local_evm_txn.py +430 -0
  83. wayfinder_paths/core/services/local_token_txn.py +231 -0
  84. wayfinder_paths/core/services/web3_service.py +45 -0
  85. wayfinder_paths/core/settings.py +61 -0
  86. wayfinder_paths/core/strategies/Strategy.py +280 -0
  87. wayfinder_paths/core/strategies/__init__.py +5 -0
  88. wayfinder_paths/core/strategies/base.py +7 -0
  89. wayfinder_paths/core/strategies/descriptors.py +81 -0
  90. wayfinder_paths/core/utils/__init__.py +1 -0
  91. wayfinder_paths/core/utils/evm_helpers.py +206 -0
  92. wayfinder_paths/core/utils/wallets.py +77 -0
  93. wayfinder_paths/core/wallets/README.md +91 -0
  94. wayfinder_paths/core/wallets/WalletManager.py +56 -0
  95. wayfinder_paths/core/wallets/__init__.py +7 -0
  96. wayfinder_paths/policies/enso.py +17 -0
  97. wayfinder_paths/policies/erc20.py +34 -0
  98. wayfinder_paths/policies/evm.py +21 -0
  99. wayfinder_paths/policies/hyper_evm.py +19 -0
  100. wayfinder_paths/policies/hyperlend.py +12 -0
  101. wayfinder_paths/policies/hyperliquid.py +30 -0
  102. wayfinder_paths/policies/moonwell.py +54 -0
  103. wayfinder_paths/policies/prjx.py +30 -0
  104. wayfinder_paths/policies/util.py +27 -0
  105. wayfinder_paths/run_strategy.py +411 -0
  106. wayfinder_paths/scripts/__init__.py +0 -0
  107. wayfinder_paths/scripts/create_strategy.py +181 -0
  108. wayfinder_paths/scripts/make_wallets.py +169 -0
  109. wayfinder_paths/scripts/run_strategy.py +124 -0
  110. wayfinder_paths/scripts/validate_manifests.py +213 -0
  111. wayfinder_paths/strategies/__init__.py +0 -0
  112. wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
  113. wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
  114. wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
  115. wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
  116. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
  117. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
  118. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
  119. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
  120. wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
  121. wayfinder_paths/strategies/config.py +85 -0
  122. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +100 -0
  123. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +8 -0
  124. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
  125. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2270 -0
  126. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +352 -0
  127. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +96 -0
  128. wayfinder_paths/strategies/stablecoin_yield_strategy/examples.json +17 -0
  129. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
  130. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1810 -0
  131. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +520 -0
  132. wayfinder_paths/templates/adapter/README.md +105 -0
  133. wayfinder_paths/templates/adapter/adapter.py +26 -0
  134. wayfinder_paths/templates/adapter/examples.json +8 -0
  135. wayfinder_paths/templates/adapter/manifest.yaml +6 -0
  136. wayfinder_paths/templates/adapter/test_adapter.py +49 -0
  137. wayfinder_paths/templates/strategy/README.md +153 -0
  138. wayfinder_paths/templates/strategy/examples.json +11 -0
  139. wayfinder_paths/templates/strategy/manifest.yaml +8 -0
  140. wayfinder_paths/templates/strategy/strategy.py +57 -0
  141. wayfinder_paths/templates/strategy/test_strategy.py +197 -0
  142. wayfinder_paths/tests/__init__.py +0 -0
  143. wayfinder_paths/tests/test_smoke_manifest.py +48 -0
  144. wayfinder_paths/tests/test_test_coverage.py +212 -0
  145. wayfinder_paths/tests/test_utils.py +64 -0
  146. wayfinder_paths-0.1.7.dist-info/LICENSE +21 -0
  147. wayfinder_paths-0.1.7.dist-info/METADATA +777 -0
  148. wayfinder_paths-0.1.7.dist-info/RECORD +149 -0
  149. wayfinder_paths-0.1.7.dist-info/WHEEL +4 -0
@@ -0,0 +1,179 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from web3 import AsyncWeb3
5
+
6
+ from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
7
+
8
+
9
+ class TokenTxn(ABC):
10
+ """Interface describing high-level EVM transaction builders."""
11
+
12
+ @abstractmethod
13
+ async def build_send(
14
+ self,
15
+ *,
16
+ token_id: str,
17
+ amount: float,
18
+ from_address: str,
19
+ to_address: str,
20
+ token_info: dict[str, Any] | None = None,
21
+ ) -> tuple[bool, dict[str, Any] | str]:
22
+ """Build raw transaction data for sending tokens."""
23
+
24
+ @abstractmethod
25
+ def build_erc20_approve(
26
+ self,
27
+ *,
28
+ chain_id: int,
29
+ token_address: str,
30
+ from_address: str,
31
+ spender: str,
32
+ amount: int,
33
+ ) -> tuple[bool, dict[str, Any] | str]:
34
+ """Build raw ERC20 approve transaction data."""
35
+
36
+ @abstractmethod
37
+ async def read_erc20_allowance(
38
+ self, chain: Any, token_address: str, from_address: str, spender_address: str
39
+ ) -> dict[str, Any]:
40
+ """Read allowance granted for a spender."""
41
+
42
+
43
+ class EvmTxn(ABC):
44
+ """
45
+ Abstract base class for wallet providers.
46
+
47
+ This interface abstracts all blockchain interactions needed by adapters so the
48
+ rest of the codebase never touches raw web3 primitives. Implementations
49
+ are responsible for RPC resolution, gas estimation, signing, broadcasting and
50
+ transaction confirmations.
51
+ """
52
+
53
+ @abstractmethod
54
+ async def get_balance(
55
+ self,
56
+ address: str,
57
+ token_address: str | None,
58
+ chain_id: int,
59
+ ) -> tuple[bool, Any]:
60
+ """
61
+ Get balance for an address.
62
+
63
+ Args:
64
+ address: Address to query balance for
65
+ token_address: ERC20 token address, or None for native token
66
+ chain_id: Chain ID
67
+
68
+ Returns:
69
+ Tuple of (success, balance_integer_or_error_message)
70
+ """
71
+ pass
72
+
73
+ @abstractmethod
74
+ async def approve_token(
75
+ self,
76
+ token_address: str,
77
+ spender: str,
78
+ amount: int,
79
+ from_address: str,
80
+ chain_id: int,
81
+ wait_for_receipt: bool = True,
82
+ timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
83
+ ) -> tuple[bool, Any]:
84
+ """
85
+ Approve a spender to spend tokens on behalf of from_address.
86
+
87
+ Args:
88
+ token_address: ERC20 token contract address
89
+ spender: Address being approved to spend tokens
90
+ amount: Amount to approve (in token units, not human-readable)
91
+ from_address: Address approving the tokens
92
+ chain_id: Chain ID
93
+ wait_for_receipt: Whether to wait for the transaction receipt
94
+ timeout: Receipt timeout in seconds
95
+
96
+ Returns:
97
+ Tuple of (success, transaction_result_dict_or_error_message)
98
+ Transaction result should include 'tx_hash' and optionally 'receipt'
99
+ """
100
+ pass
101
+
102
+ @abstractmethod
103
+ async def broadcast_transaction(
104
+ self,
105
+ transaction: dict[str, Any],
106
+ *,
107
+ wait_for_receipt: bool = True,
108
+ timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
109
+ ) -> tuple[bool, Any]:
110
+ """
111
+ Sign and broadcast a transaction dict.
112
+
113
+ Providers must handle gas estimation, gas pricing, nonce selection, signing
114
+ and submission internally so callers can simply pass the transaction data.
115
+
116
+ Args:
117
+ transaction: Dictionary describing the transaction (to, data, value, etc.)
118
+ wait_for_receipt: Whether to wait for the transaction receipt
119
+ timeout: Receipt timeout in seconds
120
+ """
121
+ pass
122
+
123
+ @abstractmethod
124
+ async def transaction_succeeded(
125
+ self, tx_hash: str, chain_id: int, timeout: int = 120
126
+ ) -> bool:
127
+ """
128
+ Check if a transaction hash succeeded on-chain.
129
+
130
+ Args:
131
+ tx_hash: Transaction hash to inspect
132
+ chain_id: Chain ID where the transaction was broadcast
133
+ timeout: Maximum seconds to wait for a receipt
134
+
135
+ Returns:
136
+ Boolean indicating whether the transaction completed successfully.
137
+ """
138
+ pass
139
+
140
+ @abstractmethod
141
+ def get_web3(self, chain_id: int) -> AsyncWeb3:
142
+ """
143
+ Return an AsyncWeb3 instance configured for the given chain.
144
+
145
+ Implementations may create new instances per call or pull from an internal
146
+ cache, but they must document whether the caller is responsible for closing
147
+ the underlying HTTP session.
148
+ """
149
+ pass
150
+
151
+
152
+ class Web3Service(ABC):
153
+ """Facade that exposes low-level wallet access and higher-level EVM helpers."""
154
+
155
+ @property
156
+ @abstractmethod
157
+ def evm_transactions(self) -> EvmTxn:
158
+ """Return the wallet provider responsible for RPC, signing, and broadcasting."""
159
+
160
+ @property
161
+ @abstractmethod
162
+ def token_transactions(self) -> TokenTxn:
163
+ """Returns TokenTxn, for sends and swaps of any token"""
164
+
165
+ async def broadcast_transaction(
166
+ self,
167
+ transaction: dict[str, Any],
168
+ *,
169
+ wait_for_receipt: bool = True,
170
+ timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
171
+ ) -> tuple[bool, Any]:
172
+ """Proxy convenience wrapper to underlying wallet provider."""
173
+ return await self.evm_transactions.broadcast_transaction(
174
+ transaction, wait_for_receipt=wait_for_receipt, timeout=timeout
175
+ )
176
+
177
+ def get_web3(self, chain_id: int):
178
+ """Expose underlying web3 provider for ABI encoding helpers."""
179
+ return self.evm_transactions.get_web3(chain_id)
@@ -0,0 +1,430 @@
1
+ import asyncio
2
+ from typing import Any
3
+
4
+ from eth_account import Account
5
+ from eth_utils import to_checksum_address
6
+ from loguru import logger
7
+ from web3 import AsyncHTTPProvider, AsyncWeb3, Web3
8
+
9
+ from wayfinder_paths.core.constants import (
10
+ DEFAULT_GAS_ESTIMATE_FALLBACK,
11
+ ONE_GWEI,
12
+ ZERO_ADDRESS,
13
+ )
14
+ from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
15
+ from wayfinder_paths.core.constants.erc20_abi import (
16
+ ERC20_APPROVAL_ABI,
17
+ ERC20_MINIMAL_ABI,
18
+ )
19
+ from wayfinder_paths.core.services.base import EvmTxn
20
+ from wayfinder_paths.core.utils.evm_helpers import (
21
+ resolve_private_key_for_from_address,
22
+ resolve_rpc_url,
23
+ )
24
+
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
78
+
79
+
80
+ class LocalEvmTxn(EvmTxn):
81
+ """
82
+ Local wallet provider using private keys stored in config or environment variables.
83
+
84
+ This provider implements the current default behavior:
85
+ - Resolves private keys from config or environment
86
+ - Signs transactions using eth_account
87
+ - Broadcasts transactions via RPC
88
+ """
89
+
90
+ 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
+ self.config = config or {}
98
+ self.logger = logger.bind(provider="LocalWalletProvider")
99
+ self._nonce_manager = NonceManager()
100
+
101
+ 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
+ """
107
+ rpc_url = self._resolve_rpc_url(chain_id)
108
+ return AsyncWeb3(AsyncHTTPProvider(rpc_url))
109
+
110
+ async def get_balance(
111
+ self,
112
+ address: str,
113
+ token_address: str | None,
114
+ chain_id: int,
115
+ ) -> tuple[bool, Any]:
116
+ """
117
+ Get balance for an address (native or ERC20 token).
118
+ """
119
+ w3 = self.get_web3(chain_id)
120
+ try:
121
+ checksum_addr = to_checksum_address(address)
122
+
123
+ if not token_address or token_address.lower() == ZERO_ADDRESS:
124
+ balance = await w3.eth.get_balance(checksum_addr)
125
+ return (True, int(balance))
126
+
127
+ token_checksum = to_checksum_address(token_address)
128
+ contract = w3.eth.contract(address=token_checksum, abi=ERC20_MINIMAL_ABI)
129
+ balance = await contract.functions.balanceOf(checksum_addr).call()
130
+ return (True, int(balance))
131
+
132
+ except Exception as exc: # noqa: BLE001
133
+ self.logger.error(f"Failed to get balance: {exc}")
134
+ return (False, f"Balance query failed: {exc}")
135
+ finally:
136
+ await self._close_web3(w3)
137
+
138
+ async def approve_token(
139
+ self,
140
+ token_address: str,
141
+ spender: str,
142
+ amount: int,
143
+ from_address: str,
144
+ chain_id: int,
145
+ wait_for_receipt: bool = True,
146
+ timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
147
+ ) -> tuple[bool, Any]:
148
+ """
149
+ Approve a spender to spend tokens on behalf of from_address.
150
+ """
151
+ try:
152
+ token_checksum = to_checksum_address(token_address)
153
+ spender_checksum = to_checksum_address(spender)
154
+ from_checksum = to_checksum_address(from_address)
155
+ amount_int = int(amount)
156
+
157
+ w3_sync = Web3()
158
+ contract = w3_sync.eth.contract(
159
+ address=token_checksum, abi=ERC20_APPROVAL_ABI
160
+ )
161
+ transaction_data = contract.encodeABI(
162
+ fn_name="approve",
163
+ args=[spender_checksum, amount_int],
164
+ )
165
+
166
+ approve_txn = {
167
+ "from": from_checksum,
168
+ "chainId": int(chain_id),
169
+ "to": token_checksum,
170
+ "data": transaction_data,
171
+ "value": 0,
172
+ "gas": ERC20_APPROVAL_GAS_LIMIT,
173
+ }
174
+
175
+ return await self.broadcast_transaction(
176
+ approve_txn,
177
+ wait_for_receipt=wait_for_receipt,
178
+ timeout=timeout,
179
+ )
180
+ except Exception as exc: # noqa: BLE001
181
+ self.logger.error(f"ERC20 approval failed: {exc}")
182
+ return (False, f"ERC20 approval failed: {exc}")
183
+
184
+ async def broadcast_transaction(
185
+ self,
186
+ transaction: dict[str, Any],
187
+ *,
188
+ wait_for_receipt: bool = True,
189
+ timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
190
+ ) -> tuple[bool, Any]:
191
+ """
192
+ Sign and broadcast a transaction dict.
193
+ """
194
+ 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
+ )
225
+
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}"
230
+ )
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,
248
+ )
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"]
261
+ )
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
281
+ )
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
334
+ )
335
+
336
+ return (True, result)
337
+ # Re-raise if it's not a nonce error
338
+ raise
339
+ finally:
340
+ await self._close_web3(w3)
341
+ except Exception as exc: # noqa: BLE001
342
+ self.logger.error(f"Transaction broadcast failed: {exc}")
343
+ return (False, f"Transaction broadcast failed: {exc}")
344
+
345
+ async def transaction_succeeded(
346
+ self, tx_hash: str, chain_id: int, timeout: int = 120
347
+ ) -> bool:
348
+ """Return True if the transaction hash completed successfully on-chain."""
349
+ w3 = self.get_web3(chain_id)
350
+ try:
351
+ receipt = await w3.eth.wait_for_transaction_receipt(
352
+ tx_hash, timeout=timeout
353
+ )
354
+ status = getattr(receipt, "status", None)
355
+ if status is None and isinstance(receipt, dict):
356
+ status = receipt.get("status")
357
+ return status == 1
358
+ except Exception as exc: # noqa: BLE001
359
+ self.logger.warning(
360
+ f"Failed to confirm transaction {tx_hash} on chain {chain_id}: {exc}"
361
+ )
362
+ return False
363
+ finally:
364
+ await self._close_web3(w3)
365
+
366
+ def _sign_transaction(
367
+ self, transaction: dict[str, Any], from_address: str
368
+ ) -> bytes:
369
+ private_key = resolve_private_key_for_from_address(from_address, self.config)
370
+ if not private_key:
371
+ raise ValueError(f"No private key available for address {from_address}")
372
+ signed = Account.sign_transaction(transaction, private_key)
373
+ return signed.raw_transaction
374
+
375
+ def _resolve_rpc_url(self, chain_id: int) -> str:
376
+ return resolve_rpc_url(chain_id, self.config or {}, None)
377
+
378
+ async def _close_web3(self, w3: AsyncWeb3) -> None:
379
+ try:
380
+ await w3.provider.session.close()
381
+ except Exception: # noqa: BLE001
382
+ pass
383
+
384
+ def _format_receipt(self, receipt: Any) -> dict[str, Any]:
385
+ tx_hash = getattr(receipt, "transactionHash", None)
386
+ if hasattr(tx_hash, "hex"):
387
+ tx_hash = tx_hash.hex()
388
+
389
+ return {
390
+ "transactionHash": tx_hash,
391
+ "status": (
392
+ getattr(receipt, "status", None)
393
+ if not isinstance(receipt, dict)
394
+ else receipt.get("status")
395
+ ),
396
+ "blockNumber": (
397
+ getattr(receipt, "blockNumber", None)
398
+ if not isinstance(receipt, dict)
399
+ else receipt.get("blockNumber")
400
+ ),
401
+ "gasUsed": (
402
+ getattr(receipt, "gasUsed", None)
403
+ if not isinstance(receipt, dict)
404
+ else receipt.get("gasUsed")
405
+ ),
406
+ "logs": (
407
+ [
408
+ dict(log_entry) if not isinstance(log_entry, dict) else log_entry
409
+ for log_entry in getattr(receipt, "logs", [])
410
+ ]
411
+ if hasattr(receipt, "logs")
412
+ else receipt.get("logs")
413
+ if isinstance(receipt, dict)
414
+ else []
415
+ ),
416
+ }
417
+
418
+ def _normalize_int(self, value: Any) -> int:
419
+ if isinstance(value, int):
420
+ return value
421
+ if isinstance(value, float):
422
+ return int(value)
423
+ if isinstance(value, str):
424
+ if value.startswith("0x"):
425
+ return int(value, 16)
426
+ try:
427
+ return int(value)
428
+ except ValueError:
429
+ return int(float(value))
430
+ raise ValueError(f"Unable to convert value '{value}' to int")