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