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.

Files changed (115) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +394 -0
  2. wayfinder_paths/__init__.py +21 -0
  3. wayfinder_paths/config.example.json +20 -0
  4. wayfinder_paths/conftest.py +31 -0
  5. wayfinder_paths/core/__init__.py +13 -0
  6. wayfinder_paths/core/adapters/BaseAdapter.py +48 -0
  7. wayfinder_paths/core/adapters/__init__.py +5 -0
  8. wayfinder_paths/core/adapters/base.py +5 -0
  9. wayfinder_paths/core/clients/AuthClient.py +83 -0
  10. wayfinder_paths/core/clients/BRAPClient.py +90 -0
  11. wayfinder_paths/core/clients/ClientManager.py +231 -0
  12. wayfinder_paths/core/clients/HyperlendClient.py +151 -0
  13. wayfinder_paths/core/clients/LedgerClient.py +222 -0
  14. wayfinder_paths/core/clients/PoolClient.py +96 -0
  15. wayfinder_paths/core/clients/SimulationClient.py +180 -0
  16. wayfinder_paths/core/clients/TokenClient.py +73 -0
  17. wayfinder_paths/core/clients/TransactionClient.py +47 -0
  18. wayfinder_paths/core/clients/WalletClient.py +90 -0
  19. wayfinder_paths/core/clients/WayfinderClient.py +258 -0
  20. wayfinder_paths/core/clients/__init__.py +48 -0
  21. wayfinder_paths/core/clients/protocols.py +295 -0
  22. wayfinder_paths/core/clients/sdk_example.py +115 -0
  23. wayfinder_paths/core/config.py +369 -0
  24. wayfinder_paths/core/constants/__init__.py +26 -0
  25. wayfinder_paths/core/constants/base.py +25 -0
  26. wayfinder_paths/core/constants/erc20_abi.py +118 -0
  27. wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
  28. wayfinder_paths/core/engine/VaultJob.py +182 -0
  29. wayfinder_paths/core/engine/__init__.py +5 -0
  30. wayfinder_paths/core/engine/manifest.py +97 -0
  31. wayfinder_paths/core/services/__init__.py +0 -0
  32. wayfinder_paths/core/services/base.py +177 -0
  33. wayfinder_paths/core/services/local_evm_txn.py +429 -0
  34. wayfinder_paths/core/services/local_token_txn.py +231 -0
  35. wayfinder_paths/core/services/web3_service.py +45 -0
  36. wayfinder_paths/core/settings.py +61 -0
  37. wayfinder_paths/core/strategies/Strategy.py +183 -0
  38. wayfinder_paths/core/strategies/__init__.py +5 -0
  39. wayfinder_paths/core/strategies/base.py +7 -0
  40. wayfinder_paths/core/utils/__init__.py +1 -0
  41. wayfinder_paths/core/utils/evm_helpers.py +165 -0
  42. wayfinder_paths/core/utils/wallets.py +77 -0
  43. wayfinder_paths/core/wallets/README.md +91 -0
  44. wayfinder_paths/core/wallets/WalletManager.py +56 -0
  45. wayfinder_paths/core/wallets/__init__.py +7 -0
  46. wayfinder_paths/run_strategy.py +409 -0
  47. wayfinder_paths/scripts/__init__.py +0 -0
  48. wayfinder_paths/scripts/create_strategy.py +181 -0
  49. wayfinder_paths/scripts/make_wallets.py +160 -0
  50. wayfinder_paths/scripts/validate_manifests.py +213 -0
  51. wayfinder_paths/tests/__init__.py +0 -0
  52. wayfinder_paths/tests/test_smoke_manifest.py +48 -0
  53. wayfinder_paths/tests/test_test_coverage.py +212 -0
  54. wayfinder_paths/tests/test_utils.py +64 -0
  55. wayfinder_paths/vaults/__init__.py +0 -0
  56. wayfinder_paths/vaults/adapters/__init__.py +0 -0
  57. wayfinder_paths/vaults/adapters/balance_adapter/README.md +104 -0
  58. wayfinder_paths/vaults/adapters/balance_adapter/adapter.py +257 -0
  59. wayfinder_paths/vaults/adapters/balance_adapter/examples.json +6 -0
  60. wayfinder_paths/vaults/adapters/balance_adapter/manifest.yaml +8 -0
  61. wayfinder_paths/vaults/adapters/balance_adapter/test_adapter.py +83 -0
  62. wayfinder_paths/vaults/adapters/brap_adapter/README.md +249 -0
  63. wayfinder_paths/vaults/adapters/brap_adapter/__init__.py +7 -0
  64. wayfinder_paths/vaults/adapters/brap_adapter/adapter.py +717 -0
  65. wayfinder_paths/vaults/adapters/brap_adapter/examples.json +175 -0
  66. wayfinder_paths/vaults/adapters/brap_adapter/manifest.yaml +11 -0
  67. wayfinder_paths/vaults/adapters/brap_adapter/test_adapter.py +288 -0
  68. wayfinder_paths/vaults/adapters/hyperlend_adapter/__init__.py +7 -0
  69. wayfinder_paths/vaults/adapters/hyperlend_adapter/adapter.py +298 -0
  70. wayfinder_paths/vaults/adapters/hyperlend_adapter/manifest.yaml +10 -0
  71. wayfinder_paths/vaults/adapters/hyperlend_adapter/test_adapter.py +267 -0
  72. wayfinder_paths/vaults/adapters/ledger_adapter/README.md +158 -0
  73. wayfinder_paths/vaults/adapters/ledger_adapter/__init__.py +7 -0
  74. wayfinder_paths/vaults/adapters/ledger_adapter/adapter.py +286 -0
  75. wayfinder_paths/vaults/adapters/ledger_adapter/examples.json +131 -0
  76. wayfinder_paths/vaults/adapters/ledger_adapter/manifest.yaml +11 -0
  77. wayfinder_paths/vaults/adapters/ledger_adapter/test_adapter.py +202 -0
  78. wayfinder_paths/vaults/adapters/pool_adapter/README.md +218 -0
  79. wayfinder_paths/vaults/adapters/pool_adapter/__init__.py +7 -0
  80. wayfinder_paths/vaults/adapters/pool_adapter/adapter.py +289 -0
  81. wayfinder_paths/vaults/adapters/pool_adapter/examples.json +143 -0
  82. wayfinder_paths/vaults/adapters/pool_adapter/manifest.yaml +10 -0
  83. wayfinder_paths/vaults/adapters/pool_adapter/test_adapter.py +222 -0
  84. wayfinder_paths/vaults/adapters/token_adapter/README.md +101 -0
  85. wayfinder_paths/vaults/adapters/token_adapter/__init__.py +3 -0
  86. wayfinder_paths/vaults/adapters/token_adapter/adapter.py +92 -0
  87. wayfinder_paths/vaults/adapters/token_adapter/examples.json +26 -0
  88. wayfinder_paths/vaults/adapters/token_adapter/manifest.yaml +6 -0
  89. wayfinder_paths/vaults/adapters/token_adapter/test_adapter.py +135 -0
  90. wayfinder_paths/vaults/strategies/__init__.py +0 -0
  91. wayfinder_paths/vaults/strategies/config.py +85 -0
  92. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/README.md +99 -0
  93. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/examples.json +16 -0
  94. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
  95. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/strategy.py +2328 -0
  96. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/test_strategy.py +319 -0
  97. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/README.md +95 -0
  98. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/examples.json +17 -0
  99. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
  100. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/strategy.py +1684 -0
  101. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/test_strategy.py +350 -0
  102. wayfinder_paths/vaults/templates/adapter/README.md +105 -0
  103. wayfinder_paths/vaults/templates/adapter/adapter.py +26 -0
  104. wayfinder_paths/vaults/templates/adapter/examples.json +8 -0
  105. wayfinder_paths/vaults/templates/adapter/manifest.yaml +6 -0
  106. wayfinder_paths/vaults/templates/adapter/test_adapter.py +49 -0
  107. wayfinder_paths/vaults/templates/strategy/README.md +152 -0
  108. wayfinder_paths/vaults/templates/strategy/examples.json +11 -0
  109. wayfinder_paths/vaults/templates/strategy/manifest.yaml +8 -0
  110. wayfinder_paths/vaults/templates/strategy/strategy.py +57 -0
  111. wayfinder_paths/vaults/templates/strategy/test_strategy.py +197 -0
  112. wayfinder_paths-0.1.1.dist-info/LICENSE +21 -0
  113. wayfinder_paths-0.1.1.dist-info/METADATA +727 -0
  114. wayfinder_paths-0.1.1.dist-info/RECORD +115 -0
  115. 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