Skip to content

feat!: support decoding multiple ABIs at the same time, including ds-note library logs #757

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

Merged
merged 62 commits into from
Jul 27, 2022
Merged
Show file tree
Hide file tree
Changes from 51 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
66ff53e
feat: add receipt events and ds-note decoding
banteg Jul 6, 2022
fc20c38
refactor: move ds-note decoding to ecosystem api
banteg Jul 6, 2022
41347d3
fix: consistent exceptions
banteg Jul 6, 2022
4b4539a
feat: optimize ds-log decoding
banteg Jul 6, 2022
2695454
test: add makerdao vat contract
banteg Jul 6, 2022
946536a
test: ds note
banteg Jul 7, 2022
cc8be00
feat: make abi optional for decode_logs, add ds-note decoding
banteg Jul 7, 2022
1622abc
test: decode events without providing abi
banteg Jul 7, 2022
1ec6214
refactor: simplify finding selector for ds-note
banteg Jul 7, 2022
0c6666e
fix: black/flake8 clash
banteg Jul 7, 2022
7952e32
feat: lazy fetching of contract types
banteg Jul 7, 2022
8fdc8de
fix: catch decoding error
banteg Jul 7, 2022
10c25df
fix: handle None contract types
antazoey Jul 7, 2022
9e7f370
chore: Merge branch 'main' into feat/decode-events
antazoey Jul 7, 2022
10d062a
chore: rm optional annotation
antazoey Jul 7, 2022
4cdc91d
refactor: move stuff to ethereum and handle anon logs more
antazoey Jul 7, 2022
d876f3d
test: add simple tests
antazoey Jul 7, 2022
1660f86
test: make fixture
antazoey Jul 7, 2022
c5ed093
test: tests for getting ds notes from receipt
antazoey Jul 8, 2022
0185ab2
chore: del unneeded file
antazoey Jul 8, 2022
eafb03f
chore: merge main
antazoey Jul 10, 2022
e033050
fix: decode lib log optimization
antazoey Jul 10, 2022
44eee65
refactor: move decode lib logs to ecosystem
antazoey Jul 10, 2022
7d12143
chore: put back fixture use
antazoey Jul 10, 2022
563b2cf
Merge branch 'main' into feat/decode-events
fubuloubu Jul 14, 2022
903ba84
chore: merge main
antazoey Jul 20, 2022
83536ff
chore: resolve merge conflicts
antazoey Jul 20, 2022
eddf2d3
fix: address regressions from merge
antazoey Jul 20, 2022
69f9f22
refator: make list of abis
antazoey Jul 20, 2022
3125cdd
chore: pr feedback
antazoey Jul 20, 2022
25d9288
fix: base method param type correct
antazoey Jul 20, 2022
c22bb8a
fix: skip unfound contracttypes
antazoey Jul 20, 2022
0b924bc
refactor: use return from pool map
antazoey Jul 20, 2022
fbc160c
refactor: make get_all method in contract cache
antazoey Jul 20, 2022
d1b8c89
fix: handle conversion and non contract types
antazoey Jul 20, 2022
afd7915
refactor: get contract type
banteg Jul 20, 2022
659191a
feat: exit early for empty code
banteg Jul 20, 2022
0901366
fix: exit early
banteg Jul 20, 2022
2b6d167
refactor: get multiple
banteg Jul 20, 2022
832616e
fix: decode in correct order
banteg Jul 20, 2022
9a20e30
feat: add event selector to ecosystem
banteg Jul 22, 2022
ab67dd9
Merge branch 'main' into feat/decode-events
banteg Jul 22, 2022
08323a6
refactor: make decode_logs method in ape_eth txn
antazoey Jul 22, 2022
57bb7b3
fix: add abstract method
antazoey Jul 22, 2022
ccccb5c
Merge branch 'main' into feat/decode-events
antazoey Jul 22, 2022
2861def
feat: rm get lib logs method
antazoey Jul 25, 2022
25c12fb
chore: resolve conflicts
antazoey Jul 25, 2022
5dc6276
fix: contract cache issue suddenly
antazoey Jul 25, 2022
c8d0e67
fix: cache before return
antazoey Jul 25, 2022
56b272f
chore: Merge branch 'main' into feat/decode-events
antazoey Jul 26, 2022
64cb17d
refactor: use * not for event abis
antazoey Jul 26, 2022
55625f2
refactor: rename name to event_name
antazoey Jul 26, 2022
d320ded
feat: support transaction_index
antazoey Jul 26, 2022
cc0a676
fix: to int
banteg Jul 26, 2022
a6b3232
feat: handle raw responses
antazoey Jul 26, 2022
ae68813
test: improve test
antazoey Jul 26, 2022
084f5af
fix: issue when not using web3 provideR
antazoey Jul 26, 2022
8f223c4
fix: revert unneeded change
antazoey Jul 26, 2022
d2ee852
chore: new found mypy issues
antazoey Jul 26, 2022
299b048
chore: rm unused import
antazoey Jul 27, 2022
e5b764b
feat: remove support for snake_case
antazoey Jul 27, 2022
083ca81
Merge branch 'main' into feat/decode-events
antazoey Jul 27, 2022
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
6 changes: 3 additions & 3 deletions src/ape/api/networks.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,13 @@ def encode_transaction(
"""

@abstractmethod
def decode_logs(self, abi: EventABI, raw_logs: List[Dict]) -> Iterator[ContractLog]:
def decode_logs(self, logs: List[Dict], *events: EventABI) -> Iterator["ContractLog"]:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the variadic argument feels a bit weird here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fubuloubu wanted it and I was hesitant at first, but I kind of like how it feels by not having to wrap single ABIs in a list first.

But I can go either way still, let's reach a consensus

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a breaking change anyways, live life on the edge 🤘

"""
Decode any contract logs that match the given event ABI from the raw log data.

Args:
abi (EventABI): The event producing the logs.
raw_logs (List[Dict]): A list of raw log data from the chain.
logs (List[Dict]): A list of raw log data from the chain.
*events (EventABI): Event definitions to decode.

Returns:
Iterator[:class:`~ape.types.ContractLog`]
Expand Down
2 changes: 1 addition & 1 deletion src/ape/api/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,7 +750,7 @@ def fetch_log_page(block_range):
# eth-tester expects a different format, let web3 handle the conversions for it
raw = "EthereumTester" not in self.client_version
logs = self._get_logs(page_filter.dict(), raw)
return self.network.ecosystem.decode_logs(log_filter.events, logs)
return self.network.ecosystem.decode_logs(logs, *log_filter.events)

with ThreadPoolExecutor(self.concurrency) as pool:
for page in pool.map(fetch_log_page, block_ranges):
Expand Down
24 changes: 10 additions & 14 deletions src/ape/api/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,12 @@ def _confirmations_occurred(self) -> int:

return latest_block.number - self.block_number

def raise_for_status(self):
"""
Handle provider-specific errors regarding a non-successful
:class:`~api.providers.TransactionStatusEnum`.
"""

@abstractmethod
def decode_logs(
self, abi: Union[List[Union[EventABI, "ContractEvent"]], Union[EventABI, "ContractEvent"]]
self,
abi: Optional[
Union[List[Union[EventABI, "ContractEvent"]], Union[EventABI, "ContractEvent"]]
] = None,
) -> Iterator[ContractLog]:
"""
Decode the logs on the receipt.
Expand All @@ -233,14 +231,12 @@ def decode_logs(
Returns:
Iterator[:class:`~ape.types.ContractLog`]
"""
if not isinstance(abi, (list, tuple)):
abi = [abi]

for event_abi in abi:
if not isinstance(event_abi, EventABI):
event_abi = event_abi.abi

yield from self.provider.network.ecosystem.decode_logs(event_abi, self.logs)
def raise_for_status(self):
"""
Handle provider-specific errors regarding a non-successful
:class:`~api.providers.TransactionStatusEnum`.
"""

def await_confirmations(self) -> "ReceiptAPI":
"""
Expand Down
2 changes: 1 addition & 1 deletion src/ape/contracts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ def from_receipt(self, receipt: ReceiptAPI) -> Iterator[ContractLog]:
Iterator[:class:`~ape.contracts.base.ContractLog`]
"""
ecosystem = self.provider.network.ecosystem
yield from ecosystem.decode_logs(self.abi, receipt.logs)
yield from ecosystem.decode_logs(receipt.logs, self.abi)

def poll_logs(
self,
Expand Down
8 changes: 7 additions & 1 deletion src/ape/managers/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,6 @@ def get(
return default

contract_type = self._get_contract_type_from_disk(address_key)

if not contract_type:
# Contract could be a minimal proxy
proxy_info = self._local_proxies.get(address_key) or self._get_proxy_info_from_disk(
Expand All @@ -596,6 +595,13 @@ def get(
self._local_proxies[address_key] = proxy_info
return self.get(proxy_info.target)

if not self.provider.get_code(address_key):
if default:
self._local_contracts[address_key] = default
self._cache_contract_to_disk(address_key, default)

return default

# Also gets cached to disk for faster lookup next time.
contract_type = self._get_contract_type_from_explorer(address_key)

Expand Down
21 changes: 8 additions & 13 deletions src/ape_ethereum/ecosystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ def encode_address(cls, address: AddressType) -> RawAddress:

def get_proxy_info(self, address: AddressType) -> Optional[ProxyInfo]:
code = self.provider.get_code(address).hex()[2:]
if not code:
return None
patterns = {
ProxyType.Minimal: r"363d3d373d3d3d363d73(.{40})5af43d82803e903d91602b57fd5bf3",
ProxyType.Vyper: r"366000600037611000600036600073(.{40})5af4602c57600080fd5b6110006000f3", # noqa: E501
Expand Down Expand Up @@ -442,13 +444,7 @@ def create_transaction(self, **kwargs) -> TransactionAPI:

return txn_class(**kwargs) # type: ignore

def decode_logs(
self, events: Union[EventABI, List[EventABI]], logs: List[Dict]
) -> Iterator["ContractLog"]:

if not isinstance(events, list):
events = [events]

def decode_logs(self, logs: List[Dict], *events: EventABI) -> Iterator["ContractLog"]:
abi_inputs = {
encode_hex(keccak(text=abi.selector)): LogInputABICollection(abi) for abi in events
}
Expand All @@ -468,13 +464,12 @@ def decode_logs(
continue

event_arguments = abi.decode(topics, log["data"])

yield ContractLog(
name=abi.event_name,
block_hash=log["blockHash"],
block_number=to_int(log["blockNumber"]),
contract_address=self.decode_address(log["address"]),
event_arguments=event_arguments,
log_index=log["logIndex"],
name=abi.event_name,
transaction_hash=log["transactionHash"],
block_number=to_int(log["blockNumber"]),
block_hash=log["blockHash"],
log_index=to_int(log["logIndex"]),
)
) # type: ignore
86 changes: 81 additions & 5 deletions src/ape_ethereum/transactions.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import sys
from enum import Enum, IntEnum
from typing import IO, Dict, List, Optional, Union
from typing import IO, Dict, Iterator, List, Optional, Union

from eth_abi import decode_abi
from eth_account import Account as EthAccount # type: ignore
from eth_account._utils.legacy_transactions import (
encode_transaction,
serializable_unsigned_transaction_from_dict,
)
from eth_utils import keccak, to_int
from eth_utils import decode_hex, encode_hex, keccak, to_int
from ethpm_types import HexBytes
from ethpm_types.abi import EventABI
from pydantic import BaseModel, Field, root_validator, validator
from rich.console import Console as RichConsole

from ape.api import ReceiptAPI, TransactionAPI
from ape.contracts import ContractEvent
from ape.exceptions import OutOfGasError, SignatureError, TransactionError
from ape.types import ContractLog
from ape.utils import CallTraceParser, TraceStyles


Expand Down Expand Up @@ -79,7 +82,7 @@ class StaticFeeTransaction(BaseTransaction):
type: Union[str, int, bytes] = Field(TransactionType.STATIC.value, exclude=True)
max_fee: Optional[int] = Field(None, exclude=True)

@root_validator(pre=True)
@root_validator(pre=True, allow_reuse=True)
def calculate_read_only_max_fee(cls, values) -> Dict:
# NOTE: Work-around, Pydantic doesn't handle calculated fields well.
values["max_fee"] = values.get("gas_limit", 0) * values.get("gas_price", 0)
Expand All @@ -97,7 +100,7 @@ class DynamicFeeTransaction(BaseTransaction):
type: Union[int, str, bytes] = Field(TransactionType.DYNAMIC.value)
access_list: List[AccessList] = Field(default_factory=list, alias="accessList")

@validator("type")
@validator("type", allow_reuse=True)
def check_type(cls, value):

if isinstance(value, TransactionType):
Expand All @@ -115,7 +118,7 @@ class AccessListTransaction(BaseTransaction):
type: Union[int, str, bytes] = Field(TransactionType.ACCESS_LIST.value)
access_list: List[AccessList] = Field(default_factory=list, alias="accessList")

@validator("type")
@validator("type", allow_reuse=True)
def check_type(cls, value):

if isinstance(value, TransactionType):
Expand Down Expand Up @@ -177,3 +180,76 @@ def show_trace(self, verbose: bool = False, file: IO[str] = sys.stdout):

console.print(f"txn.origin=[{TraceStyles.CONTRACTS}]{self.sender}[/]")
console.print(root)

def decode_logs(
self,
abi: Optional[
Union[List[Union[EventABI, "ContractEvent"]], Union[EventABI, "ContractEvent"]]
] = None,
) -> Iterator[ContractLog]:
if abi:
if not isinstance(abi, (list, tuple)):
abi = [abi]

event_abis: List[EventABI] = [a.abi if not isinstance(a, EventABI) else a for a in abi]
yield from self.provider.network.ecosystem.decode_logs(self.logs, *event_abis)

else:
# If ABI is not provided, decode all events
addresses = {x["address"] for x in self.logs}
contract_types = self.chain_manager.contracts.get_multiple(addresses)
# address → selector → abi
selectors = {
address: {encode_hex(keccak(text=abi.selector)): abi for abi in contract.events}
for address, contract in contract_types.items()
}
for log in self.logs:
contract_address = log["address"]
if contract_address not in selectors:
continue
try:
selector = encode_hex(log["topics"][0])
event_abi = selectors[contract_address][selector]
except KeyError:
# Likely a library log
library_log = self._decode_ds_note(log)
if library_log:
yield library_log
else:
yield from self.provider.network.ecosystem.decode_logs([log], event_abi)

def _decode_ds_note(self, log: Dict) -> Optional[ContractLog]:
# The first topic encodes the function selector
selector, tail = log["topics"][0][:4], log["topics"][0][4:]
if sum(tail):
# non-zero bytes found after selector
return None

contract_type = self.chain_manager.contracts.get(log["address"])
if contract_type is None:
# contract type for {log['address']} not found
return None

try:
method_abi = contract_type.mutable_methods[selector]
except KeyError:
# selector {selector.hex()} not found in {log['address']}
return None

# ds-note data field uses either (uint256,bytes) or (bytes) encoding
# instead of guessing, assume the payload begins right after the selector
data = decode_hex(log["data"])
input_types = [i.canonical_type for i in method_abi.inputs]
start_index = data.index(selector) + 4
values = decode_abi(input_types, data[start_index:])
address = self.provider.network.ecosystem.decode_address(log["address"])

return ContractLog(
block_hash=log["blockHash"],
block_number=log["blockNumber"],
contract_address=address,
event_arguments={i.name: value for i, value in zip(method_abi.inputs, values)},
log_index=log["logIndex"],
name=method_abi.name,
transaction_hash=log["transactionHash"],
) # type: ignore
56 changes: 52 additions & 4 deletions tests/functional/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,20 @@
from ape.types import AddressType, ContractLog


def _get_raw_contract(compiler: str) -> Dict:
def _get_raw_contract(name: str) -> Dict:
here = Path(__file__).parent
contracts_dir = here / "data" / "contracts" / "ethereum" / "local"
return json.loads((contracts_dir / f"{compiler}_contract.json").read_text())
return json.loads((contracts_dir / f"{name}.json").read_text())


ALIAS = "__FUNCTIONAL_TESTS_ALIAS__"
ALIAS_2 = "__FUNCTIONAL_TESTS_ALIAS_2__"
RAW_SOLIDITY_CONTRACT_TYPE = _get_raw_contract("solidity")
RAW_VYPER_CONTRACT_TYPE = _get_raw_contract("vyper")
RAW_SOLIDITY_CONTRACT_TYPE = _get_raw_contract("solidity_contract")
RAW_VYPER_CONTRACT_TYPE = _get_raw_contract("vyper_contract")
TEST_ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
BASE_PROJECTS_DIRECTORY = (Path(__file__).parent / "data" / "projects").absolute()
PROJECT_WITH_LONG_CONTRACTS_FOLDER = BASE_PROJECTS_DIRECTORY / "LongContractsFolder"
DS_NOTE_TEST_CONTRACT_TYPE = _get_raw_contract("ds_note_test")
APE_PROJECT_FOLDER = BASE_PROJECTS_DIRECTORY / "ApeProject"
SOLIDITY_CONTRACT_ADDRESS = "0xBcF7FFFD8B256Ec51a36782a52D0c34f6474D951"
VYPER_CONTRACT_ADDRESS = "0x274b028b03A250cA03644E6c578D81f019eE1323"
Expand Down Expand Up @@ -200,6 +201,13 @@ def contract_instance(request, solidity_contract_instance, vyper_contract_instan
return solidity_contract_instance if request.param == "solidity" else vyper_contract_instance


@pytest.fixture
def ds_note_test_contract(vyper_contract_type, owner, eth_tester_provider):
contract_type = ContractType.parse_obj(DS_NOTE_TEST_CONTRACT_TYPE)
contract_container = ContractContainer(contract_type=contract_type)
return contract_container.deploy(sender=owner)


@pytest.fixture(scope="session")
def temp_config(config):
@contextmanager
Expand Down Expand Up @@ -259,6 +267,46 @@ def base_projects_directory():
return BASE_PROJECTS_DIRECTORY


@pytest.fixture
def mainnet_contract(chain):
def contract_getter(address):
path = (
Path(__file__).parent
/ "data"
/ "contracts"
/ "ethereum"
/ "mainnet"
/ f"{address}.json"
)
contract = ContractType.parse_file(path)
chain.contracts._local_contracts[address] = contract
return contract

return contract_getter


@pytest.fixture
def ds_note():
return {
"address": "0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B",
"topics": [
HexBytes("0x7608870300000000000000000000000000000000000000000000000000000000"),
HexBytes("0x5946492d41000000000000000000000000000000000000000000000000000000"),
HexBytes("0x0000000000000000000000000abb839063ef747c8432b2acc60bf8f70ec09a45"),
HexBytes("0x0000000000000000000000000abb839063ef747c8432b2acc60bf8f70ec09a45"),
],
"data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000e0760887035946492d410000000000000000000000000000000000000000000000000000000000000000000000000000000abb839063ef747c8432b2acc60bf8f70ec09a450000000000000000000000000abb839063ef747c8432b2acc60bf8f70ec09a450000000000000000000000000abb839063ef747c8432b2acc60bf8f70ec09a450000000000000000000000000000000000000000000000000000000000000000fffffffffffffffffffffffffffffffffffffffffffa050e82a57b7fc6b6020c00000000000000000000000000000000000000000000000000000000", # noqa: E501
"blockNumber": 14623434,
"transactionHash": HexBytes(
"0xa322a9fd0e627e22bfe1b0877cca1d1f2e697d076007231d0b7a366d1a0fdd51"
),
"transactionIndex": 333,
"blockHash": HexBytes("0x0fd77b0af3fa471aa040a02d4fcd1ec0a35122a4166d0bb7c31354e23823de49"),
"logIndex": 376,
"removed": False,
}


@pytest.fixture
def chain_at_block_5(chain):
snapshot_id = chain.snapshot()
Expand Down
Loading