solver-multirpc 3.1.4__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.
- solver_multirpc-3.1.4.dist-info/METADATA +241 -0
- solver_multirpc-3.1.4.dist-info/RECORD +19 -0
- solver_multirpc-3.1.4.dist-info/WHEEL +4 -0
- src/__init__.py +0 -0
- src/multirpc/__init__.py +1 -0
- src/multirpc/async_multi_rpc_interface.py +125 -0
- src/multirpc/base_multi_rpc_interface.py +640 -0
- src/multirpc/constants.py +52 -0
- src/multirpc/exceptions.py +77 -0
- src/multirpc/gas_estimation.py +151 -0
- src/multirpc/sync_multi_rpc_interface.py +135 -0
- src/multirpc/tx_trace.py +153 -0
- src/multirpc/utils.py +265 -0
- src/tests/__init__.py +0 -0
- src/tests/abi.json +34 -0
- src/tests/constants.py +78 -0
- src/tests/contract.sol +16 -0
- src/tests/test.py +138 -0
- src/tests/test_settings.py.example +7 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from abc import ABC
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from time import sleep
|
|
7
|
+
from typing import Callable, Coroutine, Dict, List, Optional, Tuple, TypeVar, Union
|
|
8
|
+
|
|
9
|
+
import web3
|
|
10
|
+
from eth_account import Account
|
|
11
|
+
from eth_account.datastructures import SignedTransaction
|
|
12
|
+
from eth_account.signers.local import LocalAccount
|
|
13
|
+
from eth_typing import Address, ChecksumAddress, HexStr
|
|
14
|
+
from multicallable.async_multicallable import AsyncCall, AsyncMulticall
|
|
15
|
+
from requests import ConnectionError, HTTPError, ReadTimeout
|
|
16
|
+
from web3 import AsyncWeb3, Web3
|
|
17
|
+
from web3._utils.contracts import encode_transaction_data # noqa
|
|
18
|
+
from web3.contract import Contract
|
|
19
|
+
from web3.exceptions import BadResponseFormat, BlockNotFound, TimeExhausted, TransactionNotFound
|
|
20
|
+
from web3.types import BlockData, BlockIdentifier, TxReceipt
|
|
21
|
+
|
|
22
|
+
from .constants import EstimateGasLimitBuffer, GasLimit, GasUpperBound, MultiRPCLogger, ViewPolicy
|
|
23
|
+
from .exceptions import (DontHaveThisRpcType, FailedOnAllRPCs, GetBlockFailed, NotValidViewPolicy,
|
|
24
|
+
TransactionFailedStatus, TransactionValueError, Web3InterfaceException)
|
|
25
|
+
from .gas_estimation import GasEstimation, GasEstimationMethod
|
|
26
|
+
from .tx_trace import TxTrace
|
|
27
|
+
from .utils import NestedDict, ResultEvent, TxPriority, calculate_chain_id, create_web3_from_rpc, \
|
|
28
|
+
get_span_proper_label_from_provider, get_unix_time, reduce_list_of_list
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BaseMultiRpc(ABC):
|
|
34
|
+
"""
|
|
35
|
+
This class is used to be more sure when running web3 view calls and sending transactions by using of multiple RPCs.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
rpc_urls: NestedDict,
|
|
41
|
+
contract_address: Union[Address, ChecksumAddress, str],
|
|
42
|
+
contract_abi: Dict,
|
|
43
|
+
rpcs_supporting_tx_trace: Optional[List[str]] = None,
|
|
44
|
+
view_policy: ViewPolicy = ViewPolicy.MostUpdated,
|
|
45
|
+
gas_estimation: Optional[GasEstimation] = None,
|
|
46
|
+
gas_limit: int = GasLimit,
|
|
47
|
+
gas_upper_bound: int = GasUpperBound,
|
|
48
|
+
apm=None,
|
|
49
|
+
enable_estimate_gas_limit: bool = False,
|
|
50
|
+
is_proof_authority: bool = False,
|
|
51
|
+
multicall_custom_address: str = None,
|
|
52
|
+
log_level: logging = logging.WARN
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Args:
|
|
56
|
+
gas_estimation: gas_estimation is module we use to estimate gas price for current chain
|
|
57
|
+
gas_limit: The gas limit is the maximum amount of gas units that a user is willing to spend on a transaction
|
|
58
|
+
- It limits the total computational work or "effort" that the network will
|
|
59
|
+
put into processing a transaction
|
|
60
|
+
- for same contracts on different chains gas limit can be different
|
|
61
|
+
gas_upper_bound: is upper bound for gasPrice.
|
|
62
|
+
- gasPrice: determines the total cost a user will pay for each unit of gas
|
|
63
|
+
- max_tx_fee = gas_limit * gas_price
|
|
64
|
+
enable_estimate_gas_limit: use web3.estimate_gas() before real tx
|
|
65
|
+
- for checking if tx can be executed successfully without paying tx fee
|
|
66
|
+
- also this(estimate_gas) function return gas limit that it will be used for tx.
|
|
67
|
+
"""
|
|
68
|
+
self.rpc_urls = rpc_urls
|
|
69
|
+
|
|
70
|
+
self.gas_estimation = gas_estimation
|
|
71
|
+
|
|
72
|
+
self.contract_address = Web3.to_checksum_address(contract_address)
|
|
73
|
+
self.contract_abi = contract_abi
|
|
74
|
+
self.rpcs_supporting_tx_trace = [] if rpcs_supporting_tx_trace is None else rpcs_supporting_tx_trace
|
|
75
|
+
self.apm = apm
|
|
76
|
+
|
|
77
|
+
self.contracts: NestedDict = NestedDict({'transaction': None, 'view': None})
|
|
78
|
+
self.multi_calls: NestedDict = NestedDict({'transaction': None, 'view': None})
|
|
79
|
+
|
|
80
|
+
self.functions = type("functions", (object,), {})()
|
|
81
|
+
|
|
82
|
+
self.view_policy = view_policy
|
|
83
|
+
self.gas_limit = gas_limit
|
|
84
|
+
self.gas_upper_bound = gas_upper_bound
|
|
85
|
+
self.enable_estimate_gas_limit = enable_estimate_gas_limit
|
|
86
|
+
self.is_proof_authority = is_proof_authority
|
|
87
|
+
self.multicall_custom_address = multicall_custom_address
|
|
88
|
+
self.max_gas_limit = None
|
|
89
|
+
self.providers = None
|
|
90
|
+
self.address = None
|
|
91
|
+
self.private_key = None
|
|
92
|
+
self.chain_id = None
|
|
93
|
+
|
|
94
|
+
MultiRPCLogger.setLevel(log_level)
|
|
95
|
+
|
|
96
|
+
def _logger_params(self, **kwargs) -> None:
|
|
97
|
+
if self.apm:
|
|
98
|
+
self.apm.span_label(**kwargs)
|
|
99
|
+
else:
|
|
100
|
+
MultiRPCLogger.info(f'params={kwargs}')
|
|
101
|
+
|
|
102
|
+
def set_account(self, address: Union[ChecksumAddress, str], private_key: str) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Set public key and private key for sending transactions. If these values set, there is no need to pass address,
|
|
105
|
+
private_key in "call" function.
|
|
106
|
+
Args:
|
|
107
|
+
address: sender public_key
|
|
108
|
+
private_key: sender private key
|
|
109
|
+
"""
|
|
110
|
+
self.address = Web3.to_checksum_address(address)
|
|
111
|
+
self.private_key = private_key
|
|
112
|
+
|
|
113
|
+
async def setup(self) -> None:
|
|
114
|
+
self.providers = await create_web3_from_rpc(self.rpc_urls, self.is_proof_authority)
|
|
115
|
+
self.chain_id = await calculate_chain_id(self.providers)
|
|
116
|
+
|
|
117
|
+
if self.gas_estimation is None and self.providers.get('transaction'):
|
|
118
|
+
self.gas_estimation = GasEstimation(
|
|
119
|
+
self.chain_id,
|
|
120
|
+
reduce_list_of_list(self.providers['transaction'].values()),
|
|
121
|
+
# GasEstimationMethod.RPC,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
is_rpc_provided = False
|
|
125
|
+
for wb3_k, wb3_v in self.providers.items(): # type: Tuple, List[web3.AsyncWeb3]
|
|
126
|
+
multi_calls = []
|
|
127
|
+
contracts = []
|
|
128
|
+
for wb3 in wb3_v:
|
|
129
|
+
rpc_url = wb3.provider.endpoint_uri
|
|
130
|
+
try:
|
|
131
|
+
mc = AsyncMulticall()
|
|
132
|
+
await mc.setup(w3=wb3, custom_address=self.multicall_custom_address)
|
|
133
|
+
multi_calls.append(mc)
|
|
134
|
+
contracts.append(
|
|
135
|
+
wb3.eth.contract(self.contract_address, abi=self.contract_abi)
|
|
136
|
+
)
|
|
137
|
+
except (ConnectionError, ReadTimeout, asyncio.TimeoutError) as e:
|
|
138
|
+
# fixme: at least we should retry not ignoring rpc
|
|
139
|
+
MultiRPCLogger.warning(f"Ignore rpc {rpc_url} because of {e}")
|
|
140
|
+
if len(multi_calls) != 0 and len(contracts) != 0:
|
|
141
|
+
is_rpc_provided = True
|
|
142
|
+
|
|
143
|
+
self.multi_calls[wb3_k] = multi_calls
|
|
144
|
+
self.contracts[wb3_k] = contracts
|
|
145
|
+
|
|
146
|
+
if not is_rpc_provided:
|
|
147
|
+
raise ValueError("No available rpc provided")
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
async def __gather_tasks(execution_list: List[Coroutine], result_selector: Callable[[List], any],
|
|
151
|
+
view_policy: ViewPolicy = ViewPolicy.MostUpdated):
|
|
152
|
+
"""
|
|
153
|
+
Get an execution list and wait for all to end. If all executable raise an exception, it will raise a
|
|
154
|
+
'Web3InterfaceException' exception, otherwise returns all results which has no exception
|
|
155
|
+
Args:
|
|
156
|
+
execution_list:
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def wrap_coroutine(coro: Coroutine):
|
|
163
|
+
def sync_wrapper():
|
|
164
|
+
try:
|
|
165
|
+
res = asyncio.run(coro)
|
|
166
|
+
return res, None
|
|
167
|
+
except Exception as e:
|
|
168
|
+
return None, e
|
|
169
|
+
|
|
170
|
+
return sync_wrapper
|
|
171
|
+
|
|
172
|
+
if view_policy == view_policy.MostUpdated: # wait for all task to be completed
|
|
173
|
+
results = []
|
|
174
|
+
exceptions = []
|
|
175
|
+
with ThreadPoolExecutor() as executor:
|
|
176
|
+
wrapped_coroutines = [wrap_coroutine(coro) for coro in execution_list]
|
|
177
|
+
for result, exception in executor.map(lambda f: f(), wrapped_coroutines):
|
|
178
|
+
if exception:
|
|
179
|
+
exceptions.append(exception)
|
|
180
|
+
else:
|
|
181
|
+
results.append(result)
|
|
182
|
+
|
|
183
|
+
if len(results) == 0:
|
|
184
|
+
for exc in exceptions:
|
|
185
|
+
MultiRPCLogger.exception(f"RAISED EXCEPTION: {exc}")
|
|
186
|
+
raise FailedOnAllRPCs(f"All of RPCs raise exception. first exception: {exceptions[0]}")
|
|
187
|
+
return result_selector(results)
|
|
188
|
+
elif view_policy == view_policy.FirstSuccess: # wait to at least 1 task completed
|
|
189
|
+
return result_selector([await BaseMultiRpc.__execute_batch_tasks(
|
|
190
|
+
execution_list,
|
|
191
|
+
[HTTPError, ConnectionError, ValueError],
|
|
192
|
+
FailedOnAllRPCs
|
|
193
|
+
)])
|
|
194
|
+
|
|
195
|
+
raise NotValidViewPolicy()
|
|
196
|
+
|
|
197
|
+
async def _call_view_function(self,
|
|
198
|
+
func_name: str,
|
|
199
|
+
block_identifier: Union[str, int] = 'latest',
|
|
200
|
+
use_multicall=False,
|
|
201
|
+
*args, **kwargs):
|
|
202
|
+
"""
|
|
203
|
+
Calling view function 'func_name' by using of multicall
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
func_name: view function name
|
|
207
|
+
*args:
|
|
208
|
+
**kwargs:
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
the results of multicallable object for each rpc
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def max_block_finder(results: List):
|
|
215
|
+
max_block_number = results[0][0]
|
|
216
|
+
max_index = 0
|
|
217
|
+
for i, result in enumerate(results):
|
|
218
|
+
if result[0] > max_block_number:
|
|
219
|
+
max_block_number = result[0]
|
|
220
|
+
max_index = i
|
|
221
|
+
if use_multicall:
|
|
222
|
+
return results[max_index][2]
|
|
223
|
+
return results[max_index][2][0]
|
|
224
|
+
|
|
225
|
+
last_error = None
|
|
226
|
+
for contracts, multi_calls in zip(self.contracts['view'].values(),
|
|
227
|
+
self.multi_calls['view'].values()): # type: any, List[AsyncMulticall]
|
|
228
|
+
rpc_bracket = list(map(lambda c: c.w3.provider.endpoint_uri, contracts))
|
|
229
|
+
|
|
230
|
+
if use_multicall:
|
|
231
|
+
calls = [[AsyncCall(cont, func_name, arg) for arg in args[0]] for cont in contracts]
|
|
232
|
+
else:
|
|
233
|
+
calls = [[AsyncCall(cont, func_name, args, kwargs)] for cont in contracts]
|
|
234
|
+
execution_list = [mc.call(call, block_identifier=block_identifier) for mc, call in zip(multi_calls, calls)]
|
|
235
|
+
try:
|
|
236
|
+
return await self.__gather_tasks(execution_list, max_block_finder, view_policy=self.view_policy)
|
|
237
|
+
except (Web3InterfaceException, asyncio.TimeoutError) as e:
|
|
238
|
+
last_error = e
|
|
239
|
+
MultiRPCLogger.warning(f"Can't call view function from this list of rpc({rpc_bracket}), error: {e}")
|
|
240
|
+
raise Web3InterfaceException(f"All of RPCs raise exception. {last_error=}")
|
|
241
|
+
|
|
242
|
+
async def _get_nonce(self, address: Union[Address, ChecksumAddress, str],
|
|
243
|
+
block_identifier: Optional[BlockIdentifier] = None) -> int:
|
|
244
|
+
address = Web3.to_checksum_address(address)
|
|
245
|
+
providers_4_nonce = self.providers.get('view') or self.providers['transaction']
|
|
246
|
+
last_error = None
|
|
247
|
+
for providers in providers_4_nonce.values():
|
|
248
|
+
execution_list = [
|
|
249
|
+
prov.eth.get_transaction_count(address, block_identifier=block_identifier) for prov in providers
|
|
250
|
+
]
|
|
251
|
+
try:
|
|
252
|
+
return await self.__gather_tasks(execution_list, max)
|
|
253
|
+
except (Web3InterfaceException, asyncio.TimeoutError) as e:
|
|
254
|
+
last_error = e
|
|
255
|
+
MultiRPCLogger.warning(f"get_nounce: {e}")
|
|
256
|
+
pass
|
|
257
|
+
raise Web3InterfaceException(f"All of RPCs raise exception. {last_error=}")
|
|
258
|
+
|
|
259
|
+
async def _get_tx_params(
|
|
260
|
+
self, nonce: int, address: str, gas_limit: int, gas_upper_bound: int, priority: TxPriority,
|
|
261
|
+
gas_estimation_method: GasEstimationMethod) -> Dict:
|
|
262
|
+
gas_params = await self.gas_estimation.get_gas_price(gas_upper_bound, priority, gas_estimation_method)
|
|
263
|
+
|
|
264
|
+
# max transaction fee = gas_limit * gas_price
|
|
265
|
+
tx_params = {
|
|
266
|
+
"from": address,
|
|
267
|
+
"nonce": nonce,
|
|
268
|
+
"gas": gas_limit or self.gas_limit, # gas is gas_limit
|
|
269
|
+
"chainId": self.chain_id,
|
|
270
|
+
}
|
|
271
|
+
tx_params.update(gas_params)
|
|
272
|
+
return tx_params
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
async def _build_transaction(contract: Contract, func_name: str, func_args: Tuple,
|
|
276
|
+
func_kwargs: Dict, tx_params: Dict):
|
|
277
|
+
func_args = func_args or []
|
|
278
|
+
func_kwargs = func_kwargs or {}
|
|
279
|
+
return await contract.functions.__getattribute__(func_name)(*func_args, **func_kwargs
|
|
280
|
+
).build_transaction(tx_params)
|
|
281
|
+
|
|
282
|
+
async def _build_and_sign_transaction(
|
|
283
|
+
self,
|
|
284
|
+
contract: Contract,
|
|
285
|
+
provider: AsyncWeb3,
|
|
286
|
+
func_name: str,
|
|
287
|
+
func_args: Tuple,
|
|
288
|
+
func_kwargs: Dict,
|
|
289
|
+
signer_private_key: str,
|
|
290
|
+
tx_params: Dict,
|
|
291
|
+
enable_estimate_gas_limit: bool
|
|
292
|
+
) -> SignedTransaction:
|
|
293
|
+
try:
|
|
294
|
+
tx = await self._build_transaction(contract, func_name, func_args, func_kwargs, tx_params)
|
|
295
|
+
account: LocalAccount = Account.from_key(signer_private_key)
|
|
296
|
+
if enable_estimate_gas_limit:
|
|
297
|
+
del tx['gas']
|
|
298
|
+
estimate_gas = await provider.eth.estimate_gas(tx)
|
|
299
|
+
MultiRPCLogger.info(f"gas_estimation({estimate_gas} gas needed) is successful")
|
|
300
|
+
return account.sign_transaction({**tx, 'gas': int(estimate_gas * EstimateGasLimitBuffer)})
|
|
301
|
+
return account.sign_transaction(tx)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
MultiRPCLogger.error("exception in build and sign transaction: %s, %s", e.__class__.__name__, str(e))
|
|
304
|
+
raise
|
|
305
|
+
|
|
306
|
+
async def _send_transaction(self, provider: web3.AsyncWeb3, raw_transaction: any) -> Tuple[AsyncWeb3, any]:
|
|
307
|
+
rpc_url = provider.provider.endpoint_uri
|
|
308
|
+
try:
|
|
309
|
+
rpc_label_prefix = get_span_proper_label_from_provider(rpc_url)
|
|
310
|
+
transaction = await provider.eth.send_raw_transaction(raw_transaction)
|
|
311
|
+
self._logger_params(**{f"{rpc_label_prefix}_post_send_time": get_unix_time()})
|
|
312
|
+
self._logger_params(tx_send_time=int(time.time() * 1000))
|
|
313
|
+
return provider, transaction
|
|
314
|
+
except ValueError as e:
|
|
315
|
+
MultiRPCLogger.error(f"RPC({rpc_url}) value error: {str(e)}")
|
|
316
|
+
t_bnb_flag = "transaction would cause overdraft" in str(e).lower() and (await provider.eth.chain_id) == 97
|
|
317
|
+
if not (
|
|
318
|
+
t_bnb_flag or
|
|
319
|
+
'nonce too low' in str(e).lower() or
|
|
320
|
+
'already known' in str(e).lower() or
|
|
321
|
+
'transaction underpriced' in str(e).lower() or
|
|
322
|
+
'account suspended' in str(e).lower() or
|
|
323
|
+
'exceeds the configured cap' in str(e).lower() or
|
|
324
|
+
'no backends available for method' in str(e).lower() or
|
|
325
|
+
'future transaction tries to replace pending' in str(e).lower() or
|
|
326
|
+
'over rate limit' in str(e).lower()
|
|
327
|
+
):
|
|
328
|
+
MultiRPCLogger.exception("_send_transaction_exception")
|
|
329
|
+
raise TransactionValueError
|
|
330
|
+
raise
|
|
331
|
+
except (ConnectionError, ReadTimeout, HTTPError) as e:
|
|
332
|
+
MultiRPCLogger.debug(f"network exception in send transaction: {e.__class__.__name__}, {str(e)}")
|
|
333
|
+
raise
|
|
334
|
+
except Exception as e:
|
|
335
|
+
# FIXME needs better exception handling
|
|
336
|
+
MultiRPCLogger.error(f"exception in send transaction: {e.__class__.__name__}, {str(e)}")
|
|
337
|
+
if self.apm:
|
|
338
|
+
self.apm.capture_exception()
|
|
339
|
+
raise
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _handle_tx_trace(trace: TxTrace, func_name: str, func_args: Tuple, func_kwargs: Dict):
|
|
343
|
+
"""
|
|
344
|
+
You can override this method to customize handling failed transaction.
|
|
345
|
+
|
|
346
|
+
example:
|
|
347
|
+
if "out of gas" in trace.text():
|
|
348
|
+
return InsufficientGasBalance(f'out of gas in {func_name}')
|
|
349
|
+
if "PartyBFacet: Will be liquidatable" in trace.text():
|
|
350
|
+
return PartyBWillBeLiquidatable(f'partyB will be liquidatable in {func_name}')
|
|
351
|
+
if "LibMuon: TSS not verified" in trace.text():
|
|
352
|
+
return TssNotVerified(trace.tx_hash, func_name, func_args, func_kwargs, trace)
|
|
353
|
+
if trace.ok():
|
|
354
|
+
MultiRPCLogger.error(f'TraceTransaction({func_name}): {trace.result().long_error()}')
|
|
355
|
+
apm.capture_message(param_message={
|
|
356
|
+
'message': f'tr failed ({func_name}, {trace.result().first_usable_error()}): %s',
|
|
357
|
+
'params': (trace.text(),),
|
|
358
|
+
})
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
async def _wait_and_get_tx_receipt(self, provider: AsyncWeb3, tx, timeout: float) -> Tuple[AsyncWeb3, TxReceipt]:
|
|
364
|
+
con_err_count = tx_err_count = 0
|
|
365
|
+
rpc_url = provider.provider.endpoint_uri
|
|
366
|
+
while True:
|
|
367
|
+
try:
|
|
368
|
+
self._logger_params(received_provider=rpc_url)
|
|
369
|
+
tx_receipt = await provider.eth.wait_for_transaction_receipt(tx, timeout=timeout)
|
|
370
|
+
return provider, tx_receipt
|
|
371
|
+
except ConnectionError:
|
|
372
|
+
if con_err_count >= 5:
|
|
373
|
+
raise
|
|
374
|
+
con_err_count += 1
|
|
375
|
+
sleep(5)
|
|
376
|
+
except (TimeExhausted, TransactionNotFound):
|
|
377
|
+
if tx_err_count >= 1: # double-check the endpoint_uri
|
|
378
|
+
raise
|
|
379
|
+
tx_err_count += 1
|
|
380
|
+
timeout *= 2
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
async def __get_tx_trace(tx, provider_url, func_name=None, func_args=None, func_kwargs=None):
|
|
384
|
+
tx_hash = Web3.to_hex(tx)
|
|
385
|
+
trace = TxTrace(tx_hash, provider_url)
|
|
386
|
+
BaseMultiRpc._handle_tx_trace(trace, func_name, func_args, func_kwargs)
|
|
387
|
+
return TransactionFailedStatus(tx_hash, func_name, func_args, func_kwargs, trace)
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
async def __execute_batch_tasks(
|
|
391
|
+
execution_list: List[Coroutine],
|
|
392
|
+
ignored_exceptions: Optional[List[type[BaseException]]] = None,
|
|
393
|
+
final_exception: Optional[type[BaseException]] = None
|
|
394
|
+
) -> T:
|
|
395
|
+
"""
|
|
396
|
+
Executes a batch of asynchronous tasks concurrently and returns the result of the first completed task.
|
|
397
|
+
|
|
398
|
+
This function runs multiple coroutines concurrently and waits for the first one to complete successfully.
|
|
399
|
+
If any task raises an exception, it checks whether the exception is in the `exception_handler` list.
|
|
400
|
+
If so, it stores the exception but continues execution. If a terminal exception occurs (not in the
|
|
401
|
+
`exception_handler` list), it cancels all remaining tasks and raises that exception.
|
|
402
|
+
|
|
403
|
+
Parameters:
|
|
404
|
+
execution_list (List[Coroutine[None, None, T]]): A list of coroutine objects to be executed concurrently.
|
|
405
|
+
ignored_exceptions (Optional[List[type[BaseException]]], optional): A list of exception types to be handled
|
|
406
|
+
without terminating all tasks immediately. Exceptions of these types are stored and raised after
|
|
407
|
+
all tasks have been processed. Defaults to None.
|
|
408
|
+
final_exception (Optional[type[BaseException]], optional): An exception type to raise if no tasks complete
|
|
409
|
+
successfully and no terminal exceptions occur. Defaults to None.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
T: The result returned by the first task that completes successfully.
|
|
413
|
+
|
|
414
|
+
Raises:
|
|
415
|
+
BaseException: If a terminal exception occurs in any of the tasks, it is raised immediately.
|
|
416
|
+
BaseException: If all tasks fail with exceptions specified in `exception_handler`, the last exception is raised.
|
|
417
|
+
final_exception: If provided and no tasks complete successfully or raise terminal exceptions, this exception
|
|
418
|
+
is raised.
|
|
419
|
+
RuntimeError: If no tasks complete successfully and no exceptions are raised, a RuntimeError is raised.
|
|
420
|
+
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
async def exec_task(task: Coroutine, cancel_event_: ResultEvent, lock_: asyncio.Lock):
|
|
424
|
+
res = await task
|
|
425
|
+
async with lock_:
|
|
426
|
+
cancel_event_.set_result(res)
|
|
427
|
+
cancel_event_.set()
|
|
428
|
+
|
|
429
|
+
cancel_event = ResultEvent()
|
|
430
|
+
lock = asyncio.Lock()
|
|
431
|
+
|
|
432
|
+
tasks = [
|
|
433
|
+
asyncio.create_task(exec_task(task, cancel_event, lock))
|
|
434
|
+
for task in execution_list
|
|
435
|
+
]
|
|
436
|
+
not_completed_tasks = tasks.copy()
|
|
437
|
+
exception = None
|
|
438
|
+
terminal_exception = None
|
|
439
|
+
|
|
440
|
+
while len(not_completed_tasks) > 0:
|
|
441
|
+
dones, not_completed_tasks = await asyncio.wait(
|
|
442
|
+
not_completed_tasks, return_when=asyncio.FIRST_COMPLETED
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
for task in list(dones):
|
|
446
|
+
e = task.exception()
|
|
447
|
+
if e:
|
|
448
|
+
if ignored_exceptions and isinstance(e, tuple(ignored_exceptions)):
|
|
449
|
+
exception = e
|
|
450
|
+
else:
|
|
451
|
+
terminal_exception = e
|
|
452
|
+
continue
|
|
453
|
+
if cancel_event.is_set():
|
|
454
|
+
break
|
|
455
|
+
|
|
456
|
+
if cancel_event.is_set() or terminal_exception:
|
|
457
|
+
break
|
|
458
|
+
|
|
459
|
+
# Cancel the remaining tasks
|
|
460
|
+
for task in not_completed_tasks:
|
|
461
|
+
if not task.done():
|
|
462
|
+
task.cancel()
|
|
463
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
464
|
+
|
|
465
|
+
if cancel_event.is_set():
|
|
466
|
+
return cancel_event.get_result()
|
|
467
|
+
if terminal_exception or exception:
|
|
468
|
+
raise terminal_exception or exception
|
|
469
|
+
raise final_exception or RuntimeError("Execution completed without setting a result or exception.")
|
|
470
|
+
|
|
471
|
+
async def __call_tx(
|
|
472
|
+
self,
|
|
473
|
+
func_name: str,
|
|
474
|
+
func_args: Tuple,
|
|
475
|
+
func_kwargs: Dict,
|
|
476
|
+
private_key: str,
|
|
477
|
+
wait_for_receipt: int,
|
|
478
|
+
providers: List[AsyncWeb3],
|
|
479
|
+
contracts: List[Contract],
|
|
480
|
+
tx_params: Dict,
|
|
481
|
+
enable_estimate_gas_limit: bool,
|
|
482
|
+
) -> Union[str, TxReceipt]:
|
|
483
|
+
signed_transaction = await self._build_and_sign_transaction(
|
|
484
|
+
contracts[0], providers[0], func_name, func_args, func_kwargs, private_key, tx_params,
|
|
485
|
+
enable_estimate_gas_limit
|
|
486
|
+
)
|
|
487
|
+
tx_hash = Web3.to_hex(signed_transaction.hash)
|
|
488
|
+
self._logger_params(tx_hash=tx_hash)
|
|
489
|
+
|
|
490
|
+
execution_tx_list = [
|
|
491
|
+
self._send_transaction(p, signed_transaction.raw_transaction) for p in providers
|
|
492
|
+
]
|
|
493
|
+
result = await self.__execute_batch_tasks(
|
|
494
|
+
execution_tx_list,
|
|
495
|
+
[ValueError, ConnectionError, ReadTimeout, HTTPError],
|
|
496
|
+
FailedOnAllRPCs
|
|
497
|
+
)
|
|
498
|
+
provider, tx = result
|
|
499
|
+
|
|
500
|
+
MultiRPCLogger.info(f"success tx: {provider= }, {tx= }")
|
|
501
|
+
rpc_url = provider.provider.endpoint_uri
|
|
502
|
+
self._logger_params(sent_provider=rpc_url, tx_send_time=int(time.time()) * 1000)
|
|
503
|
+
|
|
504
|
+
if not wait_for_receipt:
|
|
505
|
+
return tx_hash
|
|
506
|
+
execution_receipt_list = [
|
|
507
|
+
self._wait_and_get_tx_receipt(p, tx_hash, wait_for_receipt) for p in providers
|
|
508
|
+
]
|
|
509
|
+
provider, tx_receipt = await self.__execute_batch_tasks(
|
|
510
|
+
execution_receipt_list,
|
|
511
|
+
[TimeExhausted, TransactionNotFound, ConnectionError, ReadTimeout,
|
|
512
|
+
ValueError, BadResponseFormat, HTTPError],
|
|
513
|
+
)
|
|
514
|
+
if tx_receipt.status == 1:
|
|
515
|
+
return tx_receipt
|
|
516
|
+
|
|
517
|
+
# get tx_trace In case transaction failed
|
|
518
|
+
execution_trace_list = [
|
|
519
|
+
self.__get_tx_trace(tx, p.provider.endpoint_uri, func_name, func_args, func_kwargs) for p in providers
|
|
520
|
+
if p.provider.endpoint_uri in self.rpcs_supporting_tx_trace
|
|
521
|
+
]
|
|
522
|
+
if not execution_trace_list:
|
|
523
|
+
raise TransactionFailedStatus(tx_hash, func_name, func_args, func_kwargs)
|
|
524
|
+
|
|
525
|
+
raise await self.__execute_batch_tasks(
|
|
526
|
+
execution_trace_list,
|
|
527
|
+
[HTTPError, ConnectionError, ReadTimeout, BadResponseFormat],
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
async def _call_tx_function(self, address: str, gas_limit: int, gas_upper_bound: int, priority: TxPriority,
|
|
531
|
+
gas_estimation_method: GasEstimationMethod,
|
|
532
|
+
enable_estimate_gas_limit: Optional[bool] = None, **kwargs):
|
|
533
|
+
nonce = await self._get_nonce(address)
|
|
534
|
+
tx_params = await self._get_tx_params(
|
|
535
|
+
nonce, address, gas_limit, gas_upper_bound, priority, gas_estimation_method
|
|
536
|
+
)
|
|
537
|
+
last_error = None
|
|
538
|
+
enable_estimate_gas_limit = self.enable_estimate_gas_limit if enable_estimate_gas_limit is None \
|
|
539
|
+
else enable_estimate_gas_limit
|
|
540
|
+
|
|
541
|
+
for p, c in zip(
|
|
542
|
+
self.providers['transaction'].values(), self.contracts['transaction'].values()
|
|
543
|
+
): # type: List[AsyncWeb3], List[Contract]
|
|
544
|
+
try:
|
|
545
|
+
return await self.__call_tx(**kwargs, providers=p, contracts=c, tx_params=tx_params,
|
|
546
|
+
enable_estimate_gas_limit=enable_estimate_gas_limit)
|
|
547
|
+
except (TransactionFailedStatus, TransactionValueError):
|
|
548
|
+
raise
|
|
549
|
+
except (ConnectionError, ReadTimeout, TimeExhausted, TransactionNotFound, FailedOnAllRPCs) as e:
|
|
550
|
+
last_error = e
|
|
551
|
+
except Exception:
|
|
552
|
+
raise
|
|
553
|
+
raise Web3InterfaceException(f"All of RPCs raise exception. {last_error=}")
|
|
554
|
+
|
|
555
|
+
def check_for_view(self):
|
|
556
|
+
if self.providers.get('view') is None:
|
|
557
|
+
raise DontHaveThisRpcType(f"Doesn't have view RPCs")
|
|
558
|
+
|
|
559
|
+
async def get_tx_receipt(self, tx_hash) -> TxReceipt:
|
|
560
|
+
self.check_for_view()
|
|
561
|
+
|
|
562
|
+
exceptions = (HTTPError, ConnectionError, ReadTimeout, ValueError, TimeExhausted, TransactionNotFound)
|
|
563
|
+
|
|
564
|
+
last_exception = None
|
|
565
|
+
for provider in self.providers['view'].values(): # type: List[AsyncWeb3]
|
|
566
|
+
execution_tx_list = [p.eth.wait_for_transaction_receipt(tx_hash) for p in provider]
|
|
567
|
+
try:
|
|
568
|
+
return await self.__execute_batch_tasks(
|
|
569
|
+
execution_tx_list,
|
|
570
|
+
list(exceptions),
|
|
571
|
+
TransactionFailedStatus
|
|
572
|
+
)
|
|
573
|
+
except exceptions as e:
|
|
574
|
+
last_exception = e
|
|
575
|
+
pass
|
|
576
|
+
except TransactionFailedStatus:
|
|
577
|
+
raise
|
|
578
|
+
raise last_exception
|
|
579
|
+
|
|
580
|
+
async def get_block(self, block_identifier: BlockIdentifier = 'latest',
|
|
581
|
+
full_transactions: bool = False) -> BlockData:
|
|
582
|
+
self.check_for_view()
|
|
583
|
+
|
|
584
|
+
exceptions = (HTTPError, ConnectionError, ReadTimeout, ValueError, TimeExhausted, BlockNotFound)
|
|
585
|
+
last_exception = None
|
|
586
|
+
for provider in self.providers['view'].values(): # type: List[AsyncWeb3]
|
|
587
|
+
execution_tx_params_list = [p.eth.get_block(block_identifier, full_transactions) for p in provider]
|
|
588
|
+
try:
|
|
589
|
+
return await self.__execute_batch_tasks(
|
|
590
|
+
execution_tx_params_list,
|
|
591
|
+
list(exceptions),
|
|
592
|
+
GetBlockFailed
|
|
593
|
+
)
|
|
594
|
+
except exceptions as e:
|
|
595
|
+
last_exception = e
|
|
596
|
+
pass
|
|
597
|
+
except GetBlockFailed:
|
|
598
|
+
raise
|
|
599
|
+
raise last_exception
|
|
600
|
+
|
|
601
|
+
async def get_block_number(self) -> int:
|
|
602
|
+
self.check_for_view()
|
|
603
|
+
|
|
604
|
+
exceptions = (HTTPError, ConnectionError, ReadTimeout, ValueError, TimeExhausted)
|
|
605
|
+
last_exception = None
|
|
606
|
+
for provider in self.providers['view'].values(): # type: List[AsyncWeb3]
|
|
607
|
+
execution_tx_params_list = [p.eth.get_block_number() for p in provider]
|
|
608
|
+
try:
|
|
609
|
+
result = await self.__execute_batch_tasks(
|
|
610
|
+
execution_tx_params_list,
|
|
611
|
+
list(exceptions),
|
|
612
|
+
GetBlockFailed
|
|
613
|
+
)
|
|
614
|
+
return result
|
|
615
|
+
except exceptions as e:
|
|
616
|
+
last_exception = e
|
|
617
|
+
pass
|
|
618
|
+
except GetBlockFailed:
|
|
619
|
+
raise
|
|
620
|
+
raise last_exception
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
class BaseContractFunction:
|
|
624
|
+
def __init__(self, name: str, abi: Dict, multi_rpc_web3: BaseMultiRpc, typ: str):
|
|
625
|
+
self.name = name
|
|
626
|
+
self.mr = multi_rpc_web3
|
|
627
|
+
self.typ = typ
|
|
628
|
+
self.abi = abi
|
|
629
|
+
self.args = None
|
|
630
|
+
self.kwargs = None
|
|
631
|
+
|
|
632
|
+
def get_encoded_data(self) -> HexStr:
|
|
633
|
+
return encode_transaction_data(
|
|
634
|
+
reduce_list_of_list(self.mr.providers['transaction'].values())[0],
|
|
635
|
+
self.name,
|
|
636
|
+
self.mr.contract_abi,
|
|
637
|
+
self.abi,
|
|
638
|
+
self.args,
|
|
639
|
+
self.kwargs,
|
|
640
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
DEFAULT_API_PROVIDER = 'https://gas-api.metaswap.codefi.network/networks/{chain_id}/suggestedGasFees'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ViewPolicy(enum.Enum):
|
|
8
|
+
FirstSuccess = 0
|
|
9
|
+
MostUpdated = 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GasEstimationMethod(enum.Enum):
|
|
13
|
+
GAS_API_PROVIDER = 0
|
|
14
|
+
RPC = 1
|
|
15
|
+
FIXED = 2
|
|
16
|
+
CUSTOM = 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
GasLimit = 1_000_000
|
|
20
|
+
GasUpperBound = 50_000
|
|
21
|
+
|
|
22
|
+
GasMultiplierLow = 1.1
|
|
23
|
+
GasMultiplierMedium = 1.3
|
|
24
|
+
GasMultiplierHigh = 1.5
|
|
25
|
+
|
|
26
|
+
MaxRPCInEachBracket = 3
|
|
27
|
+
|
|
28
|
+
# config
|
|
29
|
+
# It must be greater than 1, so we have a safe margin to ensure the transaction can be successful.
|
|
30
|
+
EstimateGasLimitBuffer = 1.1
|
|
31
|
+
ChainIdToGas = {
|
|
32
|
+
97: 10.1, # Test BNB Network
|
|
33
|
+
250: 20, # Ftm
|
|
34
|
+
5000: 0.02 # Mantle
|
|
35
|
+
}
|
|
36
|
+
GasFromRpcChainIds = [] # for this chain ids use rpc to estimate gas
|
|
37
|
+
FixedValueGas = 30
|
|
38
|
+
|
|
39
|
+
MultiRPCLoggerName = 'Multi-RPC'
|
|
40
|
+
GasEstimationLoggerName = MultiRPCLoggerName + '.Gas-Estimation'
|
|
41
|
+
|
|
42
|
+
console_handler = logging.StreamHandler()
|
|
43
|
+
console_handler.setLevel(logging.INFO)
|
|
44
|
+
|
|
45
|
+
MultiRPCLogger = logging.getLogger(MultiRPCLoggerName)
|
|
46
|
+
GasEstimationLogger = logging.getLogger(GasEstimationLoggerName)
|
|
47
|
+
|
|
48
|
+
MultiRPCLogger.addHandler(console_handler)
|
|
49
|
+
GasEstimationLogger.addHandler(console_handler)
|
|
50
|
+
|
|
51
|
+
RequestTimeout = 30
|
|
52
|
+
DevEnv = True
|