eth-portfolio 1.1.0__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 eth-portfolio might be problematic. Click here for more details.
- eth_portfolio/__init__.py +16 -0
- eth_portfolio/_argspec.py +42 -0
- eth_portfolio/_cache.py +116 -0
- eth_portfolio/_config.py +3 -0
- eth_portfolio/_db/__init__.py +0 -0
- eth_portfolio/_db/decorators.py +147 -0
- eth_portfolio/_db/entities.py +204 -0
- eth_portfolio/_db/utils.py +595 -0
- eth_portfolio/_decimal.py +122 -0
- eth_portfolio/_decorators.py +71 -0
- eth_portfolio/_exceptions.py +67 -0
- eth_portfolio/_ledgers/__init__.py +0 -0
- eth_portfolio/_ledgers/address.py +892 -0
- eth_portfolio/_ledgers/portfolio.py +327 -0
- eth_portfolio/_loaders/__init__.py +33 -0
- eth_portfolio/_loaders/balances.py +78 -0
- eth_portfolio/_loaders/token_transfer.py +214 -0
- eth_portfolio/_loaders/transaction.py +379 -0
- eth_portfolio/_loaders/utils.py +59 -0
- eth_portfolio/_shitcoins.py +212 -0
- eth_portfolio/_utils.py +286 -0
- eth_portfolio/_ydb/__init__.py +0 -0
- eth_portfolio/_ydb/token_transfers.py +136 -0
- eth_portfolio/address.py +382 -0
- eth_portfolio/buckets.py +181 -0
- eth_portfolio/constants.py +58 -0
- eth_portfolio/portfolio.py +629 -0
- eth_portfolio/protocols/__init__.py +66 -0
- eth_portfolio/protocols/_base.py +107 -0
- eth_portfolio/protocols/convex.py +17 -0
- eth_portfolio/protocols/dsr.py +31 -0
- eth_portfolio/protocols/lending/__init__.py +49 -0
- eth_portfolio/protocols/lending/_base.py +57 -0
- eth_portfolio/protocols/lending/compound.py +185 -0
- eth_portfolio/protocols/lending/liquity.py +110 -0
- eth_portfolio/protocols/lending/maker.py +105 -0
- eth_portfolio/protocols/lending/unit.py +47 -0
- eth_portfolio/protocols/liquity.py +16 -0
- eth_portfolio/py.typed +0 -0
- eth_portfolio/structs/__init__.py +43 -0
- eth_portfolio/structs/modified.py +69 -0
- eth_portfolio/structs/structs.py +637 -0
- eth_portfolio/typing.py +1460 -0
- eth_portfolio-1.1.0.dist-info/METADATA +174 -0
- eth_portfolio-1.1.0.dist-info/RECORD +47 -0
- eth_portfolio-1.1.0.dist-info/WHEEL +5 -0
- eth_portfolio-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import TYPE_CHECKING, AsyncIterator, Dict, Generic, Optional, TypeVar
|
|
3
|
+
|
|
4
|
+
import a_sync
|
|
5
|
+
from pandas import DataFrame, concat # type: ignore
|
|
6
|
+
from y.datatypes import Address, Block
|
|
7
|
+
|
|
8
|
+
from eth_portfolio._decorators import set_end_block_if_none
|
|
9
|
+
from eth_portfolio._ledgers.address import (
|
|
10
|
+
AddressLedgerBase,
|
|
11
|
+
InternalTransfersList,
|
|
12
|
+
TokenTransfersList,
|
|
13
|
+
TransactionsList,
|
|
14
|
+
_LedgerEntryList,
|
|
15
|
+
)
|
|
16
|
+
from eth_portfolio._utils import _AiterMixin
|
|
17
|
+
from eth_portfolio.structs import InternalTransfer, TokenTransfer, Transaction
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from eth_portfolio.portfolio import Portfolio
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PortfolioLedgerBase(a_sync.ASyncGenericBase, _AiterMixin[T], Generic[_LedgerEntryList, T]):
|
|
28
|
+
property_name: str
|
|
29
|
+
object_caches: Dict[Address, AddressLedgerBase[_LedgerEntryList, T]]
|
|
30
|
+
|
|
31
|
+
def __init__(self, portfolio: "Portfolio"): # type: ignore
|
|
32
|
+
assert hasattr(self, "property_name"), "Subclasses must define a property_name"
|
|
33
|
+
self.object_caches = {
|
|
34
|
+
address.address: getattr(address, self.property_name) for address in portfolio
|
|
35
|
+
}
|
|
36
|
+
self.portfolio = portfolio
|
|
37
|
+
super().__init__()
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def _start_block(self) -> int:
|
|
41
|
+
"""Returns the start block for analysis of this portfolio."""
|
|
42
|
+
return self.portfolio._start_block
|
|
43
|
+
|
|
44
|
+
def _get_and_yield(self, start_block: int, end_block: int) -> AsyncIterator[T]:
|
|
45
|
+
"""
|
|
46
|
+
Asynchronously yields ledger entries for each address in the portfolio for the specified block range.
|
|
47
|
+
|
|
48
|
+
This method is crucial for efficient data retrieval across multiple addresses, as it:
|
|
49
|
+
- Utilizes asynchronous iteration to process addresses concurrently.
|
|
50
|
+
- Reduces memory usage by yielding entries one at a time.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
start_block: The starting block for the ledger query.
|
|
54
|
+
end_block: The ending block for the ledger query.
|
|
55
|
+
|
|
56
|
+
Yields:
|
|
57
|
+
Individual ledger entries of type T for each address in the portfolio.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> async for entry in ledger._get_and_yield(start_block=1000000, end_block=1100000):
|
|
61
|
+
... print(entry)
|
|
62
|
+
"""
|
|
63
|
+
aiterators = [
|
|
64
|
+
getattr(address, self.property_name)[start_block:end_block]
|
|
65
|
+
for address in self.portfolio
|
|
66
|
+
]
|
|
67
|
+
return a_sync.as_yielded(*aiterators)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def asynchronous(self) -> bool:
|
|
71
|
+
"""Returns `True` if the portfolio associated with this ledger is asynchronous, `False` if not."""
|
|
72
|
+
return self.portfolio.asynchronous
|
|
73
|
+
|
|
74
|
+
@set_end_block_if_none
|
|
75
|
+
async def get(self, start_block: Block, end_block: Block) -> Dict[Address, _LedgerEntryList]:
|
|
76
|
+
"""
|
|
77
|
+
Fetches ledger entries for all portfolio addresses within the specified block range.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
start_block: The starting block number for the query.
|
|
81
|
+
end_block: The ending block number for the query.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
A dictionary mapping each portfolio address to its corresponding ledger entries within the specified block range.
|
|
85
|
+
|
|
86
|
+
Note:
|
|
87
|
+
The @set_end_block_if_none decorator ensures that if end_block is not provided,
|
|
88
|
+
it defaults to the latest block.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
>>> ledger = PortfolioTransactionsLedger(portfolio=portfolio)
|
|
92
|
+
>>> ledger_entries = await ledger.get(start_block=1000000, end_block=1100000)
|
|
93
|
+
>>> print("\n".join(f"Address {addr}: {len(entries)} entries" for addr, entries in ledger_entries.items()))
|
|
94
|
+
"""
|
|
95
|
+
coros = {
|
|
96
|
+
address: cache.get(start_block, end_block, sync=False)
|
|
97
|
+
for address, cache in self.object_caches.items()
|
|
98
|
+
}
|
|
99
|
+
return await a_sync.gather(coros)
|
|
100
|
+
|
|
101
|
+
@set_end_block_if_none
|
|
102
|
+
async def df(self, start_block: Block, end_block: Block) -> DataFrame:
|
|
103
|
+
"""
|
|
104
|
+
Returns a DataFrame containing all entries for this ledger.
|
|
105
|
+
|
|
106
|
+
This method provides an easy way to access your data in a standardized, clean format
|
|
107
|
+
for further analysis and reporting.
|
|
108
|
+
|
|
109
|
+
NOTE: Subclasses may override this method for type-specific DataFrame processing.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
start_block: The starting block for the query.
|
|
113
|
+
end_block: The ending block for the query.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
A DataFrame containing processed ledger entries.
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
>>> df = await ledger.df(start_block=1000000, end_block=1100000)
|
|
120
|
+
>>> print(df)
|
|
121
|
+
"""
|
|
122
|
+
df = await self._df_base(start_block, end_block)
|
|
123
|
+
if len(df) > 0:
|
|
124
|
+
df = self._cleanup_df(df)
|
|
125
|
+
return df
|
|
126
|
+
|
|
127
|
+
async def sent(
|
|
128
|
+
self, start_block: Optional[Block] = None, end_block: Optional[Block] = None
|
|
129
|
+
) -> AsyncIterator[T]:
|
|
130
|
+
portfolio_addresses = set(self.portfolio.addresses.keys())
|
|
131
|
+
async for obj in self[start_block:end_block]:
|
|
132
|
+
if (
|
|
133
|
+
obj.from_address in portfolio_addresses
|
|
134
|
+
and obj.to_address not in portfolio_addresses
|
|
135
|
+
):
|
|
136
|
+
yield obj
|
|
137
|
+
|
|
138
|
+
async def received(
|
|
139
|
+
self, start_block: Optional[Block] = None, end_block: Optional[Block] = None
|
|
140
|
+
) -> AsyncIterator[T]:
|
|
141
|
+
portfolio_addresses = set(self.portfolio.addresses.keys())
|
|
142
|
+
async for obj in self[start_block:end_block]:
|
|
143
|
+
if (
|
|
144
|
+
obj.to_address in portfolio_addresses
|
|
145
|
+
and obj.from_address not in portfolio_addresses
|
|
146
|
+
):
|
|
147
|
+
yield obj
|
|
148
|
+
|
|
149
|
+
async def _df_base(self, start_block: Block, end_block: Block) -> DataFrame:
|
|
150
|
+
"""
|
|
151
|
+
Fetches and concatenates raw ledger data into a :class:`~DataFrame` for all addresses in the portfolio.
|
|
152
|
+
|
|
153
|
+
This method is a crucial part of the data processing pipeline, as it:
|
|
154
|
+
- Retrieves ledger entries for the specified block range across all portfolio addresses.
|
|
155
|
+
- Combines the data from multiple addresses into a single DataFrame.
|
|
156
|
+
- Serves as the foundation for further data cleaning and analysis.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
start_block: The starting block number for the query.
|
|
160
|
+
end_block: The ending block number for the query.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
DataFrame: A concatenated DataFrame containing raw ledger entries from all addresses.
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
>>> df_base = await ledger._df_base(start_block=1000000, end_block=1100000)
|
|
167
|
+
>>> print(f"Total entries: {len(df_base)}", df_base.head(), sep="\n")
|
|
168
|
+
|
|
169
|
+
Note:
|
|
170
|
+
This method returns raw data that may contain duplicates or require further processing.
|
|
171
|
+
For cleaned and deduplicated data, use the `df()` method instead.
|
|
172
|
+
"""
|
|
173
|
+
data: Dict[Address, _LedgerEntryList] = await self.get(start_block, end_block, sync=False)
|
|
174
|
+
return concat(pandable.df for pandable in data.values())
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def _deduplicate_df(cls, df: DataFrame) -> DataFrame:
|
|
178
|
+
"""
|
|
179
|
+
Deduplicate the DataFrame to prevent double-counting of transfers within the portfolio.
|
|
180
|
+
|
|
181
|
+
This method is crucial for ensuring accurate portfolio analysis by removing duplicate entries
|
|
182
|
+
where transfers between owned addresses appear once in each result set.
|
|
183
|
+
|
|
184
|
+
Note:
|
|
185
|
+
- This method can be overridden in subclasses if needed.
|
|
186
|
+
- If the DataFrame contains columns with list-type values, they are converted to strings for comparison.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
df: The DataFrame to deduplicate.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
A deduplicated version of the input DataFrame.
|
|
193
|
+
|
|
194
|
+
Example:
|
|
195
|
+
>>> original_df = pd.DataFrame(...) # Your original DataFrame
|
|
196
|
+
>>> deduped_df = PortfolioLedgerBase._deduplicate_df(original_df)
|
|
197
|
+
>>> print(f"Original rows: {len(original_df)}, Deduplicated rows: {len(deduped_df)}")
|
|
198
|
+
"""
|
|
199
|
+
return df.loc[df.astype(str).drop_duplicates().index]
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def _cleanup_df(cls, df: DataFrame) -> DataFrame:
|
|
203
|
+
"""
|
|
204
|
+
Cleans up the DataFrame by deduplicating and sorting it by block number.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
df: The DataFrame to clean up.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
A cleaned and deduplicated DataFrame sorted by block number.
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
>>> cleaned_df = ledger._cleanup_df(df)
|
|
214
|
+
>>> print(cleaned_df)
|
|
215
|
+
"""
|
|
216
|
+
df = cls._deduplicate_df(df)
|
|
217
|
+
return df.sort_values(["blockNumber"]).reset_index(drop=True)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class PortfolioTransactionsLedger(PortfolioLedgerBase[TransactionsList, Transaction]):
|
|
221
|
+
"""
|
|
222
|
+
The :class:`~eth_portfolio._ledgers.PortfolioTransactionsLedger` class manages Ethereum
|
|
223
|
+
transaction entries across all addresses in a portfolio. It aggregates and processes
|
|
224
|
+
transactions for the entire portfolio within specified block ranges.
|
|
225
|
+
|
|
226
|
+
In the eth-portfolio ecosystem, this class is essential for:
|
|
227
|
+
- Providing a comprehensive view of all Ethereum transactions across multiple addresses.
|
|
228
|
+
- Supporting portfolio-wide analysis and reporting of transaction history.
|
|
229
|
+
- Enabling efficient querying of transaction data for specific time periods or block ranges.
|
|
230
|
+
|
|
231
|
+
Example:
|
|
232
|
+
>>> ledger = PortfolioTransactionsLedger(portfolio=Portfolio(addresses=["0x1234...", "0xABCD..."]))
|
|
233
|
+
>>> df = await ledger.df(start_block=10000000, end_block=12000000)
|
|
234
|
+
>>> print(df)
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
property_name = "transactions"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class PortfolioTokenTransfersLedger(PortfolioLedgerBase[TokenTransfersList, TokenTransfer]):
|
|
241
|
+
"""
|
|
242
|
+
The :class:`~eth_portfolio._ledgers.PortfolioTokenTransfersLedger` class manages ERC20 token
|
|
243
|
+
transfer entries across all addresses in a portfolio. It aggregates and processes
|
|
244
|
+
token transfers for the entire portfolio within specified block ranges.
|
|
245
|
+
|
|
246
|
+
In the eth-portfolio ecosystem, this class is crucial for:
|
|
247
|
+
- Tracking all ERC20 token movements across multiple addresses in a portfolio.
|
|
248
|
+
- Facilitating token balance calculations and portfolio valuation.
|
|
249
|
+
- Supporting analysis of token transfer patterns and history.
|
|
250
|
+
|
|
251
|
+
Example:
|
|
252
|
+
>>> ledger = PortfolioTokenTransfersLedger(portfolio=Portfolio(addresses=["0x1234...", "0xABCD..."]))
|
|
253
|
+
>>> df = await ledger.df(start_block=10000000, end_block=12000000)
|
|
254
|
+
>>> print(df)
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
property_name = "token_transfers"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class PortfolioInternalTransfersLedger(
|
|
261
|
+
PortfolioLedgerBase[InternalTransfersList, InternalTransfer]
|
|
262
|
+
):
|
|
263
|
+
"""
|
|
264
|
+
The :class:`~eth_portfolio._ledgers.PortfolioInternalTransfersLedger` class manages internal
|
|
265
|
+
transfer entries across all addresses in a portfolio. It aggregates and processes internal transfers
|
|
266
|
+
for the entire portfolio within specified block ranges.
|
|
267
|
+
|
|
268
|
+
In the eth-portfolio ecosystem, this class plays a vital role in:
|
|
269
|
+
- Tracking internal Ethereum transfers between addresses within the same portfolio.
|
|
270
|
+
- Providing insights into complex transactions that involve multiple internal transfers.
|
|
271
|
+
- Supporting accurate portfolio analysis of internal transfer data from EVM traces.
|
|
272
|
+
|
|
273
|
+
Example:
|
|
274
|
+
>>> ledger = PortfolioInternalTransfersLedger(portfolio=Portfolio(addresses=["0x1234...", "0xABCD..."]))
|
|
275
|
+
>>> df = await ledger.df(start_block=10000000, end_block=12000000)
|
|
276
|
+
>>> print(df)
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
property_name = "internal_transfers"
|
|
280
|
+
|
|
281
|
+
@set_end_block_if_none
|
|
282
|
+
async def df(self, start_block: Block, end_block: Block) -> DataFrame:
|
|
283
|
+
"""
|
|
284
|
+
Returns a DataFrame containing all internal transfers to or from any of the addresses in the portfolio.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
start_block: The starting block for the query.
|
|
288
|
+
end_block: The ending block for the query.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
A DataFrame containing processed internal transfer entries.
|
|
292
|
+
|
|
293
|
+
Example:
|
|
294
|
+
>>> df = await ledger.df(start_block=10000000, end_block=12000000)
|
|
295
|
+
>>> print(df)
|
|
296
|
+
"""
|
|
297
|
+
df = await self._df_base(start_block, end_block)
|
|
298
|
+
if len(df) > 0:
|
|
299
|
+
df.rename(
|
|
300
|
+
columns={"transactionHash": "hash", "transactionPosition": "transactionIndex"},
|
|
301
|
+
inplace=True,
|
|
302
|
+
)
|
|
303
|
+
df = self._cleanup_df(df)
|
|
304
|
+
return df
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def _deduplicate_df(cls, df: DataFrame) -> DataFrame:
|
|
308
|
+
"""
|
|
309
|
+
Deduplicates the DataFrame.
|
|
310
|
+
|
|
311
|
+
This deduplication is essential for ensuring accurate portfolio analysis by removing
|
|
312
|
+
duplicate entries due to transfers between addresses within the same portfolio.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
df: The DataFrame to deduplicate.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
A deduplicated DataFrame.
|
|
319
|
+
|
|
320
|
+
Example:
|
|
321
|
+
>>> deduped_df = PortfolioInternalTransfersLedger._deduplicate_df(df)
|
|
322
|
+
>>> print(deduped_df)
|
|
323
|
+
"""
|
|
324
|
+
df = df.reset_index(drop=True)
|
|
325
|
+
# We cant use drop_duplicates when one of the columns, `traceAddress`, contains lists.
|
|
326
|
+
# We must first convert the lists to strings
|
|
327
|
+
return df.loc[df.astype(str).drop_duplicates().index]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module initializes the `_loaders` package within the `eth_portfolio` library.
|
|
3
|
+
It imports key functions responsible for loading blockchain data related to transactions
|
|
4
|
+
and token transfers for use within the package.
|
|
5
|
+
|
|
6
|
+
The functions imported here are designed to facilitate the retrieval and processing of
|
|
7
|
+
Ethereum blockchain data, enabling efficient data handling and storage for portfolio analysis.
|
|
8
|
+
|
|
9
|
+
Imported Functions:
|
|
10
|
+
- :func:`~eth_portfolio._loaders.transaction.load_transaction`:
|
|
11
|
+
Loads transaction data by address and nonce, with optional price data retrieval.
|
|
12
|
+
- :func:`~eth_portfolio._loaders.token_transfer.load_token_transfer`:
|
|
13
|
+
Processes and loads token transfer data from log entries, with optional price fetching.
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
These functions can be used to load and process blockchain data for portfolio analysis.
|
|
17
|
+
For example, you might use them as follows:
|
|
18
|
+
|
|
19
|
+
>>> from eth_portfolio._loaders import load_transaction, load_token_transfer
|
|
20
|
+
>>> nonce, transaction = await load_transaction(address="0x1234567890abcdef1234567890abcdef12345678", nonce=5, load_prices=True)
|
|
21
|
+
>>> print(transaction)
|
|
22
|
+
|
|
23
|
+
>>> transfer_log = {"address": "0xTokenAddress", "data": "0xData", "removed": False}
|
|
24
|
+
>>> token_transfer = await load_token_transfer(transfer_log, load_prices=True)
|
|
25
|
+
>>> print(token_transfer)
|
|
26
|
+
|
|
27
|
+
See Also:
|
|
28
|
+
- :mod:`eth_portfolio._loaders.transaction`: Contains functions for loading transaction data.
|
|
29
|
+
- :mod:`eth_portfolio._loaders.token_transfer`: Contains functions for processing token transfer logs.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from eth_portfolio._loaders.transaction import load_transaction
|
|
33
|
+
from eth_portfolio._loaders.token_transfer import load_token_transfer
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from decimal import InvalidOperation
|
|
3
|
+
|
|
4
|
+
import y
|
|
5
|
+
from y._decorators import stuck_coro_debugger
|
|
6
|
+
from y.datatypes import Address, Block
|
|
7
|
+
|
|
8
|
+
from eth_portfolio._decimal import Decimal
|
|
9
|
+
from eth_portfolio._utils import _get_price
|
|
10
|
+
from eth_portfolio.typing import Balance
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@stuck_coro_debugger
|
|
16
|
+
async def load_token_balance(token: y.ERC20, address: Address, block: Block) -> Balance:
|
|
17
|
+
"""
|
|
18
|
+
Asynchronously fetch the ERC20 token balance and its USD value for a given address at a specific block.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
token: The ERC20 token contract to query.
|
|
22
|
+
address: The address holding the ERC20 tokens.
|
|
23
|
+
block: The block number for the balance query.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
:class:`~eth_portfolio.typing.Balance`: A custom object containing:
|
|
27
|
+
- balance: The token balance (in token's smallest unit).
|
|
28
|
+
- value: The USD value of the balance (18 decimal places).
|
|
29
|
+
- token: The ERC20 token which was checked.
|
|
30
|
+
- block: The block number where the balance was taken.
|
|
31
|
+
|
|
32
|
+
Note:
|
|
33
|
+
Non-standard ERC20 tokens are handled gracefully, returning a zero balance.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> balance = await load_token_balance(token=dai_contract, address='0x1234...', block=12345678)
|
|
37
|
+
>>> print(f"Token: {balance.token}, Value: {balance.balance}, USD: {balance.usd_value}")
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
balance = await token.balance_of_readable(address, block, sync=False)
|
|
41
|
+
except y.NonStandardERC20:
|
|
42
|
+
logger.warning("NonStandardERC20 exc for %s", token)
|
|
43
|
+
balance = 0
|
|
44
|
+
if not balance:
|
|
45
|
+
return Balance(token=token.address, block=block)
|
|
46
|
+
price = await _get_price(token, block)
|
|
47
|
+
return Balance(
|
|
48
|
+
round(Decimal(balance), 18), _calc_value(balance, price), token=token.address, block=block
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _calc_value(balance, price) -> Decimal:
|
|
53
|
+
"""
|
|
54
|
+
Calculate the USD value of a token balance based on its price.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
balance: The token balance.
|
|
58
|
+
price: The token price in USD.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The total USD value, rounded to 18 decimal places if possible.
|
|
62
|
+
If rounding is not possible due to high precision, returns the unrounded value.
|
|
63
|
+
|
|
64
|
+
Note:
|
|
65
|
+
Returns :class:`~decimal.Decimal(0)` if the price is None, handling cases where price data is unavailable.
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
>>> value = _calc_value(balance=1000, price=0.50)
|
|
69
|
+
>>> print(f"USD Value: {value}")
|
|
70
|
+
"""
|
|
71
|
+
if price is None:
|
|
72
|
+
return Decimal(0)
|
|
73
|
+
# NOTE If balance * price returns a Decimal with precision < 18, rounding is both impossible and unnecessary.
|
|
74
|
+
value = Decimal(balance) * Decimal(price)
|
|
75
|
+
try:
|
|
76
|
+
return round(value, 18)
|
|
77
|
+
except InvalidOperation:
|
|
78
|
+
return value
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module orchestrates the process of loading and processing token transfers within the eth_portfolio ecosystem.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import decimal
|
|
6
|
+
from logging import getLogger
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from a_sync import create_task, gather
|
|
10
|
+
from async_lru import alru_cache
|
|
11
|
+
from dank_mids import BlockSemaphore
|
|
12
|
+
from evmspec.data import TransactionIndex
|
|
13
|
+
from msgspec import Struct, ValidationError
|
|
14
|
+
from msgspec.json import decode
|
|
15
|
+
from pony.orm import TransactionIntegrityError, UnexpectedError
|
|
16
|
+
from y import ERC20
|
|
17
|
+
from y._db.log import Log
|
|
18
|
+
from y._decorators import stuck_coro_debugger
|
|
19
|
+
from y.exceptions import NonStandardERC20, reraise_excs_with_extra_context
|
|
20
|
+
|
|
21
|
+
from eth_portfolio._cache import cache_to_disk
|
|
22
|
+
from eth_portfolio._db import utils as db
|
|
23
|
+
from eth_portfolio._decimal import Decimal
|
|
24
|
+
from eth_portfolio._loaders.utils import get_transaction_receipt
|
|
25
|
+
from eth_portfolio._utils import _get_price
|
|
26
|
+
from eth_portfolio.structs import TokenTransfer
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
logger = getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
token_transfer_semaphore = BlockSemaphore(
|
|
32
|
+
20_000, # Some arbitrary number
|
|
33
|
+
name="eth_portfolio.token_transfers",
|
|
34
|
+
)
|
|
35
|
+
"""A semaphore that regulates the concurrent processing of token transfers by processing lower blocks first."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@stuck_coro_debugger
|
|
39
|
+
async def load_token_transfer(
|
|
40
|
+
transfer_log: "Log", load_prices: bool
|
|
41
|
+
) -> Optional[TokenTransfer]: # sourcery skip: simplify-boolean-comparison
|
|
42
|
+
"""
|
|
43
|
+
Processes and loads a token transfer from a log entry, with comprehensive error handling and optional price fetching.
|
|
44
|
+
|
|
45
|
+
This function employs a multi-step process:
|
|
46
|
+
1. Validates the transfer against a known set of 'shitcoins', skipping processing if matched.
|
|
47
|
+
2. Checks for existing database entries, potentially deleting and reprocessing if price data is requested but missing.
|
|
48
|
+
3. Decodes the transfer log and retrieves associated metadata (e.g., token scale, symbol, transaction index).
|
|
49
|
+
4. Optionally fetches the token price at the time of the transaction.
|
|
50
|
+
5. Constructs and persists a :class:`~eth_portfolio.structs.TokenTransfer` object in the database.
|
|
51
|
+
|
|
52
|
+
The function handles various exceptions, including :class:`~y.exceptions.NonStandardERC20` for non-compliant tokens and :class:`decimal.InvalidOperation` for extreme :class:`~Decimal` values.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
transfer_log: A dictionary containing the raw log entry of the token transfer.
|
|
56
|
+
load_prices: A flag indicating whether to fetch and include price data for the token at the time of transfer.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A processed TokenTransfer object if successful, or None if the transfer cannot be processed due to various constraints or errors.
|
|
60
|
+
|
|
61
|
+
Note:
|
|
62
|
+
This function employs caching mechanisms and database operations to optimize performance.
|
|
63
|
+
"""
|
|
64
|
+
if transfer_log.removed:
|
|
65
|
+
if transfer := await db.get_token_transfer(transfer_log):
|
|
66
|
+
await db.delete_token_transfer(transfer)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
if transfer := await db.get_token_transfer(transfer_log):
|
|
70
|
+
if load_prices is False or transfer.price:
|
|
71
|
+
return transfer
|
|
72
|
+
await db.delete_token_transfer(transfer)
|
|
73
|
+
|
|
74
|
+
if transfer_log.address in _non_standard_erc20:
|
|
75
|
+
logger.debug("%s is not a standard ERC20 token, skipping.", log.address)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
async with token_transfer_semaphore[transfer_log.block]:
|
|
79
|
+
token = ERC20(transfer_log.address, asynchronous=True)
|
|
80
|
+
try:
|
|
81
|
+
try:
|
|
82
|
+
# This will be mem cached so no need to gather and add a bunch of overhead
|
|
83
|
+
scale = await token.scale
|
|
84
|
+
except NonStandardERC20 as e:
|
|
85
|
+
# NOTE: if we cant fetch scale, this is probably either a shitcoin or an NFT (which we don't support at this time)
|
|
86
|
+
logger.debug("%s for %s, skipping.", e, transfer_log)
|
|
87
|
+
_non_standard_erc20.add(transfer_log.address)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
# This will be mem cached so no need to include it in the gather and add a bunch of overhead
|
|
91
|
+
symbol = await get_symbol(token)
|
|
92
|
+
|
|
93
|
+
tx_index_coro = get_transaction_index(transfer_log.transactionHash.hex())
|
|
94
|
+
coro_results = {"token": symbol}
|
|
95
|
+
|
|
96
|
+
if load_prices:
|
|
97
|
+
coro_results.update(
|
|
98
|
+
await gather(
|
|
99
|
+
{
|
|
100
|
+
"transaction_index": tx_index_coro,
|
|
101
|
+
"price": _get_price(token.address, transfer_log.blockNumber),
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
coro_results["transaction_index"] = await tx_index_coro
|
|
107
|
+
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error(
|
|
110
|
+
f"%s %s for %s %s at block %s:",
|
|
111
|
+
e.__class__.__name__,
|
|
112
|
+
e,
|
|
113
|
+
await get_symbol(token) or token.address,
|
|
114
|
+
transfer_log.address,
|
|
115
|
+
transfer_log.blockNumber,
|
|
116
|
+
)
|
|
117
|
+
logger.exception(e)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
value = Decimal(transfer_log.data.as_uint256) / scale
|
|
121
|
+
|
|
122
|
+
if price := coro_results.get("price"):
|
|
123
|
+
coro_results["value_usd"] = round(value * price, 18)
|
|
124
|
+
|
|
125
|
+
transfer = TokenTransfer(log=transfer_log, value=value, **coro_results)
|
|
126
|
+
|
|
127
|
+
create_task(
|
|
128
|
+
_insert_to_db(transfer, load_prices),
|
|
129
|
+
skip_gc_until_done=True,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return transfer
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _insert_to_db(transfer: TokenTransfer, load_prices: bool) -> None:
|
|
136
|
+
with reraise_excs_with_extra_context(transfer):
|
|
137
|
+
try:
|
|
138
|
+
await db.insert_token_transfer(transfer)
|
|
139
|
+
except TransactionIntegrityError:
|
|
140
|
+
if load_prices:
|
|
141
|
+
await db.delete_token_transfer(transfer)
|
|
142
|
+
await db.insert_token_transfer(transfer)
|
|
143
|
+
except UnexpectedError:
|
|
144
|
+
digits_before_decimal = str(transfer.value).split(".")[0]
|
|
145
|
+
if len(digits_before_decimal) <= 20:
|
|
146
|
+
raise
|
|
147
|
+
except decimal.InvalidOperation:
|
|
148
|
+
# TODO: debug why this happens sometimes
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_non_standard_erc20 = set()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@stuck_coro_debugger
|
|
156
|
+
async def get_symbol(token: ERC20) -> Optional[str]:
|
|
157
|
+
"""
|
|
158
|
+
Retrieves the symbol of a given ERC20 token, with error handling for non-standard implementations.
|
|
159
|
+
|
|
160
|
+
This function attempts to access the token's symbol through the standard ERC20 symbol method. If the token contract
|
|
161
|
+
does not adhere to the standard ERC20 interface, indicated by a :class:`~y.exceptions.NonStandardERC20` exception, the function
|
|
162
|
+
returns `None` instead of propagating the error.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
token: An ERC20 token object representing the token whose symbol is to be retrieved.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The token's symbol as a string if successfully retrieved, or None if the token does not implement a standard symbol method.
|
|
169
|
+
"""
|
|
170
|
+
if token.address in _non_standard_erc20:
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
return await token.__symbol__
|
|
174
|
+
except NonStandardERC20:
|
|
175
|
+
_non_standard_erc20.add(token.address)
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@alru_cache(ttl=60 * 60)
|
|
180
|
+
@stuck_coro_debugger
|
|
181
|
+
@cache_to_disk
|
|
182
|
+
async def get_transaction_index(hash: str) -> int:
|
|
183
|
+
"""
|
|
184
|
+
Retrieves the transaction index for a given transaction hash, with results cached to disk.
|
|
185
|
+
|
|
186
|
+
This function fetches the transaction receipt corresponding to the provided hash and extracts the transaction index,
|
|
187
|
+
which represents the position of the transaction within its containing block. The result is cached to disk to
|
|
188
|
+
optimize performance for future runs.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
hash: The hexadecimal string representation of the transaction hash.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
The zero-based index of the transaction within its block.
|
|
195
|
+
"""
|
|
196
|
+
while True:
|
|
197
|
+
receipt_bytes = await get_transaction_receipt(hash)
|
|
198
|
+
if receipt_bytes is not None:
|
|
199
|
+
# TODO: debug why this happens, its something inside of dank_mids
|
|
200
|
+
break
|
|
201
|
+
logger.info("get_transaction_index failed, retrying...")
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
return decode(
|
|
205
|
+
receipt_bytes, type=HasTxIndex, dec_hook=TransactionIndex._decode_hook
|
|
206
|
+
).transactionIndex
|
|
207
|
+
except ValidationError as e:
|
|
208
|
+
new = TypeError(e, receipt_bytes, decode(receipt_bytes))
|
|
209
|
+
logger.exception(new)
|
|
210
|
+
raise new from e
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class HasTxIndex(Struct):
|
|
214
|
+
transactionIndex: TransactionIndex
|