Skip to content

Commit db95ab5

Browse files
committed
initial commit
0 parents  commit db95ab5

File tree

6 files changed

+223
-0
lines changed

6 files changed

+223
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/.idea

IceCreamSwapWeb3/EthAdvanced.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
from time import sleep
2+
3+
from web3.eth import Eth
4+
from web3.exceptions import ContractLogicError
5+
from web3.types import FilterParams, LogReceipt
6+
7+
8+
def exponential_retry(func_name: str = None):
9+
def wrapper(func):
10+
def inner(*args, no_retry=False, **kwargs):
11+
if no_retry:
12+
return func(*args, **kwargs)
13+
14+
retries = 0
15+
while True:
16+
try:
17+
return func(*args, **kwargs)
18+
except Exception as e:
19+
if isinstance(e, ContractLogicError):
20+
raise
21+
if retries == 0:
22+
wait_for = 0
23+
elif retries < 6:
24+
wait_for = 2 ** (retries - 1)
25+
else:
26+
wait_for = 30
27+
print(f"Web3Advanced.eth.{func_name or func.__name__} threw \"{repr(e)}\" on {retries+1}th try, retrying in {wait_for}s")
28+
29+
retries += 1
30+
sleep(wait_for)
31+
return inner
32+
return wrapper
33+
34+
35+
class EthAdvanced(Eth):
36+
# todo: implement multicall
37+
METHODS_TO_RETRY = [
38+
'fee_history', 'call', 'create_access_list', 'estimate_gas',
39+
'get_transaction', 'get_raw_transaction', 'get_raw_transaction_by_block',
40+
'send_transaction', 'send_raw_transaction', 'get_block', 'get_balance',
41+
'get_code', 'get_transaction_count', 'get_transaction_receipt',
42+
'wait_for_transaction_receipt', 'get_storage_at', 'replace_transaction',
43+
'modify_transaction', 'sign', 'sign_transaction', 'sign_typed_data', 'filter',
44+
'get_filter_changes', 'get_filter_logs', 'uninstall_filter', 'submit_hashrate',
45+
'get_work', 'submit_work',
46+
]
47+
48+
PROPERTIES_TO_RETRY = [
49+
'accounts', 'hashrate', 'block_number', 'chain_id', 'coinbase', 'gas_price',
50+
'max_priority_fee', 'mining', 'syncing'
51+
]
52+
53+
FILTER_RANGES_TO_TRY = sorted([
54+
100_000,
55+
50_000,
56+
20_000,
57+
10_000,
58+
5_000,
59+
2_000,
60+
1_000,
61+
500,
62+
200,
63+
100,
64+
50,
65+
20,
66+
10,
67+
5,
68+
2,
69+
1
70+
], reverse=True)
71+
assert FILTER_RANGES_TO_TRY[-1] == 1
72+
73+
def __init__(self, w3):
74+
super().__init__(w3=w3)
75+
76+
self._wrap_methods_with_retry()
77+
78+
self.filter_block_range = self._find_max_filter_range()
79+
80+
def _wrap_methods_with_retry(self):
81+
for method_name in self.METHODS_TO_RETRY:
82+
method = getattr(self, method_name)
83+
setattr(self, method_name, exponential_retry(func_name=method_name)(method))
84+
85+
for prop_name in self.PROPERTIES_TO_RETRY:
86+
prop = getattr(self.__class__, prop_name)
87+
wrapped_prop = property(exponential_retry(func_name=prop_name)(prop.fget))
88+
setattr(self.__class__, prop_name, wrapped_prop)
89+
90+
def get_logs(self, filter_params: FilterParams) -> list[LogReceipt]:
91+
# note: fromBlock and toBlock are both inclusive. e.g. 5 to 6 are 2 blocks
92+
from_block = filter_params["fromBlock"]
93+
to_block = filter_params["toBlock"]
94+
if not isinstance(from_block, int):
95+
from_block = self.get_block(from_block)["number"]
96+
if not isinstance(to_block, int):
97+
to_block = self.get_block(to_block)["number"]
98+
99+
# if we already know that the filter range is too large, split it
100+
if to_block - from_block + 1 > self.filter_block_range:
101+
results = []
102+
for filter_start in range(from_block, to_block + 1, self.filter_block_range):
103+
filter_end = min(filter_start + self.filter_block_range - 1, to_block)
104+
partial_filter = filter_params.copy()
105+
partial_filter["fromBlock"] = filter_start
106+
partial_filter["toBlock"] = filter_end
107+
results += self.get_logs(partial_filter)
108+
return results
109+
110+
# get logs
111+
try:
112+
return self._get_logs(filter_params)
113+
except Exception:
114+
pass
115+
116+
# if directly getting logs did not work, split the filter range and try again
117+
if from_block != to_block:
118+
mid_block = (from_block + to_block) // 2
119+
left_filter = filter_params.copy()
120+
left_filter["toBlock"] = mid_block
121+
right_filter = filter_params.copy()
122+
right_filter["fromBlock"] = mid_block + 1
123+
124+
results = []
125+
results += self.get_logs(left_filter)
126+
results += self.get_logs(right_filter)
127+
return results
128+
129+
# filter is trying to get a single block, retrying till it works
130+
assert from_block == to_block
131+
return exponential_retry(func_name="get_logs")(self._get_logs)(filter_params)
132+
133+
def _find_max_filter_range(self):
134+
current_block = self.block_number
135+
for filter_range in self.FILTER_RANGES_TO_TRY:
136+
try:
137+
# getting logs from the 0 address as it does not emit any logs.
138+
# This way we can test the maximum allowed filter range without getting back a ton of logs
139+
result = self._get_logs({
140+
"address": "0x0000000000000000000000000000000000000000",
141+
"fromBlock": current_block - 5 - filter_range + 1,
142+
"toBlock": current_block - 5,
143+
})
144+
assert result == []
145+
return filter_range
146+
except Exception:
147+
pass
148+
raise ValueError("Unable to use eth_getLogs")

IceCreamSwapWeb3/Web3Advanced.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from web3 import Web3, JSONBaseProvider
2+
from web3.main import get_default_modules
3+
from web3.middleware import geth_poa_middleware
4+
5+
from EthAdvanced import EthAdvanced
6+
7+
8+
class Web3Advanced(Web3):
9+
eth: EthAdvanced
10+
11+
def __init__(
12+
self,
13+
node_url: str,
14+
):
15+
provider = self._construct_provider(node_url=node_url)
16+
17+
# use the EthAdvanced class instead of the Eth class for w3.eth
18+
modules = get_default_modules()
19+
modules["eth"] = EthAdvanced
20+
21+
super().__init__(provider=provider, modules=modules)
22+
23+
self.middleware_onion.inject(geth_poa_middleware, layer=0, name="poa") # required for pos chains
24+
self._chain_id = self.eth.chain_id # avoids many RPC calls to get chain ID
25+
26+
@staticmethod
27+
def _construct_provider(node_url) -> JSONBaseProvider:
28+
assert "://" in node_url
29+
protocol = node_url.split("://")[0]
30+
if protocol in ("https", "http"):
31+
return Web3.HTTPProvider(node_url)
32+
elif protocol in ("ws", "wss"):
33+
return Web3.WebsocketProvider(node_url)
34+
else:
35+
raise ValueError(f"Unknown protocol for RPC URL {node_url}")

IceCreamSwapWeb3/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .Web3Advanced import Web3Advanced

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# IceCreamSwap Python web3 package
2+
3+
## Installation
4+
5+
```
6+
pip install git+https://github.com/IceCreamSwapCom/Web3Python
7+
```

setup.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from setuptools import setup, find_packages
2+
3+
VERSION = '0.0.1'
4+
DESCRIPTION = 'IceCreamSwap Web3.py wrapper'
5+
LONG_DESCRIPTION = 'IceCreamSwap Web3.py wrapper with automatic retries and advanced functionality'
6+
7+
requirements = [
8+
'web3',
9+
]
10+
11+
# Setting up
12+
setup(
13+
name="IceCreamSwapWeb3",
14+
version=VERSION,
15+
author="IceCreamSwap",
16+
author_email="",
17+
description=DESCRIPTION,
18+
long_description=LONG_DESCRIPTION,
19+
packages=find_packages(),
20+
install_requires=requirements,
21+
# needs to be installed along with your package.
22+
23+
keywords=['python', 'IceCreamSwapWeb3'],
24+
classifiers=[
25+
"Development Status :: 3 - Alpha",
26+
"Intended Audience :: Education",
27+
"Programming Language :: Python :: 3",
28+
"Operating System :: MacOS :: MacOS X",
29+
"Operating System :: Microsoft :: Windows",
30+
]
31+
)

0 commit comments

Comments
 (0)