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,286 @@
1
+ import importlib
2
+ import inspect
3
+ import logging
4
+ import pkgutil
5
+ import sqlite3
6
+ from abc import abstractmethod
7
+ from datetime import datetime
8
+ from functools import cached_property
9
+ from types import ModuleType
10
+ from typing import (
11
+ TYPE_CHECKING,
12
+ AsyncGenerator,
13
+ AsyncIterator,
14
+ Dict,
15
+ Generic,
16
+ Iterator,
17
+ List,
18
+ Optional,
19
+ Tuple,
20
+ TypeVar,
21
+ Union,
22
+ )
23
+
24
+ import dank_mids
25
+ from a_sync import ASyncGenericBase, ASyncIterable, ASyncIterator, as_yielded
26
+ from async_lru import alru_cache
27
+ from brownie import chain
28
+ from brownie.exceptions import ContractNotFound
29
+ from eth_abi.exceptions import InsufficientDataBytes
30
+ from pandas import DataFrame # type: ignore
31
+ from y import ERC20, Contract, Network
32
+ from y.datatypes import Address, Block
33
+ from y.exceptions import (
34
+ CantFetchParam,
35
+ ContractNotVerified,
36
+ NodeNotSynced,
37
+ NonStandardERC20,
38
+ PriceError,
39
+ reraise_excs_with_extra_context,
40
+ yPriceMagicError,
41
+ )
42
+ from y.prices.magic import get_price
43
+
44
+ from eth_portfolio import _config, _decimal
45
+ from eth_portfolio.typing import _T
46
+
47
+ if TYPE_CHECKING:
48
+ from eth_portfolio.structs import LedgerEntry
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ NON_STANDARD_ERC721 = {
53
+ Network.Mainnet: ["0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB"], # CryptoPunks
54
+ }.get(chain.id, [])
55
+
56
+
57
+ async def get_buffered_chain_height() -> int:
58
+ """Returns an int equal to the current height of the chain minus `_config.REORG_BUFFER`."""
59
+ return await dank_mids.eth.get_block_number() - _config.REORG_BUFFER
60
+
61
+
62
+ class PandableList(List[_T]):
63
+ @cached_property
64
+ def df(self) -> DataFrame:
65
+ return self._df()
66
+
67
+ def _df(self) -> DataFrame:
68
+ """Override this method if you need to manipulate your dataframe before returning it."""
69
+ return DataFrame(self)
70
+
71
+
72
+ class Decimal(_decimal.Decimal):
73
+ """
74
+ I'm in the process of moving from floats to decimals, this will help be as I buidl.
75
+ """
76
+
77
+ # TODO i forget why I had this lol, should I delete?
78
+
79
+ def __init__(self, v) -> None:
80
+ # assert not isinstance(v, _decimal.Decimal)
81
+ super().__init__()
82
+
83
+
84
+ async def _describe_err(token: Address, block: Optional[Block]) -> str:
85
+ """
86
+ Assembles a string used to provide as much useful information as possible in PriceError messages
87
+ """
88
+ try:
89
+ symbol = await ERC20(token, asynchronous=True).symbol
90
+ except NonStandardERC20:
91
+ symbol = None
92
+
93
+ if block is None:
94
+ if symbol:
95
+ return f"{symbol} {token} on {Network.name()}"
96
+
97
+ return f"malformed token {token} on {Network.name()}"
98
+
99
+ if symbol:
100
+ return f"{symbol} {token} on {Network.name()} at {block}"
101
+
102
+ return f"malformed token {token} on {Network.name()} at {block}"
103
+
104
+
105
+ _to_raise = (
106
+ OSError,
107
+ FileNotFoundError,
108
+ NodeNotSynced,
109
+ NotImplementedError,
110
+ sqlite3.OperationalError,
111
+ InsufficientDataBytes,
112
+ UnboundLocalError,
113
+ RuntimeError,
114
+ )
115
+
116
+
117
+ async def _get_price(token: Address, block: Optional[int] = None) -> _decimal.Decimal:
118
+ with reraise_excs_with_extra_context(token, block):
119
+ try:
120
+ if await is_erc721(token):
121
+ return _decimal.Decimal(0)
122
+ maybe_float = await get_price(token, block, silent=True, sync=False)
123
+ dprice = _decimal.Decimal(maybe_float)
124
+ return round(dprice, 18)
125
+ except CantFetchParam as e:
126
+ logger.warning("CantFetchParam %s", e)
127
+ except yPriceMagicError as e:
128
+ # Raise these exceptions
129
+ if isinstance(e.exception, _to_raise) and not isinstance(e.exception, RecursionError):
130
+ raise e.exception
131
+ # The exceptions below are acceptable enough
132
+ elif isinstance(e.exception, NonStandardERC20):
133
+ # Can't get symbol for handling like other excs
134
+ logger.warning(f"NonStandardERC20 while fetching price for {token}")
135
+ elif isinstance(e.exception, PriceError):
136
+ logger.warning(
137
+ f"PriceError while fetching price for {await _describe_err(token, block)}"
138
+ )
139
+ else:
140
+ logger.warning(f"{e} while fetching price for {await _describe_err(token, block)}")
141
+ logger.warning(e, exc_info=True)
142
+ return _decimal.Decimal(0)
143
+
144
+
145
+ @alru_cache(maxsize=None)
146
+ async def is_erc721(token: Address) -> bool:
147
+ # This can probably be improved
148
+ try:
149
+ contract = await Contract.coroutine(token)
150
+ except (ContractNotFound, ContractNotVerified):
151
+ return False
152
+ attrs = "setApprovalForAll", "getApproved", "isApprovedForAll"
153
+ if all(hasattr(contract, attr) for attr in attrs):
154
+ return True
155
+ elif contract.address in NON_STANDARD_ERC721:
156
+ return True
157
+ return False
158
+
159
+
160
+ def get_submodules_for_module(module: ModuleType) -> List[ModuleType]:
161
+ """
162
+ Returns a list of submodules of `module`.
163
+ """
164
+ assert isinstance(module, ModuleType), "`module` must be a module"
165
+ return [
166
+ obj
167
+ for obj in module.__dict__.values()
168
+ if isinstance(obj, ModuleType) and obj.__name__.startswith(module.__name__)
169
+ ]
170
+
171
+
172
+ def get_class_defs_from_module(module: ModuleType) -> List[type]:
173
+ """
174
+ Returns a list of class definitions from a module.
175
+ """
176
+ return [
177
+ obj
178
+ for obj in module.__dict__.values()
179
+ if isinstance(obj, type) and obj.__module__ == module.__name__
180
+ ]
181
+
182
+
183
+ def _get_protocols_for_submodule() -> List[type]:
184
+ """
185
+ Used to initialize a submodule's class object.
186
+ Returns a list of initialized protocol objects.
187
+ """
188
+ called_from_module = inspect.getmodule(inspect.stack()[1][0])
189
+ assert called_from_module, "You can only call this function from a module"
190
+ components = [
191
+ module
192
+ for module in get_submodules_for_module(called_from_module)
193
+ if not module.__name__.endswith("._base")
194
+ ]
195
+ return [
196
+ cls()
197
+ for component in components
198
+ for cls in get_class_defs_from_module(component)
199
+ if cls
200
+ and not cls.__name__.startswith("_")
201
+ and cls.__name__ != "Lending"
202
+ and (not hasattr(cls, "networks") or chain.id in cls.networks)
203
+ ]
204
+
205
+
206
+ def _import_submodules() -> Dict[str, ModuleType]:
207
+ """
208
+ Import all submodules of the module from which this was called, recursively.
209
+ Ignores submodules named `"base"`.
210
+ Returns a dict of `{module.__name__: module}`
211
+ """
212
+ called_from_module = inspect.getmodule(inspect.stack()[1][0])
213
+ if called_from_module is None:
214
+ return {}
215
+ return {
216
+ name: importlib.import_module(called_from_module.__name__ + "." + name)
217
+ for loader, name, is_pkg in pkgutil.walk_packages(called_from_module.__path__) # type: ignore
218
+ if name != "base"
219
+ }
220
+
221
+
222
+ def _unpack_indicies(indicies: Union[Block, Tuple[Block, Block]]) -> Tuple[Block, Block]:
223
+ """Unpacks indicies and returns a tuple (start_block, end_block)."""
224
+ if isinstance(indicies, tuple):
225
+ start_block, end_block = indicies
226
+ else:
227
+ start_block = 0
228
+ end_block = indicies
229
+ return start_block, end_block
230
+
231
+
232
+ class _AiterMixin(ASyncIterable[_T]):
233
+ def __aiter__(self) -> AsyncIterator[_T]:
234
+ return self[self._start_block : chain.height].__aiter__()
235
+
236
+ def __getitem__(self, slice: slice) -> ASyncIterator[_T]:
237
+ if slice.start is not None and not isinstance(slice.start, (int, datetime)):
238
+ raise TypeError(f"start must be int or datetime. you passed {slice.start}")
239
+ if slice.stop and not isinstance(slice.stop, (int, datetime)):
240
+ raise TypeError(f"start must be int or datetime. you passed {slice.start}")
241
+ if slice.step is not None:
242
+ raise ValueError("You cannot use a step here.")
243
+ return ASyncIterator(self._get_and_yield(slice.start or 0, slice.stop))
244
+
245
+ def yield_forever(self) -> ASyncIterator[_T]:
246
+ return self[self._start_block : None]
247
+
248
+ @abstractmethod
249
+ async def _get_and_yield(
250
+ self, start_block: Block, end_block: Block
251
+ ) -> AsyncGenerator[_T, None]:
252
+ yield
253
+
254
+ @property
255
+ @abstractmethod
256
+ def _start_block(self) -> int: ...
257
+
258
+
259
+ _LT = TypeVar("_LT")
260
+
261
+
262
+ class _LedgeredBase(ASyncGenericBase, _AiterMixin["LedgerEntry"], Generic[_LT]):
263
+ """A mixin class for things with ledgers"""
264
+
265
+ transactions: _LT
266
+ internal_transfers: _LT
267
+ token_transfers: _LT
268
+
269
+ def __init__(self, start_block: int) -> None:
270
+ self.start_block = start_block
271
+ super().__init__()
272
+
273
+ @property
274
+ def _start_block(self) -> int:
275
+ # in the middle of refactoring this
276
+ return self.start_block
277
+
278
+ @property
279
+ def _ledgers(self) -> Iterator[_LT]:
280
+ """An iterator with the 3 ledgers (transactions, internal transfers, token transfers)"""
281
+ yield from (self.transactions, self.internal_transfers, self.token_transfers)
282
+
283
+ def _get_and_yield(
284
+ self, start_block: Block, end_block: Block
285
+ ) -> AsyncGenerator["LedgerEntry", None]:
286
+ return as_yielded(*(ledger[start_block:end_block] for ledger in self._ledgers)) # type: ignore [return-value,index]
File without changes
@@ -0,0 +1,136 @@
1
+ from abc import abstractmethod
2
+ from asyncio import Task, create_task, sleep
3
+ from logging import DEBUG, getLogger
4
+ from typing import AsyncIterator, List
5
+
6
+ import dank_mids
7
+ import evmspec
8
+ import y._db.log
9
+ from a_sync import ASyncIterable, ASyncIterator, as_yielded
10
+ from brownie import chain
11
+ from eth_utils import encode_hex
12
+ from y.datatypes import Address
13
+ from y.utils.events import ProcessedEvents
14
+
15
+ from eth_portfolio._loaders import load_token_transfer
16
+ from eth_portfolio._shitcoins import SHITCOINS
17
+ from eth_portfolio.constants import TRANSFER_SIGS
18
+ from eth_portfolio.structs import TokenTransfer
19
+
20
+
21
+ try:
22
+ # this is only available in 4.0.0+
23
+ from eth_abi import encode
24
+
25
+ encode_address = lambda address: encode_hex(encode(["address"], [str(address)]))
26
+ except ImportError:
27
+ from eth_abi import encode_single
28
+
29
+ encode_address = lambda address: encode_hex(encode_single("address", str(address)))
30
+
31
+ logger = getLogger(__name__)
32
+ _logger_is_enabled_for = logger.isEnabledFor
33
+ _logger_log = logger._log
34
+
35
+
36
+ class _TokenTransfers(ProcessedEvents["Task[TokenTransfer]"]):
37
+ """A helper mixin that contains all logic for fetching token transfers for a particular wallet address"""
38
+
39
+ __slots__ = "address", "_load_prices"
40
+
41
+ def __init__(self, address: Address, from_block: int, load_prices: bool = False):
42
+ self.address = address
43
+ self._load_prices = load_prices
44
+ super().__init__(topics=self._topics, from_block=from_block)
45
+
46
+ def __repr__(self) -> str:
47
+ return f"<{self.__class__.__module__}.{self.__class__.__name__} address={self.address}>"
48
+
49
+ @property
50
+ @abstractmethod
51
+ def _topics(self) -> List: ...
52
+
53
+ @ASyncIterator.wrap # type: ignore [call-overload]
54
+ async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]:
55
+ if not _logger_is_enabled_for(DEBUG):
56
+ async for task in self._objects_thru(block=block):
57
+ yield task
58
+ return
59
+
60
+ _logger_log(DEBUG, "%s yielding all objects thru block %s", (self, block))
61
+ async for task in self._objects_thru(block=block):
62
+ _logger_log(
63
+ DEBUG,
64
+ "yielding %s at block %s [thru: %s, lock: %s]",
65
+ (task, task.block, block, self._lock.value),
66
+ )
67
+ yield task
68
+ _logger_log(DEBUG, "%s yield thru %s complete", (self, block))
69
+
70
+ async def _extend(self, objs: List[evmspec.Log]) -> None:
71
+ shitcoins = SHITCOINS.get(chain.id, set())
72
+ append_loader_task = self._objects.append
73
+ done = 0
74
+ for log in objs:
75
+ if log.address in shitcoins:
76
+ continue
77
+ # save i/o
78
+ array_encodable_log = y._db.log.Log(**log)
79
+ task = create_task(load_token_transfer(array_encodable_log, self._load_prices))
80
+ task.block = log.block # type: ignore [attr-defined]
81
+ append_loader_task(task)
82
+ done += 1
83
+ # Make sure the event loop doesn't get blocked
84
+ if done % 100 == 0:
85
+ await sleep(0)
86
+
87
+ def _get_block_for_obj(self, task: "Task[TokenTransfer]") -> int:
88
+ return task.block # type: ignore [attr-defined]
89
+
90
+ def _process_event(self, task: "Task[TokenTransfer]") -> "Task[TokenTransfer]":
91
+ return task
92
+
93
+ def _done_callback(self, task: Task) -> None:
94
+ if e := task.exception():
95
+ self._exc = e
96
+ logger.exception(e)
97
+ raise e
98
+
99
+
100
+ class InboundTokenTransfers(_TokenTransfers):
101
+ """A container that fetches and iterates over all inbound token transfers for a particular wallet address"""
102
+
103
+ @property
104
+ def _topics(self) -> List:
105
+ return [TRANSFER_SIGS, None, encode_address(self.address)]
106
+
107
+
108
+ class OutboundTokenTransfers(_TokenTransfers):
109
+ """A container that fetches and iterates over all outbound token transfers for a particular wallet address"""
110
+
111
+ @property
112
+ def _topics(self) -> List:
113
+ return [TRANSFER_SIGS, encode_address(self.address)]
114
+
115
+
116
+ class TokenTransfers(ASyncIterable[TokenTransfer]):
117
+ """
118
+ A container that fetches and iterates over all token transfers for a particular wallet address.
119
+ NOTE: These do not come back in chronologcal order.
120
+ """
121
+
122
+ def __init__(self, address: Address, from_block: int, load_prices: bool = False):
123
+ self.transfers_in = InboundTokenTransfers(address, from_block, load_prices=load_prices)
124
+ self.transfers_out = OutboundTokenTransfers(address, from_block, load_prices=load_prices)
125
+
126
+ async def __aiter__(self):
127
+ async for transfer in self.yield_thru_block(await dank_mids.eth.block_number):
128
+ yield transfer
129
+
130
+ def yield_thru_block(self, block: int) -> ASyncIterator["Task[TokenTransfer]"]:
131
+ return ASyncIterator(
132
+ as_yielded(
133
+ self.transfers_in.yield_thru_block(block),
134
+ self.transfers_out.yield_thru_block(block),
135
+ )
136
+ )