wayfinder-paths 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wayfinder-paths might be problematic. Click here for more details.
- wayfinder_paths/CONFIG_GUIDE.md +394 -0
- wayfinder_paths/__init__.py +21 -0
- wayfinder_paths/config.example.json +20 -0
- wayfinder_paths/conftest.py +31 -0
- wayfinder_paths/core/__init__.py +13 -0
- wayfinder_paths/core/adapters/BaseAdapter.py +48 -0
- wayfinder_paths/core/adapters/__init__.py +5 -0
- wayfinder_paths/core/adapters/base.py +5 -0
- wayfinder_paths/core/clients/AuthClient.py +83 -0
- wayfinder_paths/core/clients/BRAPClient.py +90 -0
- wayfinder_paths/core/clients/ClientManager.py +231 -0
- wayfinder_paths/core/clients/HyperlendClient.py +151 -0
- wayfinder_paths/core/clients/LedgerClient.py +222 -0
- wayfinder_paths/core/clients/PoolClient.py +96 -0
- wayfinder_paths/core/clients/SimulationClient.py +180 -0
- wayfinder_paths/core/clients/TokenClient.py +73 -0
- wayfinder_paths/core/clients/TransactionClient.py +47 -0
- wayfinder_paths/core/clients/WalletClient.py +90 -0
- wayfinder_paths/core/clients/WayfinderClient.py +258 -0
- wayfinder_paths/core/clients/__init__.py +48 -0
- wayfinder_paths/core/clients/protocols.py +295 -0
- wayfinder_paths/core/clients/sdk_example.py +115 -0
- wayfinder_paths/core/config.py +369 -0
- wayfinder_paths/core/constants/__init__.py +26 -0
- wayfinder_paths/core/constants/base.py +25 -0
- wayfinder_paths/core/constants/erc20_abi.py +118 -0
- wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
- wayfinder_paths/core/engine/VaultJob.py +182 -0
- wayfinder_paths/core/engine/__init__.py +5 -0
- wayfinder_paths/core/engine/manifest.py +97 -0
- wayfinder_paths/core/services/__init__.py +0 -0
- wayfinder_paths/core/services/base.py +177 -0
- wayfinder_paths/core/services/local_evm_txn.py +429 -0
- wayfinder_paths/core/services/local_token_txn.py +231 -0
- wayfinder_paths/core/services/web3_service.py +45 -0
- wayfinder_paths/core/settings.py +61 -0
- wayfinder_paths/core/strategies/Strategy.py +183 -0
- wayfinder_paths/core/strategies/__init__.py +5 -0
- wayfinder_paths/core/strategies/base.py +7 -0
- wayfinder_paths/core/utils/__init__.py +1 -0
- wayfinder_paths/core/utils/evm_helpers.py +165 -0
- wayfinder_paths/core/utils/wallets.py +77 -0
- wayfinder_paths/core/wallets/README.md +91 -0
- wayfinder_paths/core/wallets/WalletManager.py +56 -0
- wayfinder_paths/core/wallets/__init__.py +7 -0
- wayfinder_paths/run_strategy.py +409 -0
- wayfinder_paths/scripts/__init__.py +0 -0
- wayfinder_paths/scripts/create_strategy.py +181 -0
- wayfinder_paths/scripts/make_wallets.py +160 -0
- wayfinder_paths/scripts/validate_manifests.py +213 -0
- wayfinder_paths/tests/__init__.py +0 -0
- wayfinder_paths/tests/test_smoke_manifest.py +48 -0
- wayfinder_paths/tests/test_test_coverage.py +212 -0
- wayfinder_paths/tests/test_utils.py +64 -0
- wayfinder_paths/vaults/__init__.py +0 -0
- wayfinder_paths/vaults/adapters/__init__.py +0 -0
- wayfinder_paths/vaults/adapters/balance_adapter/README.md +104 -0
- wayfinder_paths/vaults/adapters/balance_adapter/adapter.py +257 -0
- wayfinder_paths/vaults/adapters/balance_adapter/examples.json +6 -0
- wayfinder_paths/vaults/adapters/balance_adapter/manifest.yaml +8 -0
- wayfinder_paths/vaults/adapters/balance_adapter/test_adapter.py +83 -0
- wayfinder_paths/vaults/adapters/brap_adapter/README.md +249 -0
- wayfinder_paths/vaults/adapters/brap_adapter/__init__.py +7 -0
- wayfinder_paths/vaults/adapters/brap_adapter/adapter.py +717 -0
- wayfinder_paths/vaults/adapters/brap_adapter/examples.json +175 -0
- wayfinder_paths/vaults/adapters/brap_adapter/manifest.yaml +11 -0
- wayfinder_paths/vaults/adapters/brap_adapter/test_adapter.py +288 -0
- wayfinder_paths/vaults/adapters/hyperlend_adapter/__init__.py +7 -0
- wayfinder_paths/vaults/adapters/hyperlend_adapter/adapter.py +298 -0
- wayfinder_paths/vaults/adapters/hyperlend_adapter/manifest.yaml +10 -0
- wayfinder_paths/vaults/adapters/hyperlend_adapter/test_adapter.py +267 -0
- wayfinder_paths/vaults/adapters/ledger_adapter/README.md +158 -0
- wayfinder_paths/vaults/adapters/ledger_adapter/__init__.py +7 -0
- wayfinder_paths/vaults/adapters/ledger_adapter/adapter.py +286 -0
- wayfinder_paths/vaults/adapters/ledger_adapter/examples.json +131 -0
- wayfinder_paths/vaults/adapters/ledger_adapter/manifest.yaml +11 -0
- wayfinder_paths/vaults/adapters/ledger_adapter/test_adapter.py +202 -0
- wayfinder_paths/vaults/adapters/pool_adapter/README.md +218 -0
- wayfinder_paths/vaults/adapters/pool_adapter/__init__.py +7 -0
- wayfinder_paths/vaults/adapters/pool_adapter/adapter.py +289 -0
- wayfinder_paths/vaults/adapters/pool_adapter/examples.json +143 -0
- wayfinder_paths/vaults/adapters/pool_adapter/manifest.yaml +10 -0
- wayfinder_paths/vaults/adapters/pool_adapter/test_adapter.py +222 -0
- wayfinder_paths/vaults/adapters/token_adapter/README.md +101 -0
- wayfinder_paths/vaults/adapters/token_adapter/__init__.py +3 -0
- wayfinder_paths/vaults/adapters/token_adapter/adapter.py +92 -0
- wayfinder_paths/vaults/adapters/token_adapter/examples.json +26 -0
- wayfinder_paths/vaults/adapters/token_adapter/manifest.yaml +6 -0
- wayfinder_paths/vaults/adapters/token_adapter/test_adapter.py +135 -0
- wayfinder_paths/vaults/strategies/__init__.py +0 -0
- wayfinder_paths/vaults/strategies/config.py +85 -0
- wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/README.md +99 -0
- wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/examples.json +16 -0
- wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
- wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/strategy.py +2328 -0
- wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/test_strategy.py +319 -0
- wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/README.md +95 -0
- wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/examples.json +17 -0
- wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
- wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/strategy.py +1684 -0
- wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/test_strategy.py +350 -0
- wayfinder_paths/vaults/templates/adapter/README.md +105 -0
- wayfinder_paths/vaults/templates/adapter/adapter.py +26 -0
- wayfinder_paths/vaults/templates/adapter/examples.json +8 -0
- wayfinder_paths/vaults/templates/adapter/manifest.yaml +6 -0
- wayfinder_paths/vaults/templates/adapter/test_adapter.py +49 -0
- wayfinder_paths/vaults/templates/strategy/README.md +152 -0
- wayfinder_paths/vaults/templates/strategy/examples.json +11 -0
- wayfinder_paths/vaults/templates/strategy/manifest.yaml +8 -0
- wayfinder_paths/vaults/templates/strategy/strategy.py +57 -0
- wayfinder_paths/vaults/templates/strategy/test_strategy.py +197 -0
- wayfinder_paths-0.1.1.dist-info/LICENSE +21 -0
- wayfinder_paths-0.1.1.dist-info/METADATA +727 -0
- wayfinder_paths-0.1.1.dist-info/RECORD +115 -0
- wayfinder_paths-0.1.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,429 @@
|
|
|
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.erc20_abi import (
|
|
15
|
+
ERC20_APPROVAL_ABI,
|
|
16
|
+
ERC20_MINIMAL_ABI,
|
|
17
|
+
)
|
|
18
|
+
from wayfinder_paths.core.services.base import EvmTxn
|
|
19
|
+
from wayfinder_paths.core.utils.evm_helpers import (
|
|
20
|
+
resolve_private_key_for_from_address,
|
|
21
|
+
resolve_rpc_url,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Gas management constants for ERC20 approval transactions
|
|
25
|
+
ERC20_APPROVAL_GAS_LIMIT = 120_000
|
|
26
|
+
MAX_FEE_PER_GAS_RATE = 1.2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NonceManager:
|
|
30
|
+
"""
|
|
31
|
+
Thread-safe nonce manager to track and increment nonces per address/chain.
|
|
32
|
+
Prevents nonce conflicts when multiple transactions are sent in quick succession.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
# Dictionary: (address, chain_id) -> current_nonce
|
|
37
|
+
self._nonces: dict[tuple[str, int], int] = {}
|
|
38
|
+
self._lock: asyncio.Lock | None = None
|
|
39
|
+
|
|
40
|
+
def _get_lock(self) -> asyncio.Lock:
|
|
41
|
+
"""Get or create the async lock."""
|
|
42
|
+
if self._lock is None:
|
|
43
|
+
self._lock = asyncio.Lock()
|
|
44
|
+
return self._lock
|
|
45
|
+
|
|
46
|
+
async def get_next_nonce(self, address: str, chain_id: int, w3: AsyncWeb3) -> int:
|
|
47
|
+
"""
|
|
48
|
+
Get the next nonce for an address on a chain.
|
|
49
|
+
Tracks nonces locally and syncs with chain when needed.
|
|
50
|
+
"""
|
|
51
|
+
async with self._get_lock():
|
|
52
|
+
key = (address.lower(), chain_id)
|
|
53
|
+
|
|
54
|
+
# If we don't have a tracked nonce, fetch from chain
|
|
55
|
+
if key not in self._nonces:
|
|
56
|
+
chain_nonce = await w3.eth.get_transaction_count(address, "pending")
|
|
57
|
+
self._nonces[key] = chain_nonce
|
|
58
|
+
return chain_nonce
|
|
59
|
+
|
|
60
|
+
# Return the tracked nonce and increment for next time
|
|
61
|
+
current_nonce = self._nonces[key]
|
|
62
|
+
self._nonces[key] = current_nonce + 1
|
|
63
|
+
return current_nonce
|
|
64
|
+
|
|
65
|
+
async def sync_nonce(self, address: str, chain_id: int, chain_nonce: int) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Sync the tracked nonce with the chain nonce.
|
|
68
|
+
Used when we detect a mismatch or after a transaction fails.
|
|
69
|
+
"""
|
|
70
|
+
async with self._get_lock():
|
|
71
|
+
key = (address.lower(), chain_id)
|
|
72
|
+
# Use the higher of the two to avoid going backwards
|
|
73
|
+
if key in self._nonces:
|
|
74
|
+
self._nonces[key] = max(self._nonces[key], chain_nonce)
|
|
75
|
+
else:
|
|
76
|
+
self._nonces[key] = chain_nonce
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LocalEvmTxn(EvmTxn):
|
|
80
|
+
"""
|
|
81
|
+
Local wallet provider using private keys stored in config or environment variables.
|
|
82
|
+
|
|
83
|
+
This provider implements the current default behavior:
|
|
84
|
+
- Resolves private keys from config or environment
|
|
85
|
+
- Signs transactions using eth_account
|
|
86
|
+
- Broadcasts transactions via RPC
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
90
|
+
"""
|
|
91
|
+
Initialize local wallet provider.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
config: Configuration dictionary containing wallet information
|
|
95
|
+
"""
|
|
96
|
+
self.config = config or {}
|
|
97
|
+
self.logger = logger.bind(provider="LocalWalletProvider")
|
|
98
|
+
self._nonce_manager = NonceManager()
|
|
99
|
+
|
|
100
|
+
def get_web3(self, chain_id: int) -> AsyncWeb3:
|
|
101
|
+
"""
|
|
102
|
+
Return an AsyncWeb3 configured for the requested chain.
|
|
103
|
+
|
|
104
|
+
Callers are responsible for closing the provider session when finished.
|
|
105
|
+
"""
|
|
106
|
+
rpc_url = self._resolve_rpc_url(chain_id)
|
|
107
|
+
return AsyncWeb3(AsyncHTTPProvider(rpc_url))
|
|
108
|
+
|
|
109
|
+
async def get_balance(
|
|
110
|
+
self,
|
|
111
|
+
address: str,
|
|
112
|
+
token_address: str | None,
|
|
113
|
+
chain_id: int,
|
|
114
|
+
) -> tuple[bool, Any]:
|
|
115
|
+
"""
|
|
116
|
+
Get balance for an address (native or ERC20 token).
|
|
117
|
+
"""
|
|
118
|
+
w3 = self.get_web3(chain_id)
|
|
119
|
+
try:
|
|
120
|
+
checksum_addr = to_checksum_address(address)
|
|
121
|
+
|
|
122
|
+
if not token_address or token_address.lower() == ZERO_ADDRESS:
|
|
123
|
+
balance = await w3.eth.get_balance(checksum_addr)
|
|
124
|
+
return (True, int(balance))
|
|
125
|
+
|
|
126
|
+
token_checksum = to_checksum_address(token_address)
|
|
127
|
+
contract = w3.eth.contract(address=token_checksum, abi=ERC20_MINIMAL_ABI)
|
|
128
|
+
balance = await contract.functions.balanceOf(checksum_addr).call()
|
|
129
|
+
return (True, int(balance))
|
|
130
|
+
|
|
131
|
+
except Exception as exc: # noqa: BLE001
|
|
132
|
+
self.logger.error(f"Failed to get balance: {exc}")
|
|
133
|
+
return (False, f"Balance query failed: {exc}")
|
|
134
|
+
finally:
|
|
135
|
+
await self._close_web3(w3)
|
|
136
|
+
|
|
137
|
+
async def approve_token(
|
|
138
|
+
self,
|
|
139
|
+
token_address: str,
|
|
140
|
+
spender: str,
|
|
141
|
+
amount: int,
|
|
142
|
+
from_address: str,
|
|
143
|
+
chain_id: int,
|
|
144
|
+
wait_for_receipt: bool = True,
|
|
145
|
+
timeout: int = 120,
|
|
146
|
+
) -> tuple[bool, Any]:
|
|
147
|
+
"""
|
|
148
|
+
Approve a spender to spend tokens on behalf of from_address.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
token_checksum = to_checksum_address(token_address)
|
|
152
|
+
spender_checksum = to_checksum_address(spender)
|
|
153
|
+
from_checksum = to_checksum_address(from_address)
|
|
154
|
+
amount_int = int(amount)
|
|
155
|
+
|
|
156
|
+
w3_sync = Web3()
|
|
157
|
+
contract = w3_sync.eth.contract(
|
|
158
|
+
address=token_checksum, abi=ERC20_APPROVAL_ABI
|
|
159
|
+
)
|
|
160
|
+
transaction_data = contract.encodeABI(
|
|
161
|
+
fn_name="approve",
|
|
162
|
+
args=[spender_checksum, amount_int],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
approve_txn = {
|
|
166
|
+
"from": from_checksum,
|
|
167
|
+
"chainId": int(chain_id),
|
|
168
|
+
"to": token_checksum,
|
|
169
|
+
"data": transaction_data,
|
|
170
|
+
"value": 0,
|
|
171
|
+
"gas": ERC20_APPROVAL_GAS_LIMIT,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return await self.broadcast_transaction(
|
|
175
|
+
approve_txn,
|
|
176
|
+
wait_for_receipt=wait_for_receipt,
|
|
177
|
+
timeout=timeout,
|
|
178
|
+
)
|
|
179
|
+
except Exception as exc: # noqa: BLE001
|
|
180
|
+
self.logger.error(f"ERC20 approval failed: {exc}")
|
|
181
|
+
return (False, f"ERC20 approval failed: {exc}")
|
|
182
|
+
|
|
183
|
+
async def broadcast_transaction(
|
|
184
|
+
self,
|
|
185
|
+
transaction: dict[str, Any],
|
|
186
|
+
*,
|
|
187
|
+
wait_for_receipt: bool = True,
|
|
188
|
+
timeout: int = 120,
|
|
189
|
+
) -> tuple[bool, Any]:
|
|
190
|
+
"""
|
|
191
|
+
Sign and broadcast a transaction dict.
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
tx = dict(transaction)
|
|
195
|
+
from_address = tx.get("from")
|
|
196
|
+
if not from_address:
|
|
197
|
+
return (False, "Transaction missing 'from' address")
|
|
198
|
+
checksum_from = to_checksum_address(from_address)
|
|
199
|
+
tx["from"] = checksum_from
|
|
200
|
+
|
|
201
|
+
chain_id = tx.get("chainId") or tx.get("chain_id")
|
|
202
|
+
if chain_id is None:
|
|
203
|
+
return (False, "Transaction missing chainId")
|
|
204
|
+
tx["chainId"] = int(chain_id)
|
|
205
|
+
|
|
206
|
+
w3 = self.get_web3(tx["chainId"])
|
|
207
|
+
try:
|
|
208
|
+
if "value" in tx:
|
|
209
|
+
tx["value"] = self._normalize_int(tx["value"])
|
|
210
|
+
else:
|
|
211
|
+
tx["value"] = 0
|
|
212
|
+
|
|
213
|
+
if "nonce" in tx:
|
|
214
|
+
tx["nonce"] = self._normalize_int(tx["nonce"])
|
|
215
|
+
# Sync our tracked nonce with the provided nonce
|
|
216
|
+
await self._nonce_manager.sync_nonce(
|
|
217
|
+
checksum_from, tx["chainId"], tx["nonce"]
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
# Use nonce manager to get and track the next nonce
|
|
221
|
+
tx["nonce"] = await self._nonce_manager.get_next_nonce(
|
|
222
|
+
checksum_from, tx["chainId"], w3
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if "data" in tx and isinstance(tx["data"], str):
|
|
226
|
+
calldata = tx["data"]
|
|
227
|
+
tx["data"] = (
|
|
228
|
+
calldata if calldata.startswith("0x") else f"0x{calldata}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if "gas" in tx:
|
|
232
|
+
tx["gas"] = self._normalize_int(tx["gas"])
|
|
233
|
+
else:
|
|
234
|
+
estimate_request = {
|
|
235
|
+
"to": tx.get("to"),
|
|
236
|
+
"from": tx["from"],
|
|
237
|
+
"value": tx.get("value", 0),
|
|
238
|
+
"data": tx.get("data", "0x"),
|
|
239
|
+
}
|
|
240
|
+
try:
|
|
241
|
+
tx["gas"] = await w3.eth.estimate_gas(estimate_request)
|
|
242
|
+
except Exception as exc: # noqa: BLE001
|
|
243
|
+
self.logger.warning(
|
|
244
|
+
"Gas estimation failed; using fallback %s. Reason: %s",
|
|
245
|
+
DEFAULT_GAS_ESTIMATE_FALLBACK,
|
|
246
|
+
exc,
|
|
247
|
+
)
|
|
248
|
+
tx["gas"] = DEFAULT_GAS_ESTIMATE_FALLBACK
|
|
249
|
+
|
|
250
|
+
if "maxFeePerGas" in tx or "maxPriorityFeePerGas" in tx:
|
|
251
|
+
if "maxFeePerGas" in tx:
|
|
252
|
+
tx["maxFeePerGas"] = self._normalize_int(tx["maxFeePerGas"])
|
|
253
|
+
else:
|
|
254
|
+
base = await w3.eth.gas_price
|
|
255
|
+
tx["maxFeePerGas"] = int(base * 2)
|
|
256
|
+
|
|
257
|
+
if "maxPriorityFeePerGas" in tx:
|
|
258
|
+
tx["maxPriorityFeePerGas"] = self._normalize_int(
|
|
259
|
+
tx["maxPriorityFeePerGas"]
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
tx["maxPriorityFeePerGas"] = int(ONE_GWEI)
|
|
263
|
+
tx["type"] = 2
|
|
264
|
+
else:
|
|
265
|
+
if "gasPrice" in tx:
|
|
266
|
+
tx["gasPrice"] = self._normalize_int(tx["gasPrice"])
|
|
267
|
+
else:
|
|
268
|
+
gas_price = await w3.eth.gas_price
|
|
269
|
+
tx["gasPrice"] = int(gas_price)
|
|
270
|
+
|
|
271
|
+
signed_tx = self._sign_transaction(tx, checksum_from)
|
|
272
|
+
try:
|
|
273
|
+
tx_hash = await w3.eth.send_raw_transaction(signed_tx)
|
|
274
|
+
tx_hash_hex = tx_hash.hex() if hasattr(tx_hash, "hex") else tx_hash
|
|
275
|
+
|
|
276
|
+
result: dict[str, Any] = {"tx_hash": tx_hash_hex}
|
|
277
|
+
if wait_for_receipt:
|
|
278
|
+
receipt = await w3.eth.wait_for_transaction_receipt(
|
|
279
|
+
tx_hash, timeout=timeout
|
|
280
|
+
)
|
|
281
|
+
result["receipt"] = self._format_receipt(receipt)
|
|
282
|
+
# After successful receipt, sync nonce from chain to ensure accuracy
|
|
283
|
+
chain_nonce = await w3.eth.get_transaction_count(
|
|
284
|
+
checksum_from, "latest"
|
|
285
|
+
)
|
|
286
|
+
await self._nonce_manager.sync_nonce(
|
|
287
|
+
checksum_from, tx["chainId"], chain_nonce
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return (True, result)
|
|
291
|
+
except Exception as send_exc:
|
|
292
|
+
# If transaction fails due to nonce error, sync with chain and retry once
|
|
293
|
+
# Handle both string errors and dict errors (like {'code': -32000, 'message': '...'})
|
|
294
|
+
error_msg = str(send_exc)
|
|
295
|
+
if isinstance(send_exc, dict):
|
|
296
|
+
error_msg = send_exc.get("message", str(send_exc))
|
|
297
|
+
elif hasattr(send_exc, "message"):
|
|
298
|
+
error_msg = str(send_exc.message)
|
|
299
|
+
|
|
300
|
+
if "nonce" in error_msg.lower() and "too low" in error_msg.lower():
|
|
301
|
+
self.logger.warning(
|
|
302
|
+
f"Nonce error detected, syncing with chain: {error_msg}"
|
|
303
|
+
)
|
|
304
|
+
# Sync with chain nonce
|
|
305
|
+
chain_nonce = await w3.eth.get_transaction_count(
|
|
306
|
+
checksum_from, "pending"
|
|
307
|
+
)
|
|
308
|
+
await self._nonce_manager.sync_nonce(
|
|
309
|
+
checksum_from, tx["chainId"], chain_nonce
|
|
310
|
+
)
|
|
311
|
+
# Update tx nonce and retry
|
|
312
|
+
tx["nonce"] = await self._nonce_manager.get_next_nonce(
|
|
313
|
+
checksum_from, tx["chainId"], w3
|
|
314
|
+
)
|
|
315
|
+
signed_tx = self._sign_transaction(tx, checksum_from)
|
|
316
|
+
tx_hash = await w3.eth.send_raw_transaction(signed_tx)
|
|
317
|
+
tx_hash_hex = (
|
|
318
|
+
tx_hash.hex() if hasattr(tx_hash, "hex") else tx_hash
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
result: dict[str, Any] = {"tx_hash": tx_hash_hex}
|
|
322
|
+
if wait_for_receipt:
|
|
323
|
+
receipt = await w3.eth.wait_for_transaction_receipt(
|
|
324
|
+
tx_hash, timeout=timeout
|
|
325
|
+
)
|
|
326
|
+
result["receipt"] = self._format_receipt(receipt)
|
|
327
|
+
# Sync again after successful receipt
|
|
328
|
+
chain_nonce = await w3.eth.get_transaction_count(
|
|
329
|
+
checksum_from, "latest"
|
|
330
|
+
)
|
|
331
|
+
await self._nonce_manager.sync_nonce(
|
|
332
|
+
checksum_from, tx["chainId"], chain_nonce
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return (True, result)
|
|
336
|
+
# Re-raise if it's not a nonce error
|
|
337
|
+
raise
|
|
338
|
+
finally:
|
|
339
|
+
await self._close_web3(w3)
|
|
340
|
+
except Exception as exc: # noqa: BLE001
|
|
341
|
+
self.logger.error(f"Transaction broadcast failed: {exc}")
|
|
342
|
+
return (False, f"Transaction broadcast failed: {exc}")
|
|
343
|
+
|
|
344
|
+
async def transaction_succeeded(
|
|
345
|
+
self, tx_hash: str, chain_id: int, timeout: int = 120
|
|
346
|
+
) -> bool:
|
|
347
|
+
"""Return True if the transaction hash completed successfully on-chain."""
|
|
348
|
+
w3 = self.get_web3(chain_id)
|
|
349
|
+
try:
|
|
350
|
+
receipt = await w3.eth.wait_for_transaction_receipt(
|
|
351
|
+
tx_hash, timeout=timeout
|
|
352
|
+
)
|
|
353
|
+
status = getattr(receipt, "status", None)
|
|
354
|
+
if status is None and isinstance(receipt, dict):
|
|
355
|
+
status = receipt.get("status")
|
|
356
|
+
return status == 1
|
|
357
|
+
except Exception as exc: # noqa: BLE001
|
|
358
|
+
self.logger.warning(
|
|
359
|
+
f"Failed to confirm transaction {tx_hash} on chain {chain_id}: {exc}"
|
|
360
|
+
)
|
|
361
|
+
return False
|
|
362
|
+
finally:
|
|
363
|
+
await self._close_web3(w3)
|
|
364
|
+
|
|
365
|
+
def _sign_transaction(
|
|
366
|
+
self, transaction: dict[str, Any], from_address: str
|
|
367
|
+
) -> bytes:
|
|
368
|
+
private_key = resolve_private_key_for_from_address(from_address, self.config)
|
|
369
|
+
if not private_key:
|
|
370
|
+
raise ValueError(f"No private key available for address {from_address}")
|
|
371
|
+
signed = Account.sign_transaction(transaction, private_key)
|
|
372
|
+
return signed.raw_transaction
|
|
373
|
+
|
|
374
|
+
def _resolve_rpc_url(self, chain_id: int) -> str:
|
|
375
|
+
return resolve_rpc_url(chain_id, self.config or {}, None)
|
|
376
|
+
|
|
377
|
+
async def _close_web3(self, w3: AsyncWeb3) -> None:
|
|
378
|
+
try:
|
|
379
|
+
await w3.provider.session.close()
|
|
380
|
+
except Exception: # noqa: BLE001
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
def _format_receipt(self, receipt: Any) -> dict[str, Any]:
|
|
384
|
+
tx_hash = getattr(receipt, "transactionHash", None)
|
|
385
|
+
if hasattr(tx_hash, "hex"):
|
|
386
|
+
tx_hash = tx_hash.hex()
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
"transactionHash": tx_hash,
|
|
390
|
+
"status": (
|
|
391
|
+
getattr(receipt, "status", None)
|
|
392
|
+
if not isinstance(receipt, dict)
|
|
393
|
+
else receipt.get("status")
|
|
394
|
+
),
|
|
395
|
+
"blockNumber": (
|
|
396
|
+
getattr(receipt, "blockNumber", None)
|
|
397
|
+
if not isinstance(receipt, dict)
|
|
398
|
+
else receipt.get("blockNumber")
|
|
399
|
+
),
|
|
400
|
+
"gasUsed": (
|
|
401
|
+
getattr(receipt, "gasUsed", None)
|
|
402
|
+
if not isinstance(receipt, dict)
|
|
403
|
+
else receipt.get("gasUsed")
|
|
404
|
+
),
|
|
405
|
+
"logs": (
|
|
406
|
+
[
|
|
407
|
+
dict(log_entry) if not isinstance(log_entry, dict) else log_entry
|
|
408
|
+
for log_entry in getattr(receipt, "logs", [])
|
|
409
|
+
]
|
|
410
|
+
if hasattr(receipt, "logs")
|
|
411
|
+
else receipt.get("logs")
|
|
412
|
+
if isinstance(receipt, dict)
|
|
413
|
+
else []
|
|
414
|
+
),
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
def _normalize_int(self, value: Any) -> int:
|
|
418
|
+
if isinstance(value, int):
|
|
419
|
+
return value
|
|
420
|
+
if isinstance(value, float):
|
|
421
|
+
return int(value)
|
|
422
|
+
if isinstance(value, str):
|
|
423
|
+
if value.startswith("0x"):
|
|
424
|
+
return int(value, 16)
|
|
425
|
+
try:
|
|
426
|
+
return int(value)
|
|
427
|
+
except ValueError:
|
|
428
|
+
return int(float(value))
|
|
429
|
+
raise ValueError(f"Unable to convert value '{value}' to int")
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from eth_utils import to_checksum_address
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from web3 import AsyncWeb3
|
|
8
|
+
|
|
9
|
+
from wayfinder_paths.core.clients.TokenClient import TokenClient
|
|
10
|
+
from wayfinder_paths.core.clients.TransactionClient import TransactionClient
|
|
11
|
+
from wayfinder_paths.core.constants import ZERO_ADDRESS
|
|
12
|
+
from wayfinder_paths.core.constants.erc20_abi import ERC20_APPROVAL_ABI
|
|
13
|
+
from wayfinder_paths.core.services.base import EvmTxn, TokenTxn
|
|
14
|
+
from wayfinder_paths.core.utils.evm_helpers import resolve_chain_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LocalTokenTxnService(TokenTxn):
|
|
18
|
+
"""Default transaction builder used by adapters."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
config: dict[str, Any] | None,
|
|
23
|
+
*,
|
|
24
|
+
wallet_provider: EvmTxn,
|
|
25
|
+
simulation: bool = False,
|
|
26
|
+
) -> None:
|
|
27
|
+
del config, simulation
|
|
28
|
+
self.wallet_provider = wallet_provider
|
|
29
|
+
self.logger = logger.bind(service="DefaultEvmTransactionService")
|
|
30
|
+
self.token_client = TokenClient()
|
|
31
|
+
self.builder = _EvmTransactionBuilder()
|
|
32
|
+
|
|
33
|
+
async def build_send(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
token_id: str,
|
|
37
|
+
amount: float,
|
|
38
|
+
from_address: str,
|
|
39
|
+
to_address: str,
|
|
40
|
+
token_info: dict[str, Any] | None = None,
|
|
41
|
+
) -> tuple[bool, dict[str, Any] | str]:
|
|
42
|
+
"""Build the transaction dict for sending tokens between wallets."""
|
|
43
|
+
token_meta = token_info
|
|
44
|
+
if token_meta is None:
|
|
45
|
+
token_meta = await self.token_client.get_token_details(token_id)
|
|
46
|
+
if not token_meta:
|
|
47
|
+
return False, f"Token not found: {token_id}"
|
|
48
|
+
|
|
49
|
+
chain_id = resolve_chain_id(token_meta, self.logger)
|
|
50
|
+
if chain_id is None:
|
|
51
|
+
return False, f"Token {token_id} is missing a chain id"
|
|
52
|
+
|
|
53
|
+
token_address = (token_meta or {}).get("address") or ZERO_ADDRESS
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
tx = await self.builder.build_send_transaction(
|
|
57
|
+
from_address=from_address,
|
|
58
|
+
to_address=to_address,
|
|
59
|
+
token_address=token_address,
|
|
60
|
+
amount=amount,
|
|
61
|
+
chain_id=int(chain_id),
|
|
62
|
+
)
|
|
63
|
+
except Exception as exc: # noqa: BLE001
|
|
64
|
+
return False, f"Failed to build send transaction: {exc}"
|
|
65
|
+
|
|
66
|
+
return True, tx
|
|
67
|
+
|
|
68
|
+
def build_erc20_approve(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
chain_id: int,
|
|
72
|
+
token_address: str,
|
|
73
|
+
from_address: str,
|
|
74
|
+
spender: str,
|
|
75
|
+
amount: int,
|
|
76
|
+
) -> tuple[bool, dict[str, Any] | str]:
|
|
77
|
+
"""Build the transaction dictionary for an ERC20 approval."""
|
|
78
|
+
try:
|
|
79
|
+
web3 = self.wallet_provider.get_web3(chain_id)
|
|
80
|
+
token_checksum = to_checksum_address(token_address)
|
|
81
|
+
from_checksum = to_checksum_address(from_address)
|
|
82
|
+
spender_checksum = to_checksum_address(spender)
|
|
83
|
+
amount_int = int(amount)
|
|
84
|
+
except (TypeError, ValueError) as exc:
|
|
85
|
+
return False, str(exc)
|
|
86
|
+
|
|
87
|
+
approve_tx = self.builder.build_erc20_approval_transaction(
|
|
88
|
+
chain_id=chain_id,
|
|
89
|
+
token_address=token_checksum,
|
|
90
|
+
from_address=from_checksum,
|
|
91
|
+
spender=spender_checksum,
|
|
92
|
+
amount=amount_int,
|
|
93
|
+
web3=web3,
|
|
94
|
+
)
|
|
95
|
+
return True, approve_tx
|
|
96
|
+
|
|
97
|
+
async def read_erc20_allowance(
|
|
98
|
+
self, chain: Any, token_address: str, from_address: str, spender_address: str
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
try:
|
|
101
|
+
chain_id = self._chain_id(chain)
|
|
102
|
+
except (TypeError, ValueError) as exc:
|
|
103
|
+
return {"error": str(exc), "allowance": 0}
|
|
104
|
+
|
|
105
|
+
w3 = self.get_web3(chain_id)
|
|
106
|
+
try:
|
|
107
|
+
contract = w3.eth.contract(
|
|
108
|
+
address=to_checksum_address(token_address), abi=ERC20_APPROVAL_ABI
|
|
109
|
+
)
|
|
110
|
+
allowance = await contract.functions.allowance(
|
|
111
|
+
to_checksum_address(from_address),
|
|
112
|
+
to_checksum_address(spender_address),
|
|
113
|
+
).call()
|
|
114
|
+
return (True, {"allowance": int(allowance)})
|
|
115
|
+
except Exception as exc: # noqa: BLE001
|
|
116
|
+
self.logger.error(f"Failed to read allowance: {exc}")
|
|
117
|
+
return {"error": f"Allowance query failed: {exc}", "allowance": 0}
|
|
118
|
+
finally:
|
|
119
|
+
await self._close_web3(w3)
|
|
120
|
+
|
|
121
|
+
def _chain_id(self, chain: Any) -> int:
|
|
122
|
+
if isinstance(chain, dict):
|
|
123
|
+
chain_id = chain.get("id") or chain.get("chain_id")
|
|
124
|
+
else:
|
|
125
|
+
chain_id = getattr(chain, "id", None)
|
|
126
|
+
if chain_id is None:
|
|
127
|
+
raise ValueError("Chain ID is required")
|
|
128
|
+
return int(chain_id)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _EvmTransactionBuilder:
|
|
132
|
+
"""Helpers that only build transaction dictionaries for sends and approvals."""
|
|
133
|
+
|
|
134
|
+
def __init__(self) -> None:
|
|
135
|
+
self.transaction_client = TransactionClient()
|
|
136
|
+
|
|
137
|
+
async def build_send_transaction(
|
|
138
|
+
self,
|
|
139
|
+
*,
|
|
140
|
+
from_address: str,
|
|
141
|
+
to_address: str,
|
|
142
|
+
token_address: str | None,
|
|
143
|
+
amount: float,
|
|
144
|
+
chain_id: int,
|
|
145
|
+
) -> dict[str, Any]:
|
|
146
|
+
"""Build the transaction dict for sending native or ERC20 tokens."""
|
|
147
|
+
payload = await self.transaction_client.build_send(
|
|
148
|
+
from_address=from_address,
|
|
149
|
+
to_address=to_address,
|
|
150
|
+
token_address=token_address or "",
|
|
151
|
+
amount=float(amount),
|
|
152
|
+
chain_id=int(chain_id),
|
|
153
|
+
)
|
|
154
|
+
return self._payload_to_tx(
|
|
155
|
+
payload=payload,
|
|
156
|
+
from_address=from_address,
|
|
157
|
+
is_native=not token_address or token_address.lower() == ZERO_ADDRESS,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def build_erc20_approval_transaction(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
chain_id: int,
|
|
164
|
+
token_address: str,
|
|
165
|
+
from_address: str,
|
|
166
|
+
spender: str,
|
|
167
|
+
amount: int,
|
|
168
|
+
web3: AsyncWeb3,
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
"""Build an ERC20 approval transaction dict."""
|
|
171
|
+
token_checksum = to_checksum_address(token_address)
|
|
172
|
+
spender_checksum = to_checksum_address(spender)
|
|
173
|
+
from_checksum = to_checksum_address(from_address)
|
|
174
|
+
amount_int = int(amount)
|
|
175
|
+
|
|
176
|
+
contract = web3.eth.contract(address=token_checksum, abi=ERC20_APPROVAL_ABI)
|
|
177
|
+
data = contract.encodeABI(
|
|
178
|
+
fn_name="approve", args=[spender_checksum, amount_int]
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"chainId": int(chain_id),
|
|
183
|
+
"from": from_checksum,
|
|
184
|
+
"to": token_checksum,
|
|
185
|
+
"data": data,
|
|
186
|
+
"value": 0,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def _payload_to_tx(
|
|
190
|
+
self, payload: dict[str, Any], from_address: str, is_native: bool
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
data_root = payload.get("data", payload)
|
|
193
|
+
tx_src = data_root.get("transaction") or data_root
|
|
194
|
+
|
|
195
|
+
chain_id = tx_src.get("chainId") or data_root.get("chain_id")
|
|
196
|
+
if chain_id is None:
|
|
197
|
+
raise ValueError("Transaction payload missing chainId")
|
|
198
|
+
|
|
199
|
+
tx: dict[str, Any] = {"chainId": int(chain_id)}
|
|
200
|
+
tx["from"] = to_checksum_address(from_address)
|
|
201
|
+
|
|
202
|
+
if tx_src.get("to"):
|
|
203
|
+
tx["to"] = to_checksum_address(tx_src["to"])
|
|
204
|
+
if tx_src.get("data"):
|
|
205
|
+
data = tx_src["data"]
|
|
206
|
+
tx["data"] = data if str(data).startswith("0x") else f"0x{data}"
|
|
207
|
+
|
|
208
|
+
val = tx_src.get("value", 0)
|
|
209
|
+
tx["value"] = self._normalize_value(val) if is_native else 0
|
|
210
|
+
|
|
211
|
+
if tx_src.get("gas"):
|
|
212
|
+
tx["gas"] = int(tx_src["gas"])
|
|
213
|
+
if tx_src.get("maxFeePerGas"):
|
|
214
|
+
tx["maxFeePerGas"] = int(tx_src["maxFeePerGas"])
|
|
215
|
+
if tx_src.get("maxPriorityFeePerGas"):
|
|
216
|
+
tx["maxPriorityFeePerGas"] = int(tx_src["maxPriorityFeePerGas"])
|
|
217
|
+
if tx_src.get("gasPrice"):
|
|
218
|
+
tx["gasPrice"] = int(tx_src["gasPrice"])
|
|
219
|
+
if tx_src.get("nonce") is not None:
|
|
220
|
+
tx["nonce"] = int(tx_src["nonce"])
|
|
221
|
+
|
|
222
|
+
return tx
|
|
223
|
+
|
|
224
|
+
def _normalize_value(self, value: Any) -> int:
|
|
225
|
+
if isinstance(value, str):
|
|
226
|
+
if value.startswith("0x"):
|
|
227
|
+
return int(value, 16)
|
|
228
|
+
return int(float(value))
|
|
229
|
+
if isinstance(value, (int, float)):
|
|
230
|
+
return int(value)
|
|
231
|
+
return 0
|