Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: query acct history #1277

Merged
merged 26 commits into from
Mar 22, 2023
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8d1b9ce
fix: add missing type to Iterator type hint
fubuloubu Feb 2, 2023
40e4e94
feat: add `get_transactions_by_account_nonce` to `Web3Provider`
fubuloubu Feb 2, 2023
bbeb769
feat: add method to `ProviderAPI` to fetch account history
fubuloubu Feb 2, 2023
3de79af
feat: add support for `AccountTransactionQuery` to default query eng
fubuloubu Feb 2, 2023
b6cbfd5
refactor: source `.outgoing` account history from the query manager
fubuloubu Feb 2, 2023
645cc6e
feat: add `.query` to `AccountHistory`
fubuloubu Feb 2, 2023
41393d7
feat: add support for `__getitem__` to `AccountHistory`
fubuloubu Feb 16, 2023
8ba1103
fix: refactor `.outgoing` using `AccountHistory.__getitem__`
fubuloubu Feb 16, 2023
29f48ac
docs: add a warning when using with raw RPC
fubuloubu Feb 16, 2023
8b32b32
feat: add `.history` property to `BaseAddress`
fubuloubu Feb 2, 2023
c92a185
test: use new `AccountHistory` methods inside of tests
fubuloubu Feb 16, 2023
c49744c
fix: off-by-one bug w/ slices
fubuloubu Feb 16, 2023
f145db5
fix: incorrect query when only one item was requested
fubuloubu Feb 17, 2023
b2cfa08
refactor: wrong order for input sanitation
fubuloubu Feb 21, 2023
146c1f5
refactor: use truthiness
fubuloubu Feb 21, 2023
aaa1d79
refactor: typo/whitespace
fubuloubu Feb 21, 2023
16526ea
fix: off-by-one error
fubuloubu Feb 22, 2023
6ff296c
refactor: change assertion to error
fubuloubu Feb 22, 2023
93f9b0a
fix: raise IndexError from __getitem__, not StopIteration
fubuloubu Feb 22, 2023
a0812dd
refactor: use local network named constant
fubuloubu Mar 20, 2023
6e1501f
refactor: add suggestions to engine selector error
fubuloubu Mar 20, 2023
bd12a91
refactor: more efficiently compute fields for query handlers
fubuloubu Mar 20, 2023
b793c5c
test: columns are now sorted
fubuloubu Mar 20, 2023
b97947e
docs: move cache userguide to a more general userguide about data
fubuloubu Mar 20, 2023
3f60320
refactor: apply suggestions from code review
fubuloubu Mar 21, 2023
861b42e
refactor: add some notes, optimize one statement, sort another
fubuloubu Mar 21, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
userguides/installing_plugins
userguides/projects
userguides/compile
userguides/cache
userguides/data
userguides/networks
userguides/developing_plugins
userguides/config
Expand Down
72 changes: 0 additions & 72 deletions docs/userguides/cache.md

This file was deleted.

78 changes: 78 additions & 0 deletions docs/userguides/data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Querying Data

Ape has advanced features for querying large amounts of on-chain data.
Ape provides this support through a number of standardized methods for working with data,
routed through our query management system, which incorporates data from many sources in
your set of installed plugins.

## Getting Block Data

Use `ape console`:

```bash
ape console --network ethereum:mainnet:infura
```

Run a few queries:

```python
In [1]: df = chain.blocks.query("*", stop_block=20)
In [2]: chain.blocks[-2].transactions # List of transactions in block
```

## Getting Account Transaction Data

Each account within ape will also fetch and store transactional data that you can query.
To work with an account's transaction data, you can do stuff like this:

```python
In [1]: chain.history["example.eth"].query("value").sum() # All value sent by this address
In [2]: acct = accounts.load("my-acct"); acct.history[-1] # Last txn `acct` made
In [3]: acct.history.query("total_fees_paid").sum() # Sum of ether paid for fees by `acct`
```

## Getting Contract Event Data

On a deployed contract, you can query event history.

For example, we have a contract with a `FooHappened` event that you want to query from.
This is how you would query the args from an event:

```python
In [1]: df = contract_instance.FooHappened.query("*", start_block=-1)
```

where `contract_instance` is the return value of `owner.deploy(MyContract)`

See [this guide](../userguides/contracts.html) for more information how to deploy or load contracts.

## Using the Cache

**Note**: This is in Beta release.
This functionality is in constant development and many features are in planning stages.
Use the cache plugin to store provider data in a sqlite database.

To use the cache, first you must initialize it for each network you plan on caching data for:

```bash
ape cache init --network <ecosystem-name>:<network-name>
```

**Note**: Caching only works for permanently available networks. It will not work with local development networks.

For example, to initialize the cache database for the Ethereum mainnet network, you would do the following:

```bash
ape cache init --network ethereum:mainnet
```

This creates a SQLite database file in ape's data folder inside your home directory.

You can query the cache database directly, for debugging purposes.
The cache database has the following tables:

| Table Name | Dataclass base |
| ----------------- | -------------- |
| `blocks` | `BlockAPI` |
| `transactions` | `ReceiptAPI` |
| `contract_events` | `ContractLog` |
14 changes: 12 additions & 2 deletions src/ape/api/address.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from typing import Any, List
from typing import TYPE_CHECKING, Any, List

from hexbytes import HexBytes

from ape.exceptions import ConversionError
from ape.types import AddressType, ContractCode
from ape.utils import BaseInterface, abstractmethod
from ape.utils import BaseInterface, abstractmethod, cached_property

if TYPE_CHECKING:
from ape.managers.chain import AccountHistory


class BaseAddress(BaseInterface):
Expand Down Expand Up @@ -133,6 +136,13 @@ def is_contract(self) -> bool:

return len(HexBytes(self.code)) > 0

@cached_property
def history(self) -> "AccountHistory":
"""
The list of transactions that this account has made on the current chain.
"""
return self.chain_manager.history[self.address]
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved


class Address(BaseAddress):
"""
Expand Down
90 changes: 89 additions & 1 deletion src/ape/api/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,25 @@ def get_transactions_by_block(self, block_id: BlockID) -> Iterator[TransactionAP
Iterator[:class: `~ape.api.transactions.TransactionAPI`]
"""

@raises_not_implemented
def get_transactions_by_account_nonce( # type: ignore[empty-body]
self,
account: AddressType,
start_nonce: int = 0,
stop_nonce: int = -1,
) -> Iterator[ReceiptAPI]:
"""
Get account history for the given account.

Args:
account (``AddressType``): The address of the account.
start_nonce (int): The nonce of the account to start the search with.
stop_nonce (int): The nonce of the account to stop the search with.

Returns:
Iterator[:class:`~ape.api.transactions.ReceiptAPI`]
"""

@abstractmethod
def send_transaction(self, txn: TransactionAPI) -> ReceiptAPI:
"""
Expand Down Expand Up @@ -962,7 +981,7 @@ def get_receipt(
)
return receipt.await_confirmations()

def get_transactions_by_block(self, block_id: BlockID) -> Iterator:
def get_transactions_by_block(self, block_id: BlockID) -> Iterator[TransactionAPI]:
if isinstance(block_id, str):
block_id = HexStr(block_id)

Expand All @@ -973,6 +992,75 @@ def get_transactions_by_block(self, block_id: BlockID) -> Iterator:
for transaction in block.get("transactions", []):
yield self.network.ecosystem.create_transaction(**transaction)

def get_transactions_by_account_nonce(
self,
account: AddressType,
start_nonce: int,
stop_nonce: int,
) -> Iterator[ReceiptAPI]:
if start_nonce > stop_nonce:
raise ValueError("Starting nonce cannot be greater than stop nonce for search")

if self.network.name != LOCAL_NETWORK_NAME and (stop_nonce - start_nonce) > 2:
# NOTE: RPC usage might be acceptable to find 1 or 2 transactions reasonably quickly
logger.warning(
"Performing this action is likely to be very slow and may "
f"use {20 * (stop_nonce - start_nonce)} or more RPC calls. "
"Consider installing an alternative data query provider plugin."
)

yield from self._find_txn_by_account_and_nonce(
account,
start_nonce,
stop_nonce,
0, # first block
self.chain_manager.blocks.head.number or 0, # last block (or 0 if genesis-only chain)
)

def _find_txn_by_account_and_nonce(
self,
account: AddressType,
start_nonce: int,
stop_nonce: int,
start_block: int,
stop_block: int,
) -> Iterator[ReceiptAPI]:
# binary search between `start_block` and `stop_block` to yield txns from account,
# ordered from `start_nonce` to `stop_nonce`

if start_block == stop_block:
# Honed in on one block where there's a delta in nonce, so must be the right block
for txn in self.get_transactions_by_block(stop_block):
assert isinstance(txn.nonce, int) # NOTE: just satisfying mypy here
if txn.sender == account and txn.nonce >= start_nonce:
yield self.get_receipt(txn.txn_hash.hex())

# Nothing else to search for

else:
# Break up into smaller chunks
# NOTE: biased to `stop_block`
block_number = start_block + (stop_block - start_block) // 2 + 1
txn_count_prev_to_block = self.web3.eth.get_transaction_count(account, block_number - 1)

if start_nonce < txn_count_prev_to_block:
yield from self._find_txn_by_account_and_nonce(
account,
start_nonce,
min(txn_count_prev_to_block - 1, stop_nonce), # NOTE: In case >1 txn in block
start_block,
block_number - 1,
)

if txn_count_prev_to_block <= stop_nonce:
yield from self._find_txn_by_account_and_nonce(
account,
max(start_nonce, txn_count_prev_to_block), # NOTE: In case >1 txn in block
stop_nonce,
block_number,
stop_block,
)

def block_ranges(self, start=0, stop=None, page=None):
if stop is None:
stop = self.chain_manager.blocks.height
Expand Down
53 changes: 38 additions & 15 deletions src/ape/api/query.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from functools import lru_cache
from typing import Any, Dict, Iterator, List, Optional, Set, Type, Union

from ethpm_types.abi import EventABI, MethodABI
from pydantic import BaseModel, NonNegativeInt, PositiveInt, root_validator

from ape.api.transactions import ReceiptAPI, TransactionAPI
from ape.logging import logger
from ape.types import AddressType
from ape.utils import BaseInterface, BaseInterfaceModel, abstractmethod, cached_property
Expand All @@ -16,31 +18,52 @@
]


# TODO: Replace with `functools.cache` when Py3.8 dropped
@lru_cache(maxsize=None)
def _basic_columns(Model: Type[BaseInterfaceModel]) -> Set[str]:
columns = set(Model.__fields__)

# TODO: Remove once `ReceiptAPI` fields cleaned up for better processing
if Model == ReceiptAPI:
columns.remove("transaction")
columns |= _basic_columns(TransactionAPI)

return columns


# TODO: Replace with `functools.cache` when Py3.8 dropped
@lru_cache(maxsize=None)
def _all_columns(Model: Type[BaseInterfaceModel]) -> Set[str]:
columns = _basic_columns(Model)
# NOTE: Iterate down the series of subclasses of `Model` (e.g. Block and BlockAPI)
# and get all of the public property methods of each class (which are valid columns)
columns |= {
field_name
for cls in Model.__mro__
if issubclass(cls, BaseInterfaceModel) and cls is not BaseInterfaceModel
for field_name, field in vars(cls).items()
if not field_name.startswith("_") and isinstance(field, (property, cached_property))
}

# TODO: Remove once `ReceiptAPI` fields cleaned up for better processing
if Model == ReceiptAPI:
columns |= _all_columns(TransactionAPI)

return columns


def validate_and_expand_columns(columns: List[str], Model: Type[BaseInterfaceModel]) -> List[str]:
if len(columns) == 1 and columns[0] == "*":
# NOTE: By default, only pull explicit fields
# (because they are cheap to pull, but properties might not be)
return list(Model.__fields__)
return sorted(list(_basic_columns(Model)))

else:
all_columns = _all_columns(Model)
deduped_columns = set(columns)
if len(deduped_columns) != len(columns):
logger.warning(f"Duplicate fields in {columns}")

all_columns = set(Model.__fields__)
# NOTE: Iterate down the series of subclasses of `Model` (e.g. Block and BlockAPI)
# and get all of the public property methods of each class (which are valid columns)
all_columns.update(
{
field
for cls in Model.__mro__
if issubclass(cls, BaseInterfaceModel) and cls != BaseInterfaceModel
for field in vars(cls)
if not field.startswith("_")
and isinstance(vars(cls)[field], (property, cached_property))
}
)

if len(deduped_columns - all_columns) > 0:
err_msg = _unrecognized_columns(deduped_columns, all_columns)
logger.warning(err_msg)
Expand Down
Loading