wayfinder-paths 0.1.15__py3-none-any.whl → 0.1.16__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/adapters/balance_adapter/README.md +19 -20
- wayfinder_paths/adapters/balance_adapter/adapter.py +66 -37
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +2 -8
- wayfinder_paths/adapters/brap_adapter/README.md +22 -19
- wayfinder_paths/adapters/brap_adapter/adapter.py +33 -34
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +2 -18
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +40 -56
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +1 -8
- wayfinder_paths/adapters/moonwell_adapter/README.md +29 -31
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +301 -662
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +275 -179
- wayfinder_paths/core/config.py +8 -47
- wayfinder_paths/core/constants/base.py +0 -1
- wayfinder_paths/core/constants/erc20_abi.py +13 -13
- wayfinder_paths/core/strategies/Strategy.py +6 -2
- wayfinder_paths/core/utils/erc20_service.py +100 -0
- wayfinder_paths/core/utils/evm_helpers.py +1 -1
- wayfinder_paths/core/utils/transaction.py +191 -0
- wayfinder_paths/core/utils/web3.py +66 -0
- wayfinder_paths/run_strategy.py +37 -6
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +200 -224
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +128 -151
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +0 -1
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +52 -78
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +0 -12
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +0 -1
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +39 -64
- wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +0 -1
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +42 -85
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +0 -8
- wayfinder_paths/templates/strategy/README.md +1 -5
- {wayfinder_paths-0.1.15.dist-info → wayfinder_paths-0.1.16.dist-info}/METADATA +3 -41
- {wayfinder_paths-0.1.15.dist-info → wayfinder_paths-0.1.16.dist-info}/RECORD +35 -44
- {wayfinder_paths-0.1.15.dist-info → wayfinder_paths-0.1.16.dist-info}/WHEEL +1 -1
- wayfinder_paths/core/clients/sdk_example.py +0 -125
- wayfinder_paths/core/engine/__init__.py +0 -5
- wayfinder_paths/core/services/__init__.py +0 -0
- wayfinder_paths/core/services/base.py +0 -131
- wayfinder_paths/core/services/local_evm_txn.py +0 -350
- wayfinder_paths/core/services/local_token_txn.py +0 -238
- wayfinder_paths/core/services/web3_service.py +0 -43
- wayfinder_paths/core/wallets/README.md +0 -88
- wayfinder_paths/core/wallets/WalletManager.py +0 -56
- wayfinder_paths/core/wallets/__init__.py +0 -7
- wayfinder_paths/scripts/run_strategy.py +0 -152
- wayfinder_paths/strategies/config.py +0 -85
- {wayfinder_paths-0.1.15.dist-info → wayfinder_paths-0.1.16.dist-info}/LICENSE +0 -0
|
@@ -1,350 +0,0 @@
|
|
|
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
|
|
8
|
-
from web3.middleware import ExtraDataToPOAMiddleware
|
|
9
|
-
from web3.module import Module
|
|
10
|
-
|
|
11
|
-
from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
|
|
12
|
-
from wayfinder_paths.core.services.base import EvmTxn
|
|
13
|
-
from wayfinder_paths.core.utils.evm_helpers import (
|
|
14
|
-
resolve_private_key_for_from_address,
|
|
15
|
-
resolve_rpc_url,
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
SUGGESTED_GAS_PRICE_MULTIPLIER = 1.5
|
|
19
|
-
SUGGESTED_PRIORITY_FEE_MULTIPLIER = 1.5
|
|
20
|
-
MAX_BASE_FEE_GROWTH_MULTIPLIER = 2
|
|
21
|
-
GAS_LIMIT_BUFFER_MULTIPLIER = 1.5
|
|
22
|
-
|
|
23
|
-
# Base chain ID (Base mainnet)
|
|
24
|
-
BASE_CHAIN_ID = 8453
|
|
25
|
-
|
|
26
|
-
# Chains that don't support EIP-1559 (London) and need legacy gas pricing
|
|
27
|
-
PRE_LONDON_GAS_CHAIN_IDS: set[int] = {56, 42161}
|
|
28
|
-
POA_MIDDLEWARE_CHAIN_IDS: set = {56, 137, 43114}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _looks_like_revert_error(error: Any) -> bool:
|
|
32
|
-
msg = str(error).lower()
|
|
33
|
-
return any(
|
|
34
|
-
needle in msg
|
|
35
|
-
for needle in (
|
|
36
|
-
"execution reverted",
|
|
37
|
-
"revert",
|
|
38
|
-
"always failing transaction",
|
|
39
|
-
"gas required exceeds",
|
|
40
|
-
"out of gas",
|
|
41
|
-
"insufficient funds",
|
|
42
|
-
"transfer amount exceeds balance",
|
|
43
|
-
"insufficient balance",
|
|
44
|
-
"insufficient allowance",
|
|
45
|
-
)
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class HyperModule(Module):
|
|
50
|
-
def __init__(self, w3):
|
|
51
|
-
super().__init__(w3)
|
|
52
|
-
|
|
53
|
-
async def big_block_gas_price(self):
|
|
54
|
-
big_block_gas_price = await self.w3.manager.coro_request(
|
|
55
|
-
"eth_bigBlockGasPrice", []
|
|
56
|
-
)
|
|
57
|
-
return int(big_block_gas_price, 16)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
class LocalEvmTxn(EvmTxn):
|
|
61
|
-
"""
|
|
62
|
-
Local wallet provider using private keys stored in config.json or config.json.
|
|
63
|
-
|
|
64
|
-
This provider implements the current default behavior:
|
|
65
|
-
- Resolves private keys from config.json or config.json
|
|
66
|
-
- Signs transactions using eth_account
|
|
67
|
-
- Broadcasts transactions via RPC
|
|
68
|
-
"""
|
|
69
|
-
|
|
70
|
-
def __init__(self, config: dict[str, Any] | None = None):
|
|
71
|
-
self.config = config or {}
|
|
72
|
-
self.logger = logger.bind(provider="LocalWalletProvider")
|
|
73
|
-
|
|
74
|
-
def get_web3(self, chain_id: int) -> AsyncWeb3:
|
|
75
|
-
rpc_url = self._resolve_rpc_url(chain_id)
|
|
76
|
-
web3 = AsyncWeb3(AsyncHTTPProvider(rpc_url))
|
|
77
|
-
if chain_id in POA_MIDDLEWARE_CHAIN_IDS:
|
|
78
|
-
web3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
|
79
|
-
if chain_id == 999:
|
|
80
|
-
web3.attach_modules({"hype": (HyperModule)})
|
|
81
|
-
return web3
|
|
82
|
-
|
|
83
|
-
def _validate_transaction(self, transaction: dict[str, Any]) -> dict[str, Any]:
|
|
84
|
-
tx = dict(transaction)
|
|
85
|
-
|
|
86
|
-
assert "from" in tx, "Transaction missing 'from' address"
|
|
87
|
-
assert "to" in tx, "Transaction missing 'to' address"
|
|
88
|
-
assert "chainId" in tx, "Transaction missing 'chainId'"
|
|
89
|
-
|
|
90
|
-
tx["from"] = to_checksum_address(tx["from"])
|
|
91
|
-
tx["to"] = to_checksum_address(tx["to"])
|
|
92
|
-
if "value" in tx:
|
|
93
|
-
tx["value"] = self._normalize_int(tx["value"])
|
|
94
|
-
|
|
95
|
-
tx.pop("gas", None)
|
|
96
|
-
tx.pop("gasPrice", None)
|
|
97
|
-
tx.pop("maxFeePerGas", None)
|
|
98
|
-
tx.pop("maxPriorityFeePerGas", None)
|
|
99
|
-
tx.pop("nonce", None)
|
|
100
|
-
|
|
101
|
-
return tx
|
|
102
|
-
|
|
103
|
-
async def _nonce_transaction(
|
|
104
|
-
self, transaction: dict[str, Any], w3: AsyncWeb3
|
|
105
|
-
) -> dict[str, Any]:
|
|
106
|
-
transaction["nonce"] = await w3.eth.get_transaction_count(
|
|
107
|
-
transaction["from"], "pending"
|
|
108
|
-
)
|
|
109
|
-
return transaction
|
|
110
|
-
|
|
111
|
-
async def _gas_limit_transaction(
|
|
112
|
-
self, transaction: dict[str, Any], w3: AsyncWeb3
|
|
113
|
-
) -> dict[str, Any]:
|
|
114
|
-
# Pop existing gas limit before estimating - if present, the node uses it as
|
|
115
|
-
# a ceiling and fails with "out of gas" instead of returning actual estimate
|
|
116
|
-
existing_gas = transaction.pop("gas", None)
|
|
117
|
-
try:
|
|
118
|
-
transaction.pop("gas", None) # Remove any existing gas limit
|
|
119
|
-
estimated = await w3.eth.estimate_gas(transaction)
|
|
120
|
-
transaction["gas"] = int(estimated * GAS_LIMIT_BUFFER_MULTIPLIER)
|
|
121
|
-
self.logger.debug(
|
|
122
|
-
f"Estimated gas with buffer: {estimated} -> {transaction['gas']}"
|
|
123
|
-
)
|
|
124
|
-
except Exception as exc: # noqa: BLE001
|
|
125
|
-
if _looks_like_revert_error(exc):
|
|
126
|
-
raise ValueError(
|
|
127
|
-
f"Gas estimation failed (tx likely to revert): {exc}"
|
|
128
|
-
) from exc
|
|
129
|
-
self.logger.warning(f"Gas estimation failed. Reason: {exc}")
|
|
130
|
-
# Restore existing gas limit if estimation failed, otherwise error
|
|
131
|
-
if existing_gas is not None:
|
|
132
|
-
transaction["gas"] = existing_gas
|
|
133
|
-
else:
|
|
134
|
-
raise ValueError(
|
|
135
|
-
f"Gas estimation failed and no gas limit set: {exc}"
|
|
136
|
-
) from exc
|
|
137
|
-
|
|
138
|
-
return transaction
|
|
139
|
-
|
|
140
|
-
async def _get_gas_price(self, w3: AsyncWeb3) -> int:
|
|
141
|
-
return await w3.eth.gas_price
|
|
142
|
-
|
|
143
|
-
async def _get_base_fee(self, w3: AsyncWeb3) -> int:
|
|
144
|
-
latest_block = await w3.eth.get_block("latest")
|
|
145
|
-
return latest_block.baseFeePerGas
|
|
146
|
-
|
|
147
|
-
async def _get_priority_fee(self, w3: AsyncWeb3) -> int:
|
|
148
|
-
lookback_blocks = 10
|
|
149
|
-
percentile = 80
|
|
150
|
-
fee_history = await w3.eth.fee_history(lookback_blocks, "latest", [percentile])
|
|
151
|
-
historical_priority_fees = [i[0] for i in fee_history.reward]
|
|
152
|
-
return sum(historical_priority_fees) // len(historical_priority_fees)
|
|
153
|
-
|
|
154
|
-
async def _gas_price_transaction(
|
|
155
|
-
self, transaction: dict[str, Any], chain_id: int, w3: AsyncWeb3
|
|
156
|
-
) -> dict[str, Any]:
|
|
157
|
-
if chain_id in PRE_LONDON_GAS_CHAIN_IDS:
|
|
158
|
-
gas_price = await self._get_gas_price(w3)
|
|
159
|
-
|
|
160
|
-
transaction["gasPrice"] = int(gas_price * SUGGESTED_GAS_PRICE_MULTIPLIER)
|
|
161
|
-
# elif chain_id == 999:
|
|
162
|
-
# big_block_gas_price = await w3.hype.big_block_gas_price()
|
|
163
|
-
|
|
164
|
-
# transaction["maxFeePerGas"] = int(
|
|
165
|
-
# big_block_gas_price * SUGGESTED_PRIORITY_FEE_MULTIPLIER
|
|
166
|
-
# )
|
|
167
|
-
# transaction["maxPriorityFeePerGas"] = 0
|
|
168
|
-
else:
|
|
169
|
-
base_fee = await self._get_base_fee(w3)
|
|
170
|
-
priority_fee = await self._get_priority_fee(w3)
|
|
171
|
-
|
|
172
|
-
transaction["maxFeePerGas"] = int(
|
|
173
|
-
base_fee * MAX_BASE_FEE_GROWTH_MULTIPLIER
|
|
174
|
-
+ priority_fee * SUGGESTED_PRIORITY_FEE_MULTIPLIER
|
|
175
|
-
)
|
|
176
|
-
transaction["maxPriorityFeePerGas"] = int(
|
|
177
|
-
priority_fee * SUGGESTED_PRIORITY_FEE_MULTIPLIER
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
return transaction
|
|
181
|
-
|
|
182
|
-
async def broadcast_transaction(
|
|
183
|
-
self,
|
|
184
|
-
transaction: dict[str, Any],
|
|
185
|
-
*,
|
|
186
|
-
wait_for_receipt: bool = True,
|
|
187
|
-
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
188
|
-
confirmations: int | None = None,
|
|
189
|
-
) -> tuple[bool, Any]:
|
|
190
|
-
try:
|
|
191
|
-
chain_id = transaction["chainId"]
|
|
192
|
-
from_address = transaction["from"]
|
|
193
|
-
|
|
194
|
-
# Default confirmation behavior:
|
|
195
|
-
# - Base: wait for 2 additional blocks after the receipt block
|
|
196
|
-
# - Others: do not wait for additional confirmations
|
|
197
|
-
effective_confirmations = confirmations
|
|
198
|
-
if effective_confirmations is None:
|
|
199
|
-
effective_confirmations = 2 if int(chain_id) == BASE_CHAIN_ID else 0
|
|
200
|
-
effective_confirmations = max(0, int(effective_confirmations))
|
|
201
|
-
|
|
202
|
-
web3 = self.get_web3(chain_id)
|
|
203
|
-
try:
|
|
204
|
-
transaction = self._validate_transaction(transaction)
|
|
205
|
-
transaction = await self._nonce_transaction(transaction, web3)
|
|
206
|
-
transaction = await self._gas_limit_transaction(transaction, web3)
|
|
207
|
-
transaction = await self._gas_price_transaction(
|
|
208
|
-
transaction, chain_id, web3
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
signed_tx = self._sign_transaction(transaction, from_address)
|
|
212
|
-
|
|
213
|
-
tx_hash = await web3.eth.send_raw_transaction(signed_tx)
|
|
214
|
-
tx_hash_hex = tx_hash.hex()
|
|
215
|
-
|
|
216
|
-
result: dict[str, Any] = {"tx_hash": tx_hash_hex}
|
|
217
|
-
if wait_for_receipt:
|
|
218
|
-
receipt = await web3.eth.wait_for_transaction_receipt(
|
|
219
|
-
tx_hash, timeout=timeout
|
|
220
|
-
)
|
|
221
|
-
result["receipt"] = self._format_receipt(receipt)
|
|
222
|
-
# Add block_number at top level for convenience
|
|
223
|
-
result["block_number"] = result["receipt"].get("blockNumber")
|
|
224
|
-
result["confirmations"] = effective_confirmations
|
|
225
|
-
result["confirmed_block_number"] = result["block_number"]
|
|
226
|
-
|
|
227
|
-
receipt_status = result["receipt"].get("status")
|
|
228
|
-
if receipt_status is not None and int(receipt_status) != 1:
|
|
229
|
-
return (
|
|
230
|
-
False,
|
|
231
|
-
f"Transaction reverted (status={receipt_status}): {tx_hash_hex}",
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
# Wait for additional confirmations if requested
|
|
235
|
-
if effective_confirmations > 0:
|
|
236
|
-
tx_block = result["receipt"].get("blockNumber")
|
|
237
|
-
if tx_block:
|
|
238
|
-
await self._wait_for_confirmations(
|
|
239
|
-
web3, tx_block, effective_confirmations
|
|
240
|
-
)
|
|
241
|
-
result["confirmed_block_number"] = int(tx_block) + int(
|
|
242
|
-
effective_confirmations
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
return (True, result)
|
|
246
|
-
|
|
247
|
-
finally:
|
|
248
|
-
await self._close_web3(web3)
|
|
249
|
-
|
|
250
|
-
except Exception as exc: # noqa: BLE001
|
|
251
|
-
self.logger.error(f"Transaction broadcast failed: {exc}")
|
|
252
|
-
return (False, f"Transaction broadcast failed: {exc}")
|
|
253
|
-
|
|
254
|
-
async def transaction_succeeded(
|
|
255
|
-
self, tx_hash: str, chain_id: int, timeout: int = 120
|
|
256
|
-
) -> bool:
|
|
257
|
-
w3 = self.get_web3(chain_id)
|
|
258
|
-
try:
|
|
259
|
-
receipt = await w3.eth.wait_for_transaction_receipt(
|
|
260
|
-
tx_hash, timeout=timeout
|
|
261
|
-
)
|
|
262
|
-
status = getattr(receipt, "status", None)
|
|
263
|
-
if status is None and isinstance(receipt, dict):
|
|
264
|
-
status = receipt.get("status")
|
|
265
|
-
return status == 1
|
|
266
|
-
except Exception as exc: # noqa: BLE001
|
|
267
|
-
self.logger.warning(
|
|
268
|
-
f"Failed to confirm transaction {tx_hash} on chain {chain_id}: {exc}"
|
|
269
|
-
)
|
|
270
|
-
return False
|
|
271
|
-
finally:
|
|
272
|
-
await self._close_web3(w3)
|
|
273
|
-
|
|
274
|
-
def _sign_transaction(
|
|
275
|
-
self, transaction: dict[str, Any], from_address: str
|
|
276
|
-
) -> bytes:
|
|
277
|
-
private_key = resolve_private_key_for_from_address(from_address, self.config)
|
|
278
|
-
if not private_key:
|
|
279
|
-
raise ValueError(f"No private key available for address {from_address}")
|
|
280
|
-
signed = Account.sign_transaction(transaction, private_key)
|
|
281
|
-
return signed.raw_transaction
|
|
282
|
-
|
|
283
|
-
def _resolve_rpc_url(self, chain_id: int) -> str:
|
|
284
|
-
return resolve_rpc_url(chain_id, self.config or {}, None)
|
|
285
|
-
|
|
286
|
-
async def _close_web3(self, web3: AsyncWeb3) -> None:
|
|
287
|
-
try:
|
|
288
|
-
if hasattr(web3.provider, "disconnect"):
|
|
289
|
-
await web3.provider.disconnect()
|
|
290
|
-
except Exception as e: # noqa: BLE001
|
|
291
|
-
self.logger.debug(f"Error disconnecting provider: {e}")
|
|
292
|
-
|
|
293
|
-
async def _wait_for_confirmations(
|
|
294
|
-
self, w3: AsyncWeb3, tx_block: int, confirmations: int
|
|
295
|
-
) -> None:
|
|
296
|
-
"""Wait until the transaction has the specified number of confirmations."""
|
|
297
|
-
target_block = tx_block + confirmations
|
|
298
|
-
while True:
|
|
299
|
-
current_block = await w3.eth.block_number
|
|
300
|
-
if current_block >= target_block:
|
|
301
|
-
break
|
|
302
|
-
await asyncio.sleep(1)
|
|
303
|
-
|
|
304
|
-
def _format_receipt(self, receipt: Any) -> dict[str, Any]:
|
|
305
|
-
tx_hash = getattr(receipt, "transactionHash", None)
|
|
306
|
-
if hasattr(tx_hash, "hex"):
|
|
307
|
-
tx_hash = tx_hash.hex()
|
|
308
|
-
|
|
309
|
-
return {
|
|
310
|
-
"transactionHash": tx_hash,
|
|
311
|
-
"status": (
|
|
312
|
-
getattr(receipt, "status", None)
|
|
313
|
-
if not isinstance(receipt, dict)
|
|
314
|
-
else receipt.get("status")
|
|
315
|
-
),
|
|
316
|
-
"blockNumber": (
|
|
317
|
-
getattr(receipt, "blockNumber", None)
|
|
318
|
-
if not isinstance(receipt, dict)
|
|
319
|
-
else receipt.get("blockNumber")
|
|
320
|
-
),
|
|
321
|
-
"gasUsed": (
|
|
322
|
-
getattr(receipt, "gasUsed", None)
|
|
323
|
-
if not isinstance(receipt, dict)
|
|
324
|
-
else receipt.get("gasUsed")
|
|
325
|
-
),
|
|
326
|
-
"logs": (
|
|
327
|
-
[
|
|
328
|
-
dict(log_entry) if not isinstance(log_entry, dict) else log_entry
|
|
329
|
-
for log_entry in getattr(receipt, "logs", [])
|
|
330
|
-
]
|
|
331
|
-
if hasattr(receipt, "logs")
|
|
332
|
-
else receipt.get("logs")
|
|
333
|
-
if isinstance(receipt, dict)
|
|
334
|
-
else []
|
|
335
|
-
),
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
def _normalize_int(self, value: Any) -> int:
|
|
339
|
-
if isinstance(value, int):
|
|
340
|
-
return value
|
|
341
|
-
if isinstance(value, float):
|
|
342
|
-
return int(value)
|
|
343
|
-
if isinstance(value, str):
|
|
344
|
-
if value.startswith("0x"):
|
|
345
|
-
return int(value, 16)
|
|
346
|
-
try:
|
|
347
|
-
return int(value)
|
|
348
|
-
except ValueError:
|
|
349
|
-
return int(float(value))
|
|
350
|
-
raise ValueError(f"Unable to convert value '{value}' to int")
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
from decimal import ROUND_DOWN, Decimal
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
from eth_utils import to_checksum_address
|
|
8
|
-
from loguru import logger
|
|
9
|
-
from web3 import Web3
|
|
10
|
-
|
|
11
|
-
from wayfinder_paths.core.clients.TokenClient import TokenClient
|
|
12
|
-
from wayfinder_paths.core.constants import ZERO_ADDRESS
|
|
13
|
-
from wayfinder_paths.core.constants.erc20_abi import ERC20_ABI, ERC20_APPROVAL_ABI
|
|
14
|
-
from wayfinder_paths.core.services.base import EvmTxn, TokenTxn
|
|
15
|
-
from wayfinder_paths.core.utils.evm_helpers import resolve_chain_id
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class LocalTokenTxnService(TokenTxn):
|
|
19
|
-
"""Default transaction builder used by adapters."""
|
|
20
|
-
|
|
21
|
-
def __init__(
|
|
22
|
-
self,
|
|
23
|
-
config: dict[str, Any] | None,
|
|
24
|
-
*,
|
|
25
|
-
wallet_provider: EvmTxn,
|
|
26
|
-
) -> None:
|
|
27
|
-
del config
|
|
28
|
-
self.wallet_provider = wallet_provider
|
|
29
|
-
self.logger = logger.bind(service="DefaultEvmTransactionService")
|
|
30
|
-
self.token_client = TokenClient()
|
|
31
|
-
|
|
32
|
-
async def build_send(
|
|
33
|
-
self,
|
|
34
|
-
*,
|
|
35
|
-
token_id: str,
|
|
36
|
-
amount: float,
|
|
37
|
-
from_address: str,
|
|
38
|
-
to_address: str,
|
|
39
|
-
token_info: dict[str, Any] | None = None,
|
|
40
|
-
) -> tuple[bool, dict[str, Any] | str]:
|
|
41
|
-
"""Build the transaction dict for sending tokens between wallets."""
|
|
42
|
-
token_meta = token_info
|
|
43
|
-
if token_meta is None:
|
|
44
|
-
token_meta = await self.token_client.get_token_details(token_id)
|
|
45
|
-
if not token_meta:
|
|
46
|
-
return False, f"Token not found: {token_id}"
|
|
47
|
-
|
|
48
|
-
chain_id = resolve_chain_id(token_meta, self.logger)
|
|
49
|
-
if chain_id is None:
|
|
50
|
-
return False, f"Token {token_id} is missing a chain id"
|
|
51
|
-
|
|
52
|
-
token_address = (token_meta or {}).get("address") or ZERO_ADDRESS
|
|
53
|
-
is_native = not token_address or token_address.lower() == ZERO_ADDRESS.lower()
|
|
54
|
-
|
|
55
|
-
if is_native:
|
|
56
|
-
amount_wei = self._to_base_units(
|
|
57
|
-
amount, 18
|
|
58
|
-
) # Native tokens use 18 decimals
|
|
59
|
-
else:
|
|
60
|
-
decimals = int((token_meta or {}).get("decimals") or 18)
|
|
61
|
-
amount_wei = self._to_base_units(amount, decimals)
|
|
62
|
-
|
|
63
|
-
try:
|
|
64
|
-
tx = await self.build_send_transaction(
|
|
65
|
-
from_address=from_address,
|
|
66
|
-
to_address=to_address,
|
|
67
|
-
token_address=token_address,
|
|
68
|
-
amount=amount_wei,
|
|
69
|
-
chain_id=int(chain_id),
|
|
70
|
-
is_native=is_native,
|
|
71
|
-
)
|
|
72
|
-
except Exception as exc: # noqa: BLE001
|
|
73
|
-
return False, f"Failed to build send transaction: {exc}"
|
|
74
|
-
|
|
75
|
-
return True, tx
|
|
76
|
-
|
|
77
|
-
def build_erc20_approve(
|
|
78
|
-
self,
|
|
79
|
-
*,
|
|
80
|
-
chain_id: int,
|
|
81
|
-
token_address: str,
|
|
82
|
-
from_address: str,
|
|
83
|
-
spender: str,
|
|
84
|
-
amount: int,
|
|
85
|
-
) -> tuple[bool, dict[str, Any] | str]:
|
|
86
|
-
"""Build the transaction dictionary for an ERC20 approval."""
|
|
87
|
-
try:
|
|
88
|
-
token_checksum = to_checksum_address(token_address)
|
|
89
|
-
from_checksum = to_checksum_address(from_address)
|
|
90
|
-
spender_checksum = to_checksum_address(spender)
|
|
91
|
-
amount_int = int(amount)
|
|
92
|
-
except (TypeError, ValueError) as exc:
|
|
93
|
-
return False, str(exc)
|
|
94
|
-
|
|
95
|
-
approve_tx = self.build_erc20_approval_transaction(
|
|
96
|
-
chain_id=chain_id,
|
|
97
|
-
token_address=token_checksum,
|
|
98
|
-
from_address=from_checksum,
|
|
99
|
-
spender=spender_checksum,
|
|
100
|
-
amount=amount_int,
|
|
101
|
-
)
|
|
102
|
-
return True, approve_tx
|
|
103
|
-
|
|
104
|
-
async def read_erc20_allowance(
|
|
105
|
-
self,
|
|
106
|
-
chain: Any,
|
|
107
|
-
token_address: str,
|
|
108
|
-
from_address: str,
|
|
109
|
-
spender_address: str,
|
|
110
|
-
max_retries: int = 3,
|
|
111
|
-
) -> dict[str, Any]:
|
|
112
|
-
try:
|
|
113
|
-
chain_id = self._chain_id(chain)
|
|
114
|
-
except (TypeError, ValueError) as exc:
|
|
115
|
-
return {"error": str(exc), "allowance": 0}
|
|
116
|
-
|
|
117
|
-
last_error = None
|
|
118
|
-
for attempt in range(max_retries):
|
|
119
|
-
w3 = self.wallet_provider.get_web3(chain_id)
|
|
120
|
-
try:
|
|
121
|
-
contract = w3.eth.contract(
|
|
122
|
-
address=to_checksum_address(token_address), abi=ERC20_APPROVAL_ABI
|
|
123
|
-
)
|
|
124
|
-
allowance = await contract.functions.allowance(
|
|
125
|
-
to_checksum_address(from_address),
|
|
126
|
-
to_checksum_address(spender_address),
|
|
127
|
-
).call()
|
|
128
|
-
return {"allowance": int(allowance)}
|
|
129
|
-
except Exception as exc: # noqa: BLE001
|
|
130
|
-
last_error = exc
|
|
131
|
-
error_str = str(exc)
|
|
132
|
-
if "429" in error_str or "Too Many Requests" in error_str:
|
|
133
|
-
if attempt < max_retries - 1:
|
|
134
|
-
wait_time = 3.0 * (2**attempt) # 3, 6, 12 seconds
|
|
135
|
-
self.logger.warning(
|
|
136
|
-
f"Rate limited reading allowance, retrying in {wait_time}s..."
|
|
137
|
-
)
|
|
138
|
-
await asyncio.sleep(wait_time)
|
|
139
|
-
continue
|
|
140
|
-
self.logger.error(f"Failed to read allowance: {exc}")
|
|
141
|
-
return {"error": f"Allowance query failed: {exc}", "allowance": 0}
|
|
142
|
-
finally:
|
|
143
|
-
await self.wallet_provider._close_web3(w3)
|
|
144
|
-
|
|
145
|
-
self.logger.error(
|
|
146
|
-
f"Failed to read allowance after {max_retries} retries: {last_error}"
|
|
147
|
-
)
|
|
148
|
-
return {
|
|
149
|
-
"error": f"Allowance query failed after retries: {last_error}",
|
|
150
|
-
"allowance": 0,
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
def _chain_id(self, chain: Any) -> int:
|
|
154
|
-
if isinstance(chain, dict):
|
|
155
|
-
chain_id = chain.get("id")
|
|
156
|
-
else:
|
|
157
|
-
chain_id = getattr(chain, "id", None)
|
|
158
|
-
if chain_id is None:
|
|
159
|
-
raise ValueError("Chain ID is required")
|
|
160
|
-
return int(chain_id)
|
|
161
|
-
|
|
162
|
-
def _to_base_units(self, amount: float, decimals: int) -> int:
|
|
163
|
-
"""Convert human-readable amount to base units (wei for native, token units for ERC20)."""
|
|
164
|
-
scale = Decimal(10) ** int(decimals)
|
|
165
|
-
quantized = (Decimal(str(amount)) * scale).to_integral_value(
|
|
166
|
-
rounding=ROUND_DOWN
|
|
167
|
-
)
|
|
168
|
-
return int(quantized)
|
|
169
|
-
|
|
170
|
-
async def build_send_transaction(
|
|
171
|
-
self,
|
|
172
|
-
*,
|
|
173
|
-
from_address: str,
|
|
174
|
-
to_address: str,
|
|
175
|
-
token_address: str | None,
|
|
176
|
-
amount: int,
|
|
177
|
-
chain_id: int,
|
|
178
|
-
is_native: bool,
|
|
179
|
-
) -> dict[str, Any]:
|
|
180
|
-
"""Build the transaction dict for sending native or ERC20 tokens."""
|
|
181
|
-
from_checksum = to_checksum_address(from_address)
|
|
182
|
-
to_checksum = to_checksum_address(to_address)
|
|
183
|
-
chain_id_int = int(chain_id)
|
|
184
|
-
|
|
185
|
-
if is_native:
|
|
186
|
-
return {
|
|
187
|
-
"chainId": chain_id_int,
|
|
188
|
-
"from": from_checksum,
|
|
189
|
-
"to": to_checksum,
|
|
190
|
-
"value": int(amount),
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
token_checksum = to_checksum_address(token_address or ZERO_ADDRESS)
|
|
194
|
-
w3_sync = Web3()
|
|
195
|
-
contract = w3_sync.eth.contract(address=token_checksum, abi=ERC20_ABI)
|
|
196
|
-
data = contract.functions.transfer(
|
|
197
|
-
to_checksum, int(amount)
|
|
198
|
-
)._encode_transaction_data()
|
|
199
|
-
|
|
200
|
-
return {
|
|
201
|
-
"chainId": chain_id_int,
|
|
202
|
-
"from": from_checksum,
|
|
203
|
-
"to": token_checksum,
|
|
204
|
-
"data": data,
|
|
205
|
-
"value": 0,
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
def build_erc20_approval_transaction(
|
|
209
|
-
self,
|
|
210
|
-
*,
|
|
211
|
-
chain_id: int,
|
|
212
|
-
token_address: str,
|
|
213
|
-
from_address: str,
|
|
214
|
-
spender: str,
|
|
215
|
-
amount: int,
|
|
216
|
-
) -> dict[str, Any]:
|
|
217
|
-
"""Build an ERC20 approval transaction dict."""
|
|
218
|
-
token_checksum = to_checksum_address(token_address)
|
|
219
|
-
spender_checksum = to_checksum_address(spender)
|
|
220
|
-
from_checksum = to_checksum_address(from_address)
|
|
221
|
-
amount_int = int(amount)
|
|
222
|
-
|
|
223
|
-
# Use synchronous Web3 for encoding (encodeABI doesn't exist in web3.py v7)
|
|
224
|
-
w3_sync = Web3()
|
|
225
|
-
contract = w3_sync.eth.contract(address=token_checksum, abi=ERC20_APPROVAL_ABI)
|
|
226
|
-
|
|
227
|
-
# In web3.py v7, use _encode_transaction_data to encode without network calls
|
|
228
|
-
data = contract.functions.approve(
|
|
229
|
-
spender_checksum, amount_int
|
|
230
|
-
)._encode_transaction_data()
|
|
231
|
-
|
|
232
|
-
return {
|
|
233
|
-
"chainId": int(chain_id),
|
|
234
|
-
"from": from_checksum,
|
|
235
|
-
"to": token_checksum,
|
|
236
|
-
"data": data,
|
|
237
|
-
"value": 0,
|
|
238
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from wayfinder_paths.core.services.base import EvmTxn, TokenTxn, Web3Service
|
|
4
|
-
from wayfinder_paths.core.services.local_evm_txn import LocalEvmTxn
|
|
5
|
-
from wayfinder_paths.core.services.local_token_txn import (
|
|
6
|
-
LocalTokenTxnService,
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class DefaultWeb3Service(Web3Service):
|
|
11
|
-
"""Default implementation that simply wires the provided dependencies together."""
|
|
12
|
-
|
|
13
|
-
def __init__(
|
|
14
|
-
self,
|
|
15
|
-
config: dict | None = None,
|
|
16
|
-
*,
|
|
17
|
-
wallet_provider: EvmTxn | None = None,
|
|
18
|
-
evm_transactions: TokenTxn | None = None,
|
|
19
|
-
) -> None:
|
|
20
|
-
"""
|
|
21
|
-
Initialize the service with optional dependency injection.
|
|
22
|
-
|
|
23
|
-
Strategies that already constructed wallet providers or transaction helpers
|
|
24
|
-
can pass them in directly. Otherwise we fall back to the legacy behavior of
|
|
25
|
-
building a LocalWalletProvider + DefaultEvmTransactionService from config.
|
|
26
|
-
"""
|
|
27
|
-
cfg = config or {}
|
|
28
|
-
self._wallet_provider = wallet_provider or LocalEvmTxn(cfg)
|
|
29
|
-
if evm_transactions is not None:
|
|
30
|
-
self._evm_transactions = evm_transactions
|
|
31
|
-
else:
|
|
32
|
-
self._evm_transactions = LocalTokenTxnService(
|
|
33
|
-
config=cfg,
|
|
34
|
-
wallet_provider=self._wallet_provider,
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
@property
|
|
38
|
-
def evm_transactions(self) -> EvmTxn:
|
|
39
|
-
return self._wallet_provider
|
|
40
|
-
|
|
41
|
-
@property
|
|
42
|
-
def token_transactions(self) -> TokenTxn:
|
|
43
|
-
return self._evm_transactions
|