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.

Files changed (47) hide show
  1. eth_portfolio/__init__.py +16 -0
  2. eth_portfolio/_argspec.py +42 -0
  3. eth_portfolio/_cache.py +116 -0
  4. eth_portfolio/_config.py +3 -0
  5. eth_portfolio/_db/__init__.py +0 -0
  6. eth_portfolio/_db/decorators.py +147 -0
  7. eth_portfolio/_db/entities.py +204 -0
  8. eth_portfolio/_db/utils.py +595 -0
  9. eth_portfolio/_decimal.py +122 -0
  10. eth_portfolio/_decorators.py +71 -0
  11. eth_portfolio/_exceptions.py +67 -0
  12. eth_portfolio/_ledgers/__init__.py +0 -0
  13. eth_portfolio/_ledgers/address.py +892 -0
  14. eth_portfolio/_ledgers/portfolio.py +327 -0
  15. eth_portfolio/_loaders/__init__.py +33 -0
  16. eth_portfolio/_loaders/balances.py +78 -0
  17. eth_portfolio/_loaders/token_transfer.py +214 -0
  18. eth_portfolio/_loaders/transaction.py +379 -0
  19. eth_portfolio/_loaders/utils.py +59 -0
  20. eth_portfolio/_shitcoins.py +212 -0
  21. eth_portfolio/_utils.py +286 -0
  22. eth_portfolio/_ydb/__init__.py +0 -0
  23. eth_portfolio/_ydb/token_transfers.py +136 -0
  24. eth_portfolio/address.py +382 -0
  25. eth_portfolio/buckets.py +181 -0
  26. eth_portfolio/constants.py +58 -0
  27. eth_portfolio/portfolio.py +629 -0
  28. eth_portfolio/protocols/__init__.py +66 -0
  29. eth_portfolio/protocols/_base.py +107 -0
  30. eth_portfolio/protocols/convex.py +17 -0
  31. eth_portfolio/protocols/dsr.py +31 -0
  32. eth_portfolio/protocols/lending/__init__.py +49 -0
  33. eth_portfolio/protocols/lending/_base.py +57 -0
  34. eth_portfolio/protocols/lending/compound.py +185 -0
  35. eth_portfolio/protocols/lending/liquity.py +110 -0
  36. eth_portfolio/protocols/lending/maker.py +105 -0
  37. eth_portfolio/protocols/lending/unit.py +47 -0
  38. eth_portfolio/protocols/liquity.py +16 -0
  39. eth_portfolio/py.typed +0 -0
  40. eth_portfolio/structs/__init__.py +43 -0
  41. eth_portfolio/structs/modified.py +69 -0
  42. eth_portfolio/structs/structs.py +637 -0
  43. eth_portfolio/typing.py +1460 -0
  44. eth_portfolio-1.1.0.dist-info/METADATA +174 -0
  45. eth_portfolio-1.1.0.dist-info/RECORD +47 -0
  46. eth_portfolio-1.1.0.dist-info/WHEEL +5 -0
  47. 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