from __future__ import annotations

import dataclasses
import functools
import importlib
import json
import math
import re
import sys
from abc import ABC, abstractmethod
from bdb import BdbQuit
from collections import ChainMap, defaultdict
from contextlib import contextmanager
from copy import deepcopy
from enum import Enum, IntEnum
from os import PathLike
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    DefaultDict,
    Dict,
    Iterable,
    List,
    Optional,
    Sequence,
    Set,
    Tuple,
    Type,
    Union,
    cast,
    overload,
)
from urllib.error import HTTPError

import eth_abi
import eth_abi.abi
import eth_abi.grammar
import eth_abi.packed
import eth_account
import eth_account.messages
import eth_utils
from Crypto.Hash import BLAKE2b, keccak
from typing_extensions import (
    Annotated,
    Literal,
    TypedDict,
    get_args,
    get_origin,
    get_type_hints,
)

from wake.utils import StrEnum, get_class_that_defined_method

from ..utils.keyed_default_dict import KeyedDefaultDict
from . import hardhat_console
from .blocks import ChainBlocks
from .chain_interfaces import (
    AnvilChainInterface,
    ChainInterfaceAbc,
    GanacheChainInterface,
    GethLikeChainInterfaceAbc,
    HardhatChainInterface,
    TxParams,
)
from .globals import (
    chain_interfaces_manager,
    get_config,
    get_coverage_handler,
    get_exception_handler,
)
from .internal import UnknownEvent, read_from_memory
from .json_rpc.communicator import JsonRpcError
from .primitive_types import (
    FixedSizeBytes,
    FixedSizeList,
    Integer,
    fixed_bytes_map,
    fixed_list_map,
    int_map,
    uint256,
    uint_map,
)

if TYPE_CHECKING:
    from .transactions import (
        ChainTransactions,
        TransactionAbc,
        TransactionRevertedError,
    )


# selector => (contract_fqn => pytypes_object)
errors: Dict[bytes, Dict[str, Any]] = {}
# selector => (contract_fqn => pytypes_object)
events: Dict[bytes, Dict[str, Any]] = {}
# contract_fqn => contract type
contracts_by_fqn: Dict[str, Any] = {}
# contract_metadata => contract_fqn
contracts_by_metadata: Dict[bytes, str] = {}
# contract_fqn => tuple of linearized base contract fqns
contracts_inheritance: Dict[str, Tuple[str, ...]] = {}
# contract_fqn => set of REVERT opcode PCs belonging to a revert statement for contract deployment/constructor
contracts_revert_constructor_index: Dict[str, Set[int]] = {}
# contract_fqn => set of REVERT opcode PCs belonging to a revert statement
contracts_revert_index: Dict[str, Set[int]] = {}
# list of pairs of (creation code segments, contract_fqn)
# where creation code segments is a tuple of (length, BLAKE2b hash)
creation_code_index: List[Tuple[Tuple[Tuple[int, bytes], ...], str]] = []
# user defined value type type identifier => underlying type type identifier
user_defined_value_types_index: Dict[str, str] = {}


eth_account.Account.enable_unaudited_hdwallet_features()


def get_contracts_by_fqn() -> Dict[str, Any]:
    return contracts_by_fqn


def get_user_defined_value_types_index() -> Dict[str, str]:
    return user_defined_value_types_index


class RevertToSnapshotFailedError(Exception):
    pass


class NotConnectedError(Exception):
    pass


class AlreadyConnectedError(Exception):
    pass


class TransactionConfirmationFailedError(Exception):
    pass


class RequestType(StrEnum):
    ACCESS_LIST = "access_list"
    CALL = "call"
    ESTIMATE = "estimate"
    TX = "tx"


def fix_library_abi(args: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    ret = []
    for arg in args:
        arg = deepcopy(arg)
        if arg["type"] == "tuple":
            fix_library_abi(arg["components"])
        elif "internalType" in arg:
            # internalType was added in 0.5.11
            # versions before 0.5.11 are not supported by Wake anyway
            # but we may fetch ABIs generated by an older version of solc through Etherscan
            if arg["internalType"].startswith("contract "):
                # replace contract type with "address" but keep array suffix (if any)
                arg["type"] = arg["type"].replace(
                    arg["type"].split("[", 1)[0], "address"
                )
            elif arg["internalType"].startswith("enum "):
                # replace enum type with "uint8" but keep array suffix (if any)
                arg["type"] = arg["type"].replace(arg["type"].split("[", 1)[0], "uint8")
        ret.append(arg)
    return ret


class abi:
    @classmethod
    def _normalize_input(cls, arguments: Iterable) -> List:
        ret = []
        for arg in arguments:
            if isinstance(arg, Address):
                ret.append(str(arg))
            elif isinstance(arg, Account):
                ret.append(str(arg.address))
            elif isinstance(arg, (list, tuple)):
                ret.append(cls._normalize_input(arg))
            elif dataclasses.is_dataclass(arg):
                if hasattr(arg, "_abi") and hasattr(arg, "selector"):
                    input_names = [
                        input["name"] for input in getattr(arg, "_abi")["inputs"]
                    ]
                    inputs = []
                    fields = dataclasses.fields(arg)
                    for name in input_names:
                        if hasattr(arg, name):
                            inputs.append(getattr(arg, name))
                        else:
                            f = next(
                                f
                                for f in fields
                                if f.metadata.get("original_name", None) == name
                            )
                            inputs.append(getattr(arg, f.name))
                    ret.append(cls._normalize_input(inputs))
                else:
                    ret.append(
                        cls._normalize_input(
                            [getattr(arg, f.name) for f in dataclasses.fields(arg)]
                        )
                    )
            else:
                ret.append(arg)
        return ret

    @classmethod
    def _normalize_output(cls, types: Sequence[Type], arguments: Sequence) -> Tuple:
        ret = []
        assert len(types) == len(arguments)
        for t, arg in zip(types, arguments):
            origin = get_origin(t)

            if isinstance(origin, type) and issubclass(origin, list):
                ret.append(
                    origin(cls._normalize_output([get_args(t)[0]] * len(arg), arg))
                )
            elif isinstance(t, type) and issubclass(t, int):
                ret.append(t(arg))
            elif isinstance(t, type) and issubclass(t, (bytes, bytearray)):
                ret.append(t(arg))
            elif issubclass(t, Enum):
                ret.append(t(arg))
            elif issubclass(t, (Account, Address)):
                ret.append(t(arg))
            elif dataclasses.is_dataclass(t):
                assert isinstance(arg, tuple)
                resolved_types = get_type_hints(
                    t  # pyright: ignore reportGeneralTypeIssues
                )
                field_types = [
                    resolved_types[field.name]
                    for field in dataclasses.fields(t)
                    if field.init
                ]
                assert len(arg) == len(field_types)
                ret.append(t(*cls._normalize_output(field_types, arg)))
            else:
                # int, str, bool does not need to be normalized
                ret.append(arg)

        return tuple(ret)

    @classmethod
    def _types_from_type(cls, t: Type) -> str:
        origin = get_origin(t)

        if isinstance(origin, type) and issubclass(origin, list):
            if hasattr(origin, "length"):
                return f"{cls._types_from_type(get_args(t)[0])}[{getattr(origin, 'length')}]"
            else:
                return f"{cls._types_from_type(get_args(t)[0])}[]"
        elif isinstance(t, type) and issubclass(t, Integer):
            if t.min == 0:
                bits = math.ceil(math.log2(t.max + 1))
                return f"uint{bits}"
            else:
                bits = math.ceil(math.log2(t.max - t.min + 1))
                return f"int{bits}"
        elif isinstance(t, type) and issubclass(t, FixedSizeBytes):
            return f"bytes{t.length}"
        elif t is int:
            # fallback for int used directly
            return "int256"
        elif t is bytes or t is bytearray:
            return "bytes"
        elif t is str:
            return "string"
        elif issubclass(t, Enum):
            return "uint8"
        elif t is bool:
            return "bool"
        elif issubclass(t, (Account, Address)):
            return "address"
        elif dataclasses.is_dataclass(t):
            hints = get_type_hints(
                t,  # pyright: ignore reportGeneralTypeIssues
                include_extras=True,
            )
            return f"({','.join(cls._types_from_type(hints[f.name]) for f in dataclasses.fields(t))})"
        else:
            raise ValueError(f"Unsupported type {t}")

    @classmethod
    def _types_from_args(cls, args: Iterable) -> str:
        if isinstance(args, tuple):
            return f"({','.join(cls._types_from_args(arg) for arg in args)})"
        elif isinstance(args, list):
            if len(args) == 0:
                return "uint256[]"  # should not matter what type is used

            for arg in args:
                try:
                    arg_type = cls._types_from_args(arg)
                    if hasattr(args, "length"):
                        return f"{arg_type}[{getattr(args, 'length')}]"
                    else:
                        return f"{arg_type}[]"
                except ValueError:
                    pass

            raise ValueError("Could not determine type of list")
        elif isinstance(args, (Address, Account)):
            return "address"
        elif isinstance(args, str):
            return "string"
        elif isinstance(args, (bytes, bytearray)):
            if hasattr(args, "length"):
                return f"bytes{getattr(args, 'length')}"
            else:
                return "bytes"
        elif isinstance(args, bool):
            return "bool"
        elif callable(args):
            return "function"
        elif isinstance(args, IntEnum):
            return "uint8"
        elif dataclasses.is_dataclass(args):
            return cls._types_from_type(type(args))
        elif isinstance(args, int):
            if not hasattr(args, "min") or not hasattr(args, "max"):
                raise ValueError(
                    "Integer cannot be directly ABI-encoded. Use typecast to intN or uintN instead."
                )
            min = getattr(args, "min")
            max = getattr(args, "max")
            if min == 0:
                bits = math.ceil(math.log2(max + 1))
                return f"uint{bits}"
            else:
                bits = math.ceil(math.log2(max - min + 1))
                return f"int{bits}"
        else:
            raise ValueError(f"Unsupported type {type(args)}")

    @classmethod
    def _types_from_string(cls, s: str) -> List[str]:
        ret = []
        current = []
        depth = 0

        for char in s:
            if char == "(":
                depth += 1
                current.append(char)
            elif char == ")":
                depth -= 1
                current.append(char)
            elif char == "," and depth == 0:
                ret.append("".join(current).strip())
                current = []
            else:
                current.append(char)

        if current:
            ret.append("".join(current).strip())
        return ret

    @classmethod
    def encode(cls, *args) -> bytes:
        from .transactions import (
            TransactionRevertedError,
            UnknownTransactionRevertedError,
        )

        if (
            len(args) == 1
            and hasattr(args[0], "_abi")
            and hasattr(args[0], "selector")
            and isinstance(args[0], TransactionRevertedError)
        ):
            abi = args[0]._abi["inputs"]
            if len(abi) > 0:
                types = [
                    eth_utils.abi.collapse_if_tuple(cast(Dict[str, Any], arg))
                    for arg in fix_library_abi(abi)
                ]
                return args[0].selector + eth_abi.abi.encode(
                    types, cls._normalize_input(args)[0]
                )
            else:
                return args[0].selector
        elif len(args) == 1 and isinstance(args[0], UnknownTransactionRevertedError):
            return args[0].data
        elif any(isinstance(a, TransactionRevertedError) for a in args):
            raise ValueError("Encoding multiple errors is not supported")

        return eth_abi.abi.encode(
            [cls._types_from_args(a) for a in args], cls._normalize_input(args)
        )

    @classmethod
    def encode_packed(cls, *args) -> bytes:
        return eth_abi.packed.encode_packed(
            [cls._types_from_args(a) for a in args], cls._normalize_input(args)
        )

    @classmethod
    def encode_with_selector(cls, selector: bytes, *args) -> bytes:
        return selector + cls.encode(*args)

    @classmethod
    def encode_with_signature(cls, signature: str, *args) -> bytes:
        selector = keccak.new(data=signature.encode("utf-8"), digest_bits=256).digest()[
            :4
        ]
        signature_args = signature[signature.find("(") + 1 : -1]
        return selector + eth_abi.abi.encode(
            cls._types_from_string(signature_args), cls._normalize_input(args)
        )

    @classmethod
    def encode_call(cls, func: Callable, args: Iterable) -> bytes:
        selector = func.selector
        contract = get_class_that_defined_method(func)
        assert selector in contract._abi  # pyright: ignore reportGeneralTypeIssues
        types = [
            eth_utils.abi.collapse_if_tuple(cast(Dict[str, Any], arg))
            for arg in fix_library_abi(
                contract._abi[selector][  # pyright: ignore reportGeneralTypeIssues
                    "inputs"
                ]
            )
        ]
        return selector + eth_abi.abi.encode(types, cls._normalize_input(args))

    @classmethod
    def decode(cls, data: bytes, types: Sequence[Type]) -> Any:
        from .transactions import (
            TransactionRevertedError,
            UnknownTransactionRevertedError,
        )

        if (
            len(types) == 1
            and hasattr(types[0], "_abi")
            and hasattr(types[0], "selector")
            and issubclass(types[0], TransactionRevertedError)
        ):
            if not data.startswith(types[0].selector):
                raise ValueError("Selector does not match data")

            data = data[len(types[0].selector) :]
            abi = types[0]._abi["inputs"]
            if len(abi) == 0:
                return types[0]()
            else:
                t = types[0]
                hints = get_type_hints(
                    t,  # pyright: ignore reportGeneralTypeIssues
                    include_extras=True,
                )
                return cls._normalize_output(
                    types,
                    [
                        eth_abi.abi.decode(
                            [
                                cls._types_from_type(hints[f.name])
                                for f in dataclasses.fields(t)
                                if f.name != "tx"
                            ],
                            data,
                        )
                    ],
                )[0]
        elif len(types) == 1 and issubclass(types[0], UnknownTransactionRevertedError):
            return UnknownTransactionRevertedError(data)
        elif any(issubclass(t, TransactionRevertedError) for t in types):
            raise ValueError("Decoding multiple errors is not supported")

        ret = cls._normalize_output(
            types, eth_abi.abi.decode([cls._types_from_type(t) for t in types], data)
        )
        if len(ret) == 1:
            return ret[0]
        return ret


class Abi:
    @staticmethod
    def _normalize_input(arguments: Iterable) -> List:
        ret = []
        for arg in arguments:
            if isinstance(arg, Address):
                ret.append(str(arg))
            elif isinstance(arg, Account):
                ret.append(str(arg.address))
            elif isinstance(arg, (list, tuple)):
                ret.append(Abi._normalize_input(arg))
            else:
                ret.append(arg)
        return ret

    @staticmethod
    def _normalize_output(types: Sequence[str], arguments: Sequence) -> Tuple:
        ret = []
        assert len(types) == len(arguments)
        for t, arg in zip(types, arguments):
            t = t.strip()
            if t == "address":
                ret.append(Address(arg))
            elif t.endswith("]"):
                args_type = t[: t.rfind("[")]

                if t[-2] == "[":
                    target_type = list
                else:
                    length = int(t[t.rfind("[") + 1 : -1])
                    target_type = (
                        fixed_list_map[length]
                        if length <= 32
                        else type(f"List{length}", (FixedSizeList,), {"length": length})
                    )
                assert isinstance(arg, (list, tuple))
                ret.append(
                    target_type(Abi._normalize_output([args_type] * len(arg), arg))
                )
            elif t.startswith("(") and t.endswith(")"):
                abi_type = eth_abi.grammar.parse(t)
                assert isinstance(abi_type, eth_abi.grammar.TupleType)
                ret.append(
                    Abi._normalize_output(
                        [c.to_type_str() for c in abi_type.components], arg
                    )
                )
            elif t.startswith("int"):
                length = int(t[3:])
                ret.append(int_map[length](arg))
            elif t.startswith("uint"):
                length = int(t[4:])
                ret.append(uint_map[length](arg))
            elif t.startswith("bytes") and t != "bytes":
                # bytes1 - bytes32
                length = int(t[5:])
                ret.append(fixed_bytes_map[length](arg))
            else:
                ret.append(arg)
        return tuple(ret)

    @classmethod
    def encode(cls, types: Iterable, arguments: Iterable) -> bytes:
        return eth_abi.abi.encode(types, cls._normalize_input(arguments))

    @classmethod
    def encode_packed(cls, types: Iterable, arguments: Iterable) -> bytes:
        return eth_abi.packed.encode_packed(types, cls._normalize_input(arguments))

    @classmethod
    def encode_with_selector(
        cls, selector: bytes, types: Iterable, arguments: Iterable
    ) -> bytes:
        return selector + cls.encode(types, arguments)

    @classmethod
    def encode_with_signature(
        cls, signature: str, types: Iterable, arguments: Iterable
    ) -> bytes:
        selector = keccak.new(data=signature.encode("utf-8"), digest_bits=256).digest()[
            :4
        ]
        return cls.encode_with_selector(selector, types, arguments)

    @classmethod
    def encode_call(cls, func: Callable, arguments: Iterable) -> bytes:
        selector = func.selector
        contract = get_class_that_defined_method(func)
        assert selector in contract._abi  # pyright: ignore reportGeneralTypeIssues
        types = [
            eth_utils.abi.collapse_if_tuple(cast(Dict[str, Any], arg))
            for arg in fix_library_abi(
                contract._abi[selector][  # pyright: ignore reportGeneralTypeIssues
                    "inputs"
                ]
            )
        ]
        return cls.encode_with_selector(selector, types, arguments)

    @classmethod
    def decode(cls, types: Sequence[str], data: bytes) -> Any:
        return cls._normalize_output(types, eth_abi.abi.decode(types, data))


class Wei(int):
    def to_ether(self) -> float:
        return self / 10**18

    def to_gwei(self) -> float:
        return self / 10**9

    @classmethod
    def from_ether(cls, value: Union[int, float]) -> Wei:
        return cls(int(value * 10**18))

    @classmethod
    def from_gwei(cls, value: Union[int, float]) -> Wei:
        return cls(int(value * 10**9))

    @classmethod
    def from_str(cls, value: str) -> Wei:
        count, unit = value.split()
        return cls(eth_utils.currency.to_wei(float(count), unit))


@functools.total_ordering
class Address:
    ZERO: Address

    def __init__(self, address: Union[str, int]) -> None:
        if isinstance(address, int):
            self._address = format(address, "#042x")
        elif isinstance(address, str):
            if not address.startswith(("0x", "0X")):
                address = "0x" + address
            if not eth_utils.address.is_address(address):
                raise ValueError(f"{address} is not a valid address")
            self._address = address
        else:
            raise TypeError("Expected a string or int")

    def __str__(self) -> str:
        return self._address

    def __repr__(self) -> str:
        return self._address

    def __eq__(self, other: Any) -> bool:
        if isinstance(other, Address):
            return self._address.lower() == other._address.lower()
        elif isinstance(other, str):
            return self._address.lower() == other.lower()
        elif isinstance(other, Account):
            raise TypeError(
                "Cannot compare Address and Account. Use Account.address instead"
            )
        else:
            return NotImplemented

    def __lt__(self, other: Any) -> bool:
        if isinstance(other, Address):
            return int(self._address, 16) < int(other._address, 16)
        elif isinstance(other, str):
            return int(self._address, 16) < int(other, 16)
        elif isinstance(other, Account):
            raise TypeError(
                "Cannot compare Address and Account. Use Account.address instead"
            )
        else:
            return NotImplemented

    def __hash__(self) -> int:
        return hash(self._address.lower())

    def __bytes__(self) -> bytes:
        return bytes.fromhex(self._address[2:])

    @classmethod
    def from_key(cls, private_key: Union[str, int, bytes]) -> Address:
        global _private_keys_index

        acc = eth_account.Account.from_key(private_key)
        ret = cls(acc.address)
        _private_keys_index[ret] = bytes(acc.key)
        return ret

    @classmethod
    def from_mnemonic(
        cls,
        mnemonic: str,
        passphrase: str = "",
        path: str = "m/44'/60'/0'/0/0",
    ) -> Address:
        global _private_keys_index

        acc = eth_account.Account.from_mnemonic(mnemonic, passphrase, path)
        ret = cls(acc.address)
        _private_keys_index[ret] = bytes(acc.key)
        return ret

    @classmethod
    def from_alias(
        cls,
        alias: str,
        password: Optional[str] = None,
        keystore: Optional[PathLike] = None,
    ) -> Address:
        global _private_keys_index

        if keystore is None:
            path = Path(get_config().global_data_path) / "keystore"
        else:
            path = Path(keystore)
        if not path.is_dir():
            raise ValueError(f"Keystore path {path} is not a directory")

        path = path / f"{alias}.json"
        if not path.exists():
            raise ValueError(f"Alias {alias} not found in keystore {path}")

        with path.open() as f:
            data = json.load(f)

        if not data["address"].startswith("0x"):
            data["address"] = "0x" + data["address"]

        if password is None:
            import click

            password = click.prompt(
                f"Password for account {alias}", default="", hide_input=True
            )

        key = eth_account.Account.decrypt(data, password)

        ret = cls(data["address"])
        _private_keys_index[ret] = bytes(key)
        return ret

    @property
    def private_key(self) -> Optional[bytes]:
        return _private_keys_index.get(self, None)


Address.ZERO = Address(0)


def detect_default_chain() -> Chain:
    if "wake.deployment" in sys.modules and "wake.testing" in sys.modules:
        import wake.deployment
        import wake.testing

        if (
            wake.deployment.default_chain.connected
            and wake.testing.default_chain.connected
        ):
            raise ValueError(
                "Both wake.testing.default_chain and wake.deployment.default_chain are connected. Please specify which chain to use."
            )

        if wake.deployment.default_chain.connected:
            return wake.deployment.default_chain
        elif wake.testing.default_chain.connected:
            return wake.testing.default_chain
        else:
            raise NotConnectedError("default_chain not connected")
    elif "wake.deployment" in sys.modules:
        import wake.deployment

        return wake.deployment.default_chain
    elif "wake.testing" in sys.modules:
        import wake.testing

        return wake.testing.default_chain
    else:
        raise NotConnectedError("default_chain not connected")


@functools.total_ordering
class Account:
    _address: Address
    _chain: Chain

    def __init__(
        self, address: Union[Address, str, int], chain: Optional[Chain] = None
    ) -> None:
        if chain is None:
            chain = detect_default_chain()

        if isinstance(address, Address):
            self._address = address
        else:
            self._address = Address(address)
        self._chain = chain

    def __str__(self) -> str:
        return self._chain._labels.get(self._address, f"Account({self._address})")

    __repr__ = __str__

    def __eq__(self, other: Any) -> bool:
        if isinstance(other, Account):
            return self._address == other._address and self._chain == other._chain
        elif isinstance(other, Address):
            raise TypeError(
                "Cannot compare Account to Address. Use Account.address == Address"
            )
        return NotImplemented

    def __lt__(self, other):
        if isinstance(other, Account):
            if self._chain == other._chain:
                return self._address < other._address
            else:
                raise TypeError(
                    "Cannot compare Accounts from different chains. Compare Account.address instead"
                )
        elif isinstance(other, Address):
            raise TypeError(
                "Cannot compare Account to Address. Use Account.address == Address"
            )
        return NotImplemented

    def __hash__(self) -> int:
        return hash((self._address, self._chain))

    @classmethod
    def new(cls, chain: Optional[Chain] = None, extra_entropy: bytes = b"") -> Account:
        if chain is None:
            chain = detect_default_chain()

        private_key = chain._new_private_key(extra_entropy)
        address = Address.from_key(private_key)
        return cls(address, chain)

    @classmethod
    def from_key(
        cls, private_key: Union[str, int, bytes], chain: Optional[Chain] = None
    ) -> Account:
        return cls(Address.from_key(private_key), chain)

    @classmethod
    def from_mnemonic(
        cls,
        mnemonic: str,
        passphrase: str = "",
        path: str = "m/44'/60'/0'/0/0",
        chain: Optional[Chain] = None,
    ) -> Account:
        return cls(Address.from_mnemonic(mnemonic, passphrase, path), chain)

    @classmethod
    def from_alias(
        cls,
        alias: str,
        password: Optional[str] = None,
        keystore: Optional[PathLike] = None,
        chain: Optional[Chain] = None,
    ) -> Account:
        return cls(Address.from_alias(alias, password, keystore), chain)

    @property
    def private_key(self) -> Optional[bytes]:
        return _private_keys_index.get(self._address, None)

    @property
    def address(self) -> Address:
        return self._address

    @property
    def label(self) -> Optional[str]:
        return self._chain._labels.get(self._address, None)

    @label.setter
    def label(self, value: Optional[str]) -> None:
        if value is not None and not isinstance(value, str):
            raise TypeError("label must be a string or None")
        if value is None:
            del self._chain._labels[self._address]
        else:
            self._chain._labels[self._address] = value

    @property
    def balance(self) -> Wei:
        return Wei(self._chain.chain_interface.get_balance(str(self._address)))

    @balance.setter
    def balance(self, value: Union[int, str]) -> None:
        if isinstance(value, str):
            value = Wei.from_str(value)

        if not isinstance(value, int):
            raise TypeError("value must be an integer or string")
        if value < 0:
            raise ValueError("value must be non-negative")
        self._chain.chain_interface.set_balance(str(self.address), value)

    @property
    def code(self) -> bytes:
        return self._chain.chain_interface.get_code(str(self._address))

    @code.setter
    def code(self, value: Union[bytes, bytearray]) -> None:
        if not isinstance(value, (bytes, bytearray)):
            raise TypeError("value must be a bytes object")
        self._chain.chain_interface.set_code(str(self.address), value)

    @property
    def chain(self) -> Chain:
        return self._chain

    @property
    def nonce(self) -> int:
        return self._chain.chain_interface.get_transaction_count(str(self._address))

    @nonce.setter
    def nonce(self, value: int) -> None:
        if not isinstance(value, int):
            raise TypeError("value must be an integer")
        if value < 0:
            raise ValueError("value must be non-negative")
        self._chain.chain_interface.set_nonce(str(self.address), value)
        self._chain._update_nonce(self.address, value)

    def _setup_tx_params(
        self,
        request_type: RequestType,
        data: Union[bytes, bytearray],
        value: Union[int, str],
        from_: Optional[Union[Account, Address, str]],
        gas_limit: Optional[Union[int, Literal["max"], Literal["auto"]]],
        gas_price: Optional[Union[int, str]],
        max_fee_per_gas: Optional[Union[int, str]],
        max_priority_fee_per_gas: Optional[Union[int, str]],
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ],
        type: Optional[int],
    ):
        if isinstance(value, str):
            value = Wei.from_str(value)

        params: TxParams = {
            "data": data,
            "value": value,
            "to": str(self._address),
        }
        if from_ is None:
            if request_type == RequestType.CALL:
                from_ = self._chain.default_call_account
            elif request_type == RequestType.TX:
                from_ = self._chain.default_tx_account
            elif request_type == RequestType.ESTIMATE:
                from_ = self._chain.default_estimate_account
            elif request_type == RequestType.ACCESS_LIST:
                from_ = self._chain.default_access_list_account

        if isinstance(from_, Account):
            if from_.chain != self._chain:
                raise ValueError("`from_` account must belong to this chain")
            params["from"] = str(from_.address)
        elif isinstance(from_, (Address, str)):
            params["from"] = str(from_)
        else:
            raise TypeError("`from_` must be an Account, Address, or str")

        if gas_limit == "max":
            params["gas"] = self._chain.block_gas_limit
        elif gas_limit == "auto":
            params["gas"] = "auto"
        elif isinstance(gas_limit, int):
            params["gas"] = gas_limit
        elif gas_limit is None:
            pass
        else:
            raise TypeError("`gas_limit` must be an int, 'max', 'auto', or None")

        if gas_price is not None:
            if isinstance(gas_price, str):
                gas_price = Wei.from_str(gas_price)
            params["gasPrice"] = gas_price

        if max_fee_per_gas is not None:
            if isinstance(max_fee_per_gas, str):
                max_fee_per_gas = Wei.from_str(max_fee_per_gas)
            params["maxFeePerGas"] = max_fee_per_gas

        if max_priority_fee_per_gas is not None:
            if isinstance(max_priority_fee_per_gas, str):
                max_priority_fee_per_gas = Wei.from_str(max_priority_fee_per_gas)
            params["maxPriorityFeePerGas"] = max_priority_fee_per_gas

        if access_list == "auto":
            params["accessList"] = "auto"
        elif access_list is not None:
            # normalize access_list, all keys should be Address
            tmp_access_list = defaultdict(list)
            for k, v in access_list.items():
                if isinstance(k, Account):
                    k = k.address
                elif isinstance(k, str):
                    k = Address(k)
                elif not isinstance(k, Address):
                    raise TypeError("access_list keys must be Account, Address or str")
                tmp_access_list[k].extend(v)
            access_list = tmp_access_list
            params["accessList"] = [
                {"address": str(k), "storageKeys": [hex(i) for i in v]}
                for k, v in access_list.items()
            ]

        if type is not None:
            params["type"] = type

        return params

    def call(
        self,
        data: Union[bytes, bytearray] = b"",
        value: Union[int, str] = 0,
        from_: Optional[Union[Account, Address, str]] = None,
        gas_limit: Optional[Union[int, Literal["max"], Literal["auto"]]] = None,
        gas_price: Optional[Union[int, str]] = None,
        max_fee_per_gas: Optional[Union[int, str]] = None,
        max_priority_fee_per_gas: Optional[Union[int, str]] = None,
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ] = None,
        type: Optional[int] = None,
        block: Union[
            int,
            Literal["latest"],
            Literal["pending"],
            Literal["earliest"],
            Literal["safe"],
            Literal["finalized"],
        ] = "latest",
    ) -> bytearray:
        params = self._setup_tx_params(
            RequestType.CALL,
            data,
            value,
            from_,
            gas_limit,
            gas_price,
            max_fee_per_gas,
            max_priority_fee_per_gas,
            access_list,
            type,
        )
        params = self._chain._build_transaction(RequestType.CALL, params, [], None)

        try:
            coverage_handler = get_coverage_handler()
            if coverage_handler is not None and self._chain._debug_trace_call_supported:
                ret = self._chain.chain_interface.debug_trace_call(params, block)
                coverage_handler.add_coverage(params, self._chain, ret)

                ret_value = ret["returnValue"]
                if ret_value.startswith("0x"):
                    ret_value = ret_value[2:]
                output = bytes.fromhex(ret_value)
                if ret["failed"]:
                    raise self._chain._process_revert_data(None, output) from None
            else:
                output = self._chain.chain_interface.call(params, block)
        except JsonRpcError as e:
            raise self._chain._process_call_revert(e) from None

        return bytearray(output)

    def estimate(
        self,
        data: Union[bytes, bytearray] = b"",
        value: Union[int, str] = 0,
        from_: Optional[Union[Account, Address, str]] = None,
        gas_limit: Optional[Union[int, Literal["max"], Literal["auto"]]] = None,
        gas_price: Optional[Union[int, str]] = None,
        max_fee_per_gas: Optional[Union[int, str]] = None,
        max_priority_fee_per_gas: Optional[Union[int, str]] = None,
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ] = None,
        type: Optional[int] = None,
        block: Union[
            int,
            Literal["latest"],
            Literal["pending"],
            Literal["earliest"],
            Literal["safe"],
            Literal["finalized"],
        ] = "pending",
    ) -> int:
        params = self._setup_tx_params(
            RequestType.ESTIMATE,
            data,
            value,
            from_,
            gas_limit,
            gas_price,
            max_fee_per_gas,
            max_priority_fee_per_gas,
            access_list,
            type,
        )
        params = self._chain._build_transaction(RequestType.CALL, params, [], None)

        try:
            return self._chain.chain_interface.estimate_gas(params, block)
        except JsonRpcError as e:
            raise self._chain._process_call_revert(e) from None

    def access_list(
        self,
        data: Union[bytes, bytearray] = b"",
        value: Union[int, str] = 0,
        from_: Optional[Union[Account, Address, str]] = None,
        gas_limit: Optional[Union[int, Literal["max"], Literal["auto"]]] = None,
        gas_price: Optional[Union[int, str]] = None,
        max_fee_per_gas: Optional[Union[int, str]] = None,
        max_priority_fee_per_gas: Optional[Union[int, str]] = None,
        type: Optional[int] = None,
        block: Union[
            int,
            Literal["latest"],
            Literal["pending"],
            Literal["earliest"],
            Literal["safe"],
            Literal["finalized"],
        ] = "pending",
    ):
        params = self._setup_tx_params(
            RequestType.ACCESS_LIST,
            data,
            value,
            from_,
            gas_limit,
            gas_price,
            max_fee_per_gas,
            max_priority_fee_per_gas,
            {},
            type,
        )
        params = self._chain._build_transaction(
            RequestType.ACCESS_LIST, params, [], None
        )

        try:
            response = self._chain.chain_interface.create_access_list(params, block)
            return {
                Address(e["address"]): [int(s, 16) for s in e["storageKeys"]]
                for e in response["accessList"]
            }, int(response["gasUsed"], 16)
        except JsonRpcError as e:
            raise self._chain._process_call_revert(e) from None

    def transact(
        self,
        data: Union[bytes, bytearray] = b"",
        value: Union[int, str] = 0,
        from_: Optional[Union[Account, Address, str]] = None,
        gas_limit: Optional[Union[int, Literal["max"], Literal["auto"]]] = None,
        gas_price: Optional[Union[int, str]] = None,
        max_fee_per_gas: Optional[Union[int, str]] = None,
        max_priority_fee_per_gas: Optional[Union[int, str]] = None,
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ] = None,
        type: Optional[int] = None,
        confirmations: Optional[int] = None,
    ) -> TransactionAbc[bytearray]:
        tx_params = self._setup_tx_params(
            RequestType.TX,
            data,
            value,
            from_,
            gas_limit,
            gas_price,
            max_fee_per_gas,
            max_priority_fee_per_gas,
            access_list,
            type,
        )
        tx_params = self._chain._build_transaction(
            RequestType.CALL, tx_params, [], None
        )

        tx_hash = self._chain._send_transaction(tx_params, from_)

        if "type" not in tx_params:
            from .transactions import LegacyTransaction

            tx_type = LegacyTransaction[bytearray]
        elif tx_params["type"] == 1:
            from .transactions import Eip2930Transaction

            tx_type = Eip2930Transaction[bytearray]
        elif tx_params["type"] == 2:
            from .transactions import Eip1559Transaction

            tx_type = Eip1559Transaction[bytearray]
        else:
            raise ValueError(f"Unknown transaction type {tx_params['type']}")

        tx = tx_type(
            tx_hash,
            tx_params,
            None,
            bytearray,
            self.chain,
        )

        if confirmations != 0:
            tx.wait(confirmations)

            coverage_handler = get_coverage_handler()
            if coverage_handler is not None:
                tx._fetch_debug_trace_transaction()
                coverage_handler.add_coverage(
                    tx_params,
                    self._chain,
                    tx._debug_trace_transaction,  # pyright: ignore reportGeneralTypeIssues
                )

            if self._chain.tx_callback is not None:
                self._chain.tx_callback(tx)

            if tx.error is not None:
                raise tx.error

        return tx

    def sign(self, data: bytes) -> bytes:
        """
        Sign raw data according to EIP-191 type 0x45.
        Specifically, sign(keccak256(b"\x19Ethereum Signed Message:\n" + len(data) + data)) is returned.
        """
        if self.private_key is None:
            return self._chain.chain_interface.sign(str(self._address), data)
        else:
            return bytes(
                eth_account.Account.sign_message(
                    eth_account.messages.encode_defunct(data),
                    self.private_key,
                ).signature
            )

    def sign_hash(self, data_hash: bytes) -> bytes:
        """
        Sign any 32B data (typically keccak256 hash) without prepending any prefix (non EIP-191 compliant).
        This is not recommended for most use cases.
        Specifically, sign(data_hash) is returned.
        """
        if self.private_key is None:
            raise NotImplementedError(
                "Signing data hash without prefix (non EIP-191 compliant) is not supported for accounts without supplied private key"
            )
        else:
            return bytes(
                eth_account.Account.signHash(
                    data_hash,
                    self.private_key,
                ).signature
            )

    def _prepare_eip712_dict(
        self, message: Any, domain: Eip712Domain, client_signing: bool
    ) -> Dict[str, Any]:
        def _get_type(t: Type) -> str:
            origin = get_origin(t)

            if isinstance(origin, type) and issubclass(origin, list):
                if hasattr(origin, "length"):
                    return f"{_get_type(get_args(t)[0])}[{getattr(origin, 'length')}]"
                else:
                    return f"{_get_type(get_args(t)[0])}[]"
            elif isinstance(t, type) and issubclass(t, Integer):
                if t.min == 0:
                    bits = math.ceil(math.log2(t.max + 1))
                    return f"uint{bits}"
                else:
                    bits = math.ceil(math.log2(t.max - t.min + 1))
                    return f"int{bits}"
            elif isinstance(t, type) and issubclass(t, FixedSizeBytes):
                return f"bytes{t.length}"
            elif t is int:
                # fallback for int used directly
                return "int256"
            elif t is bytes or t is bytearray:
                return "bytes"
            elif t is str:
                return "string"
            elif issubclass(t, Enum):
                return "uint8"
            elif t is bool:
                return "bool"
            elif issubclass(t, (Account, Address)):
                return "address"
            elif dataclasses.is_dataclass(t):
                return getattr(t, "original_name", t.__name__)
            else:
                raise ValueError(f"Unsupported type {t}")

        def _get_types(t: Type, types: Dict[str, List[Dict[str, str]]]) -> None:
            if not dataclasses.is_dataclass(t):
                return

            name = getattr(t, "original_name", t.__name__)
            if name in types:
                return

            fields = []
            hints = get_type_hints(
                t,  # pyright: ignore reportGeneralTypeIssues
                include_extras=True,
            )
            for f in dataclasses.fields(t):
                assert f.name in hints
                fields.append(
                    {
                        "name": f.metadata.get("original_name", f.name),
                        "type": _get_type(hints[f.name]),
                    }
                )

            types[name] = fields

            for f in dataclasses.fields(t):
                assert f.name in hints
                field_type = hints[f.name]
                while isinstance(get_origin(field_type), type) and issubclass(
                    get_origin(field_type), list
                ):
                    field_type = get_args(field_type)[0]
                if dataclasses.is_dataclass(field_type):
                    _get_types(field_type, types)

        def _get_value(value: Any) -> Any:
            if dataclasses.is_dataclass(value):
                ret = {}
                for f in dataclasses.fields(value):
                    name = f.metadata.get("original_name", f.name)
                    ret[name] = _get_value(getattr(value, f.name))
                return ret
            elif isinstance(value, (list, tuple)):
                return [_get_value(v) for v in value]
            elif isinstance(value, Account):
                return str(value.address)
            elif isinstance(value, Address):
                return str(value)
            elif isinstance(value, IntEnum):
                return int(value)
            elif isinstance(value, (bytes, bytearray)):
                if client_signing:
                    return "0x" + value.hex()
                else:
                    return value
            else:
                return value

        types = {}
        _get_types(type(message), types)

        ret = {
            "types": types,
            "domain": {},
            "primaryType": _get_type(type(message)),
            "message": _get_value(message),
        }

        domain_type = []
        if "name" in domain:
            ret["domain"]["name"] = domain["name"]
            domain_type.append({"name": "name", "type": "string"})
        if "version" in domain:
            ret["domain"]["version"] = domain["version"]
            domain_type.append({"name": "version", "type": "string"})
        if "chainId" in domain:
            ret["domain"]["chainId"] = domain["chainId"]
            domain_type.append({"name": "chainId", "type": "uint256"})
        if "verifyingContract" in domain:
            if isinstance(domain["verifyingContract"], Account):
                ret["domain"]["verifyingContract"] = str(
                    domain["verifyingContract"].address
                )
            else:
                ret["domain"]["verifyingContract"] = str(domain["verifyingContract"])
            domain_type.append({"name": "verifyingContract", "type": "address"})
        if "salt" in domain:
            ret["domain"]["salt"] = "0x" + domain["salt"].hex()
            domain_type.append({"name": "salt", "type": "bytes32"})

        ret["types"]["EIP712Domain"] = domain_type

        return ret

    def sign_structured(
        self, message: Any, domain: Optional[Eip712Domain] = None
    ) -> bytes:
        """
        Sign structured data according to EIP-712. Message can be either a raw dictionary as described in the EIP
        (https://eips.ethereum.org/EIPS/eip-712), or any ABI-compatible dataclass.
        """

        client_signing = self.private_key is None

        if isinstance(message, dict):
            if domain is not None:
                raise ValueError(
                    "Domain cannot be specified when message is a dictionary"
                )
        else:
            if domain is None:
                raise ValueError(
                    "Domain must be specified when message is not a dictionary"
                )
            message = self._prepare_eip712_dict(message, domain, client_signing)

        if client_signing:
            return self._chain.chain_interface.sign_typed(str(self._address), message)
        else:
            return bytes(
                eth_account.Account.sign_message(
                    eth_account.messages.encode_structured_data(message),
                    self.private_key,
                ).signature
            )


Eip712Domain = TypedDict(
    "Eip712Domain",
    {
        "name": str,
        "version": str,
        "chainId": int,
        "verifyingContract": Union[Account, Address, str],
        "salt": bytes,
    },
    total=False,
)


def check_connected(f):
    @functools.wraps(f)
    def wrapper(*args, **kwargs):
        if not args[0].connected:
            raise NotConnectedError("Not connected to a chain")
        return f(*args, **kwargs)

    return wrapper


_private_keys_index: Dict[Address, bytes] = {}
_test_accounts_generated_count: int = 0


class Chain(ABC):
    _connected: bool
    _chain_interface: ChainInterfaceAbc
    _accounts: List[Account]
    _accounts_set: Set[Account]  # for faster lookup
    _nonces: KeyedDefaultDict[Address, int]  # pyright: ignore reportGeneralTypeIssues
    _default_call_account: Optional[Account]
    _default_tx_account: Optional[Account]
    _default_estimate_account: Optional[Account]
    _default_access_list_account: Optional[Account]
    _default_tx_type: int
    _default_tx_confirmations: int
    _deployed_libraries: DefaultDict[bytes, List[Library]]
    _single_source_errors: Set[bytes]
    _snapshots: Dict[str, Dict]
    _blocks: ChainBlocks
    _txs: ChainTransactions
    _chain_id: int
    _labels: Dict[Address, str]
    _require_signed_txs: bool
    _fork: Optional[str]
    _forked_chain_id: Optional[int]
    _debug_trace_call_supported: bool
    _client_version: str

    tx_callback: Optional[Callable[[TransactionAbc], None]]

    def __deepcopy__(self, memo):
        return self

    @abstractmethod
    def _connect_setup(
        self, min_gas_price: Optional[int], block_base_fee_per_gas: Optional[int]
    ) -> None:
        ...

    @abstractmethod
    def _connect_finalize(self) -> None:
        ...

    @abstractmethod
    def _new_private_key(self, extra_entropy: bytes = b"") -> bytes:
        ...

    @abstractmethod
    def snapshot(self) -> str:
        ...

    @abstractmethod
    def revert(self, snapshot_id: str) -> None:
        ...

    @abstractmethod
    def _build_transaction(
        self,
        request_type: RequestType,
        params: TxParams,
        arguments: Iterable,
        abi: Optional[Dict],
    ) -> TxParams:
        ...

    @abstractmethod
    def _wait_for_transaction(
        self, tx: TransactionAbc, confirmations: Optional[int]
    ) -> None:
        ...

    @abstractmethod
    def _confirm_transaction(self, tx: TxParams) -> None:
        ...

    @property
    @abstractmethod
    def block_gas_limit(self) -> int:
        ...

    @block_gas_limit.setter
    @abstractmethod
    def block_gas_limit(self, value: int) -> None:
        ...

    @property
    @abstractmethod
    def gas_price(self) -> Wei:
        ...

    @gas_price.setter
    @abstractmethod
    def gas_price(self, value: int) -> None:
        ...

    @property
    @abstractmethod
    def max_priority_fee_per_gas(self) -> Wei:
        ...

    @max_priority_fee_per_gas.setter
    @abstractmethod
    def max_priority_fee_per_gas(self, value: int) -> None:
        ...

    @contextmanager  # pyright: ignore reportGeneralTypeIssues
    @abstractmethod
    def connect(
        self,
        uri: Optional[str] = None,
        *,
        accounts: Optional[int] = None,
        chain_id: Optional[int] = None,
        fork: Optional[str] = None,
        hardfork: Optional[str] = None,
        min_gas_price: Optional[Union[int, str]],
        block_base_fee_per_gas: Optional[Union[int, str]],
    ):
        ...

    def __init__(self):
        self._connected = False

    def _connect(
        self,
        uri: Optional[str] = None,
        *,
        accounts: Optional[int],
        chain_id: Optional[int],
        fork: Optional[str],
        hardfork: Optional[str],
        min_gas_price: Optional[Union[int, str]],
        block_base_fee_per_gas: Optional[Union[int, str]],
    ):
        global _test_accounts_generated_count

        if self._connected:
            raise AlreadyConnectedError("Already connected to a chain")

        if isinstance(min_gas_price, str):
            min_gas_price = Wei.from_str(min_gas_price)
        if isinstance(block_base_fee_per_gas, str):
            block_base_fee_per_gas = Wei.from_str(block_base_fee_per_gas)

        self._chain_interface = chain_interfaces_manager.get_or_create(
            uri, accounts=accounts, chain_id=chain_id, fork=fork, hardfork=hardfork
        )

        try:
            self._connected = True
            self._client_version = self._chain_interface.get_client_version()

            try:
                self._chain_interface.debug_trace_call(
                    {
                        "type": 0,
                    }
                )
                self._debug_trace_call_supported = True
            except (JsonRpcError, HTTPError):
                self._debug_trace_call_supported = False

            self._chain_id = self._chain_interface.get_chain_id()

            # determine the forked chain id (if any)
            # determine the chain hardfork to set the default tx type
            if isinstance(self._chain_interface, AnvilChainInterface):
                info = self._chain_interface.node_info()

                if (
                    "forkConfig" in info
                    and "forkUrl" in info["forkConfig"]
                    and info["forkConfig"]["forkUrl"] is not None
                ):
                    forked_chain_interface = ChainInterfaceAbc.connect(
                        get_config(), info["forkConfig"]["forkUrl"]
                    )
                    try:
                        self._forked_chain_id = forked_chain_interface.get_chain_id()
                    finally:
                        forked_chain_interface.close()
                else:
                    self._forked_chain_id = None

                hardfork = info["hardFork"]
                if hardfork in {
                    "FRONTIER",
                    "Frontier",
                    "HOMESTEAD",
                    "Homestead",
                    "TANGERINE",
                    "Tangerine",
                    "SPURIOUS_DRAGON",
                    "SpuriousDragon",
                    "BYZANTIUM",
                    "Byzantium",
                    "CONSTANTINOPLE",
                    "Constantinople",
                    "PETERSBURG",
                    "Petersburg",
                    "ISTANBUL",
                    "Istanbul",
                    "MUIR_GLACIER",
                    "MuirGlacier",
                }:
                    self._default_tx_type = 0
                elif hardfork in {"BERLIN", "Berlin"}:
                    self._default_tx_type = 1
                else:
                    self._default_tx_type = 2
            elif isinstance(
                self._chain_interface,
                (GethLikeChainInterfaceAbc, HardhatChainInterface),
            ):
                if isinstance(self._chain_interface, GethLikeChainInterfaceAbc):
                    self._forked_chain_id = None
                else:
                    metadata = self._chain_interface.hardhat_metadata()
                    if (
                        "forkedNetwork" in metadata
                        and "chainId" in metadata["forkedNetwork"]
                    ):
                        self._forked_chain_id = metadata["forkedNetwork"]["chainId"]
                    else:
                        self._forked_chain_id = None

                if self._chain_id in {56, 97}:
                    # BSC clients do not fail on the calls below
                    self._default_tx_type = 1
                else:
                    # TODO this is not the correct flow for Hermez:
                    # EIP-1559 txs should be supported
                    # access lists should not be supported
                    # `type` field in txs should not be used
                    # because Hermez does not implement eth_maxPriorityFeePerGas, fallback to legacy txs
                    try:
                        self._chain_interface.call(
                            {
                                "type": 2,
                                "maxPriorityFeePerGas": 0,
                                "to": "0x0000000000000000000000000000000000000000",
                            }
                        )
                        self._default_tx_type = 2
                    except JsonRpcError:
                        try:
                            self._chain_interface.call(
                                {
                                    "type": 1,
                                    "accessList": [],
                                    "to": "0x0000000000000000000000000000000000000000",
                                }
                            )
                            self._default_tx_type = 1
                        except JsonRpcError:
                            self._default_tx_type = 0
            elif isinstance(self._chain_interface, GanacheChainInterface):
                if fork is not None:
                    forked_chain_interface = ChainInterfaceAbc.connect(
                        get_config(), fork.split("@")[0]
                    )
                    try:
                        self._forked_chain_id = forked_chain_interface.get_chain_id()
                    finally:
                        forked_chain_interface.close()
                else:
                    self._forked_chain_id = None

                self._default_tx_type = 0
            else:
                raise NotImplementedError(
                    f"Unknown chain interface type: {type(self._chain_interface)}"
                )

            if block_base_fee_per_gas is not None and not isinstance(
                self._chain_interface, GanacheChainInterface
            ):
                try:
                    self._chain_interface.set_next_block_base_fee_per_gas(
                        block_base_fee_per_gas
                    )
                except JsonRpcError:
                    pass

            from .transactions import ChainTransactions

            self._txs = ChainTransactions(self)

            self._accounts = [
                Account(acc, self) for acc in self._chain_interface.get_accounts()
            ]

            if len(self._accounts) > _test_accounts_generated_count:
                # generate private keys for accounts without private key
                for i in range(_test_accounts_generated_count, len(self._accounts)):
                    Address.from_mnemonic(
                        "test test test test test test test test test test test junk",
                        path=f"m/44'/60'/0'/0/{i}",
                    )
                _test_accounts_generated_count = len(self._accounts)

            self._accounts_set = set(self._accounts)
            self._nonces = KeyedDefaultDict(
                lambda addr: self._chain_interface.get_transaction_count(  # pyright: ignore reportGeneralTypeIssues
                    str(addr)
                )
            )
            self._snapshots = {}
            self._deployed_libraries = defaultdict(list)

            if len(self._accounts) > 0:
                self.set_default_accounts(self._accounts[0])
            else:
                self.set_default_accounts(None)
            self._default_tx_confirmations = 1
            self._blocks = ChainBlocks(self)
            self._labels = {}
            self._fork = fork

            self._single_source_errors = {
                selector
                for selector, sources in errors.items()
                if len({source for fqn, source in sources.items()}) == 1
            }

            self.tx_callback = None

            self._connect_setup(min_gas_price, block_base_fee_per_gas)

            yield self
        except Exception as e:
            if not isinstance(e, BdbQuit):
                exception_handler = get_exception_handler()
                if exception_handler is not None:
                    exception_handler(*sys.exc_info())
                raise
        finally:
            self._connect_finalize()
            self._connected = False

    @property
    def connected(self) -> bool:
        return self._connected

    @property
    def client_version(self) -> str:
        return self._client_version

    @property
    @check_connected
    def chain_interface(self) -> ChainInterfaceAbc:
        return self._chain_interface

    @property
    @check_connected
    def chain_id(self) -> uint256:
        return uint256(self._chain_id)

    @property
    @check_connected
    def accounts(self) -> Tuple[Account, ...]:
        return tuple(self._accounts)

    @property
    @check_connected
    def txs(self) -> ChainTransactions:
        return self._txs

    @property
    @check_connected
    def default_call_account(self) -> Optional[Account]:
        return self._default_call_account

    @default_call_account.setter
    @check_connected
    def default_call_account(self, account: Union[Account, Address, str]) -> None:
        if isinstance(account, Account):
            if account.chain != self:
                raise ValueError("Account is not from this chain")
            self._default_call_account = account
        else:
            self._default_call_account = Account(account, self)

    @property
    @check_connected
    def default_tx_account(self) -> Optional[Account]:
        return self._default_tx_account

    @default_tx_account.setter
    @check_connected
    def default_tx_account(self, account: Union[Account, Address, str]) -> None:
        if isinstance(account, Account):
            if account.chain != self:
                raise ValueError("Account is not from this chain")
            self._default_tx_account = account
        else:
            self._default_tx_account = Account(account, self)

    @property
    @check_connected
    def default_estimate_account(self) -> Optional[Account]:
        return self._default_estimate_account

    @default_estimate_account.setter
    @check_connected
    def default_estimate_account(self, account: Union[Account, Address, str]) -> None:
        if isinstance(account, Account):
            if account.chain != self:
                raise ValueError("Account is not from this chain")
            self._default_estimate_account = account
        else:
            self._default_estimate_account = Account(account, self)

    @property
    @check_connected
    def default_access_list_account(self) -> Optional[Account]:
        return self._default_access_list_account

    @default_access_list_account.setter
    @check_connected
    def default_access_list_account(
        self, account: Union[Account, Address, str]
    ) -> None:
        if isinstance(account, Account):
            if account.chain != self:
                raise ValueError("Account is not from this chain")
            self._default_access_list_account = account
        else:
            self._default_access_list_account = Account(account, self)

    @property
    @check_connected
    def coinbase(self) -> Account:
        return Account(self._chain_interface.get_coinbase(), self)

    @coinbase.setter
    @check_connected
    def coinbase(self, value: Union[Account, Address, str]) -> None:
        if isinstance(value, Account):
            if value.chain != self:
                raise ValueError("Account is not from this chain")
            self._chain_interface.set_coinbase(str(value.address))
        else:
            self._chain_interface.set_coinbase(str(value))

    @property
    @check_connected
    def blocks(self) -> ChainBlocks:
        return self._blocks

    @property
    @check_connected
    def require_signed_txs(self) -> bool:
        return self._require_signed_txs

    @require_signed_txs.setter
    @check_connected
    def require_signed_txs(self, value: bool) -> None:
        self._require_signed_txs = value

    @property
    @check_connected
    def default_tx_type(self) -> int:
        return self._default_tx_type

    @default_tx_type.setter
    @check_connected
    def default_tx_type(self, value: int) -> None:
        if value not in {0, 1, 2}:
            raise ValueError("Invalid transaction type")
        self._default_tx_type = value

    @property
    @check_connected
    def default_tx_confirmations(self) -> int:
        return self._default_tx_confirmations

    @default_tx_confirmations.setter
    @check_connected
    def default_tx_confirmations(self, value: int) -> None:
        if value < 0:
            raise ValueError("Invalid transaction confirmations value")
        self._default_tx_confirmations = value

    @contextmanager
    def change_automine(self, automine: bool):
        if not self._connected:
            raise NotConnectedError("Not connected to a chain")
        automine_was = self._chain_interface.get_automine()
        self._chain_interface.set_automine(automine)
        try:
            yield
        except Exception as e:
            if not isinstance(e, BdbQuit):
                exception_handler = get_exception_handler()
                if exception_handler is not None:
                    exception_handler(*sys.exc_info())
                raise
        finally:
            self._chain_interface.set_automine(automine_was)

    @property
    @check_connected
    def automine(self) -> bool:
        return self._chain_interface.get_automine()

    @automine.setter
    @check_connected
    def automine(self, value: bool) -> None:
        self._chain_interface.set_automine(value)

    @check_connected
    def set_next_block_base_fee_per_gas(self, value: Union[int, str]) -> None:
        if isinstance(value, str):
            value = Wei.from_str(value)
        self._chain_interface.set_next_block_base_fee_per_gas(value)

    @check_connected
    def set_next_block_timestamp(self, timestamp: int) -> None:
        self._chain_interface.set_next_block_timestamp(timestamp)

    @check_connected
    def set_min_gas_price(self, value: Union[int, str]) -> None:
        if isinstance(value, str):
            value = Wei.from_str(value)
        self._chain_interface.set_min_gas_price(value)
        self.gas_price = value

    @check_connected
    def set_default_accounts(self, account: Union[Account, Address, str, None]) -> None:
        if isinstance(account, Account):
            if account.chain != self:
                raise ValueError("Account is not from this chain")
        elif account is not None:
            account = Account(account, self)

        self._default_call_account = account
        self._default_tx_account = account
        self._default_estimate_account = account
        self._default_access_list_account = account

    @check_connected
    def reset(self) -> None:
        self._chain_interface.reset()

    @check_connected
    def update_accounts(self):
        self._accounts = [
            Account(acc, self) for acc in self._chain_interface.get_accounts()
        ]
        self._accounts_set = set(self._accounts)

    @check_connected
    def mine(self, timestamp_change: Optional[Callable[[int], int]] = None) -> None:
        if timestamp_change is not None:
            block_info = self._chain_interface.get_block("latest")
            assert "timestamp" in block_info
            last_timestamp = int(block_info["timestamp"], 16)
            timestamp = timestamp_change(last_timestamp)
        else:
            timestamp = None

        self._chain_interface.mine(timestamp)

    @check_connected
    def mine_many(
        self, num_blocks: int, timestamp_change: Optional[int] = None
    ) -> None:
        self._chain_interface.mine_many(num_blocks, timestamp_change)

    @contextmanager
    def snapshot_and_revert(self):
        snapshot_id = self.snapshot()
        try:
            yield
        except Exception as e:
            if not isinstance(e, BdbQuit):
                exception_handler = get_exception_handler()
                if exception_handler is not None:
                    exception_handler(*sys.exc_info())
                raise
        finally:
            self.revert(snapshot_id)

    @overload
    def deploy(
        self,
        creation_code: bytes,
        *,
        request_type: Literal["call"],
        return_tx: Literal[False] = False,
        from_: Optional[Union[Account, Address, str]] = None,
        value: Union[int, str] = 0,
        gas_limit: Optional[Union[int, Literal["max", "auto"]]] = None,
        gas_price: Optional[Union[int, str]] = None,
        max_fee_per_gas: Optional[Union[int, str]] = None,
        max_priority_fee_per_gas: Optional[Union[int, str]] = None,
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ] = None,
        type: Optional[int] = None,
        block: Optional[
            Union[int, Literal["latest", "pending", "earliest", "safe", "finalized"]]
        ] = None,
        confirmations: Optional[int] = None,
    ) -> bytearray:
        ...

    @overload
    def deploy(
        self,
        creation_code: bytes,
        *,
        request_type: Literal["tx"] = "tx",
        return_tx: Literal[False] = False,
        from_: Optional[Union[Account, Address, str]] = None,
        value: Union[int, str] = 0,
        gas_limit: Optional[Union[int, Literal["max", "auto"]]] = None,
        gas_price: Optional[Union[int, str]] = None,
        max_fee_per_gas: Optional[Union[int, str]] = None,
        max_priority_fee_per_gas: Optional[Union[int, str]] = None,
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ] = None,
        type: Optional[int] = None,
        block: Optional[
            Union[int, Literal["latest", "pending", "earliest", "safe", "finalized"]]
        ] = None,
        confirmations: Optional[int] = None,
    ) -> Contract:
        ...

    @overload
    def deploy(
        self,
        creation_code: bytes,
        *,
        request_type: Literal["estimate"],
        return_tx: Literal[False] = False,
        from_: Optional[Union[Account, Address, str]] = None,
        value: Union[int, str] = 0,
        gas_limit: Optional[Union[int, Literal["max", "auto"]]] = None,
        gas_price: Optional[Union[int, str]] = None,
        max_fee_per_gas: Optional[Union[int, str]] = None,
        max_priority_fee_per_gas: Optional[Union[int, str]] = None,
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ] = None,
        type: Optional[int] = None,
        block: Optional[
            Union[int, Literal["latest", "pending", "earliest", "safe", "finalized"]]
        ] = None,
        confirmations: Optional[int] = None,
    ) -> int:
        ...

    @overload
    def deploy(
        self,
        creation_code: bytes,
        *,
        request_type: Literal["access_list"],
        return_tx: Literal[False] = False,
        from_: Optional[Union[Account, Address, str]] = None,
        value: Union[int, str] = 0,
        gas_limit: Optional[Union[int, Literal["max", "auto"]]] = None,
        gas_price: Optional[Union[int, str]] = None,
        max_fee_per_gas: Optional[Union[int, str]] = None,
        max_priority_fee_per_gas: Optional[Union[int, str]] = None,
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ] = None,
        type: Optional[int] = None,
        block: Optional[
            Union[int, Literal["latest", "pending", "earliest", "safe", "finalized"]]
        ] = None,
        confirmations: Optional[int] = None,
    ) -> Tuple[Dict[Address, List[int]], int]:
        ...

    @overload
    def deploy(
        self,
        creation_code: bytes,
        *,
        request_type: Literal["tx"] = "tx",
        return_tx: Literal[True],
        from_: Optional[Union[Account, Address, str]] = None,
        value: Union[int, str] = 0,
        gas_limit: Optional[Union[int, Literal["max", "auto"]]] = None,
        gas_price: Optional[Union[int, str]] = None,
        max_fee_per_gas: Optional[Union[int, str]] = None,
        max_priority_fee_per_gas: Optional[Union[int, str]] = None,
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ] = None,
        type: Optional[int] = None,
        block: Optional[
            Union[int, Literal["latest", "pending", "earliest", "safe", "finalized"]]
        ] = None,
        confirmations: Optional[int] = None,
    ) -> TransactionAbc[Contract]:
        ...

    def deploy(
        self,
        creation_code: bytes,
        *,
        request_type: RequestType = "tx",
        return_tx: bool = False,
        from_: Optional[Union[Account, Address, str]] = None,
        value: Union[int, str] = 0,
        gas_limit: Optional[Union[int, Literal["max", "auto"]]] = None,
        gas_price: Optional[Union[int, str]] = None,
        max_fee_per_gas: Optional[Union[int, str]] = None,
        max_priority_fee_per_gas: Optional[Union[int, str]] = None,
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ] = None,
        type: Optional[int] = None,
        block: Optional[
            Union[int, Literal["latest", "pending", "earliest", "safe", "finalized"]]
        ] = None,
        confirmations: Optional[int] = None,
    ) -> Union[
        bytearray,
        Contract,
        int,
        Tuple[Dict[Address, List[int]], int],
        TransactionAbc[Contract],
    ]:
        return Contract._execute(
            self,
            request_type,
            creation_code.hex(),
            [],
            return_tx,
            Contract,
            from_,
            None,
            value,
            gas_limit,
            gas_price,
            max_fee_per_gas,
            max_priority_fee_per_gas,
            access_list,
            type,
            block,
            confirmations,
        )

    def _update_nonce(self, address: Address, nonce: int) -> None:
        self._nonces[address] = nonce

    def _convert_to_web3_type(self, value: Any) -> Any:
        if dataclasses.is_dataclass(value):
            return tuple(
                self._convert_to_web3_type(getattr(value, f.name))
                for f in dataclasses.fields(value)
            )
        elif isinstance(value, list):
            return [self._convert_to_web3_type(v) for v in value]
        elif isinstance(value, tuple):
            return tuple(self._convert_to_web3_type(v) for v in value)
        elif isinstance(value, Account):
            if value.chain != self:
                raise ValueError("Account must belong to this chain")
            return str(value.address)
        elif isinstance(value, Address):
            return str(value)
        elif hasattr(value, "selector") and isinstance(value.selector, bytes):
            instance = value.__self__
            return bytes.fromhex(str(instance.address)[2:]) + value.selector
        else:
            return value

    def _parse_console_log_data(self, data: bytes):
        selector = data[:4]

        if selector not in hardhat_console.abis:
            raise ValueError(f"Unknown selector: {selector.hex()}")
        abi = hardhat_console.abis[selector]

        output_types = [
            eth_utils.abi.collapse_if_tuple(cast(Dict[str, Any], arg))
            for arg in fix_library_abi(abi)
        ]
        decoded_data = list(Abi.decode(output_types, data[4:]))
        for i in range(len(decoded_data)):
            if abi[i]["type"] == "address":
                decoded_data[i] = Account(decoded_data[i], self)

        if len(decoded_data) == 1:
            decoded_data = decoded_data[0]

        return decoded_data

    def _convert_from_web3_type(
        self, tx: Optional[TransactionAbc], value: Any, expected_type: Type
    ) -> Any:
        origin = get_origin(expected_type)

        if isinstance(expected_type, type(None)):
            return None
        elif expected_type is Callable:
            assert isinstance(value, bytes)
            address = Address("0x" + value[:20].hex())
            fqn = get_fqn_from_address(
                address, tx.block.number - 1 if tx is not None else "latest", self
            )
            if fqn not in contracts_by_fqn:
                raise ValueError(f"Unknown contract: {fqn}")

            module_name, attrs = contracts_by_fqn[fqn]
            obj = getattr(importlib.import_module(module_name), attrs[0])
            for attr in attrs[1:]:
                obj = getattr(obj, attr)

            selector = value[20:24]

            for x in dir(obj):
                m = getattr(obj, x)
                if hasattr(m, "selector") and m.selector == selector:
                    return getattr(obj(address, self), x)

            raise ValueError(
                f"Unable to find function with selector {selector.hex()} in contract {fqn}"
            )
        elif isinstance(origin, type) and issubclass(origin, list):
            return origin(
                [
                    self._convert_from_web3_type(tx, v, get_args(expected_type)[0])
                    for v in value
                ]
            )
        elif isinstance(expected_type, type) and issubclass(expected_type, Integer):
            return expected_type(value)
        elif isinstance(expected_type, type) and issubclass(
            expected_type, FixedSizeBytes
        ):
            return expected_type(value)
        elif origin is tuple:
            return tuple(
                self._convert_from_web3_type(tx, v, t)
                for v, t in zip(value, get_args(expected_type))
            )
        elif dataclasses.is_dataclass(expected_type):
            assert isinstance(value, tuple)
            resolved_types = get_type_hints(
                expected_type  # pyright: ignore reportGeneralTypeIssues
            )
            field_types = [
                resolved_types[field.name]
                for field in dataclasses.fields(expected_type)
                if field.init
            ]
            assert len(value) == len(field_types)
            converted_values = [
                self._convert_from_web3_type(tx, v, t)
                for v, t in zip(value, field_types)
            ]
            return expected_type(*converted_values)
        elif isinstance(expected_type, type):
            if issubclass(expected_type, Contract):
                return expected_type(value, self)
            elif issubclass(expected_type, Account):
                return Account(value, self)
            elif issubclass(expected_type, IntEnum):
                return expected_type(value)
        return value

    def _process_revert_data(
        self,
        tx: Optional[TransactionAbc],
        revert_data: bytes,
    ) -> TransactionRevertedError:
        from .transactions import UnknownTransactionRevertedError

        selector = revert_data[0:4]
        if selector not in errors:
            e = UnknownTransactionRevertedError(revert_data)
            e.tx = tx
            raise e from None

        if selector not in self._single_source_errors:
            if tx is None:
                e = UnknownTransactionRevertedError(revert_data)
                e.tx = tx
                raise e from None

            # ambiguous error, try to find the source contract
            tx._fetch_debug_trace_transaction()
            debug_trace = tx._debug_trace_transaction
            try:
                fqn_overrides: ChainMap[Address, Optional[str]] = ChainMap()
                for i in range(tx.tx_index):
                    prev_tx = tx.block.txs[i]
                    prev_tx._fetch_debug_trace_transaction()
                    process_debug_trace_for_fqn_overrides(
                        prev_tx,
                        prev_tx._debug_trace_transaction,  # pyright: ignore reportGeneralTypeIssues
                        fqn_overrides,
                    )
                fqn = process_debug_trace_for_revert(
                    tx,
                    debug_trace,  # pyright: ignore reportGeneralTypeIssues
                    fqn_overrides,
                )
            except ValueError:
                e = UnknownTransactionRevertedError(revert_data)
                e.tx = tx
                raise e from None
        else:
            fqn = list(errors[selector].keys())[0]

        module_name, attrs = errors[selector][fqn]
        obj = getattr(importlib.import_module(module_name), attrs[0])
        for attr in attrs[1:]:
            obj = getattr(obj, attr)
        abi = obj._abi

        types = [
            eth_utils.abi.collapse_if_tuple(cast(Dict[str, Any], arg))
            for arg in fix_library_abi(abi["inputs"])
        ]
        decoded = Abi.decode(types, revert_data[4:])
        generated_error = self._convert_from_web3_type(tx, decoded, obj)
        generated_error.tx = tx
        return generated_error

    def _process_events(self, tx: TransactionAbc) -> list:
        fqn_overrides: ChainMap[Address, Optional[str]] = ChainMap()
        generated_events = []

        # process fqn_overrides for all txs before this one in the same block
        for i in range(tx.tx_index):
            tx_before = tx.block.txs[i]
            tx_before._fetch_debug_trace_transaction()
            process_debug_trace_for_fqn_overrides(
                tx_before,
                tx_before._debug_trace_transaction,  # pyright: ignore reportGeneralTypeIssues
                fqn_overrides,
            )
        assert len(fqn_overrides.maps) == 1

        logs = sorted(
            (
                l
                for l in tx.chain.chain_interface.get_logs(
                    from_block=tx.block.number, to_block=tx.block.number
                )
                if l["transactionHash"] == tx.tx_hash
            ),
            key=lambda l: int(l["logIndex"], 16),
        )
        for log in logs:
            topics = [
                bytes.fromhex(t[2:].zfill(64))
                if t.startswith("0x")
                else bytes.fromhex(t.zfill(64))
                for t in log["topics"]
            ]
            data = (
                bytes.fromhex(log["data"][2:])
                if log["data"].startswith("0x")
                else bytes.fromhex(log["data"])
            )
            address = Address(log["address"])
            unknown_event = UnknownEvent(topics, data)
            unknown_event.origin = Account(address, tx.chain)

            if len(topics) == 0:
                generated_events.append(unknown_event)
                continue

            selector = topics[0]

            if selector not in events:
                generated_events.append(unknown_event)
                continue

            if len({source for source in events[selector].values()}) > 1:
                addresses = {address}

                if isinstance(tx.chain.chain_interface, AnvilChainInterface):
                    tx._fetch_trace_transaction()
                    trace = tx._trace_transaction
                    assert trace is not None

                    # `address` may not be the address of the syntatic contract that emitted the event
                    # it may be the address of a contract that delegatecalled into the syntatic contract
                    # find all delegatecalls from `address` recursively
                    finished = False
                    while not finished:
                        finished = True
                        for t in trace:
                            if (
                                "callType" in t["action"]
                                and t["action"]["callType"] == "delegatecall"
                                and Address(t["action"]["from"]) in addresses
                            ):
                                to = Address(t["action"]["to"])
                                if to not in addresses:
                                    addresses.add(to)
                                    finished = False

                candidates = []
                for a in addresses:
                    if a in fqn_overrides:
                        fqn = fqn_overrides[a]
                    else:
                        fqn = get_fqn_from_address(a, tx.block.number - 1, tx.chain)

                    if fqn is None:
                        continue

                    for base_fqn in contracts_inheritance[fqn]:
                        if base_fqn in events[selector]:
                            candidates.append(base_fqn)
                            break

                if len(candidates) != 1:
                    generated_events.append(unknown_event)
                    continue
                else:
                    fqn = candidates[0]
            else:
                fqn = list(events[selector].keys())[0]

            module_name, attrs = events[selector][fqn]
            obj = getattr(importlib.import_module(module_name), attrs[0])
            for attr in attrs[1:]:
                obj = getattr(obj, attr)
            abi = obj._abi

            topic_index = 1
            types = []

            decoded_indexed = []

            for input in fix_library_abi(abi["inputs"]):
                if input["indexed"]:
                    if (
                        input["type"] in {"string", "bytes"}
                        or input["internalType"].startswith("struct ")
                        or input["type"].endswith("]")
                    ):
                        topic_type = "bytes32"
                    else:
                        topic_type = input["type"]
                    topic_data = log["topics"][topic_index]
                    if topic_data.startswith("0x"):
                        topic_data = topic_data[2:]
                    decoded_indexed.append(
                        Abi.decode([topic_type], bytes.fromhex(topic_data.zfill(64)))[0]
                    )
                    topic_index += 1
                else:
                    types.append(eth_utils.abi.collapse_if_tuple(input))
            decoded = list(
                Abi.decode(
                    types,
                    bytes.fromhex(log["data"][2:])
                    if log["data"].startswith("0x")
                    else bytes.fromhex(log["data"]),
                )
            )
            merged = []

            for input in abi["inputs"]:
                if input["indexed"]:
                    merged.append(decoded_indexed.pop(0))
                else:
                    merged.append(decoded.pop(0))

            merged = tuple(merged)
            generated_event = self._convert_from_web3_type(tx, merged, obj)
            generated_event.origin = Account(address, tx.chain)
            generated_events.append(generated_event)

        return generated_events

    def _process_return_data(
        self, tx: Optional[TransactionAbc], output: bytes, abi: Dict, return_type: Type
    ):
        output_types = [
            eth_utils.abi.collapse_if_tuple(cast(Dict[str, Any], arg))
            for arg in fix_library_abi(abi["outputs"])
        ]
        decoded_data = Abi.decode(output_types, output)
        if isinstance(decoded_data, (list, tuple)) and len(decoded_data) == 1:
            decoded_data = decoded_data[0]
        return self._convert_from_web3_type(tx, decoded_data, return_type)

    def _process_console_logs(self, trace_output: List[Dict[str, Any]]) -> List:
        hardhat_console_address = bytes.fromhex(
            "000000000000000000636F6e736F6c652e6c6f67"
        )
        console_logs = []
        for trace in trace_output:
            if "action" in trace and "to" in trace["action"]:
                to = trace["action"]["to"]
                if to.startswith("0x"):
                    to = to[2:]
                if bytes.fromhex(to) == hardhat_console_address:
                    input = trace["action"]["input"]
                    if input.startswith("0x"):
                        input = input[2:]
                    console_logs.append(
                        self._parse_console_log_data(bytes.fromhex(input))
                    )
        return console_logs

    def _process_console_logs_from_debug_trace(
        self, debug_trace: Dict[str, Any]
    ) -> List:
        hardhat_console_address = Address("0x000000000000000000636F6e736F6c652e6c6f67")
        console_logs = []
        for trace in debug_trace["structLogs"]:
            if trace["op"] == "STATICCALL":
                addr = Address(int(trace["stack"][-2], 16))
                if addr == hardhat_console_address:
                    args_offset = int(trace["stack"][-3], 16)
                    args_size = int(trace["stack"][-4], 16)
                    data = bytes(
                        read_from_memory(args_offset, args_size, trace["memory"])
                    )
                    console_logs.append(self._parse_console_log_data(data))

        return console_logs

    def _process_call_revert_data(self, e: JsonRpcError) -> bytes:
        try:
            # Hermez does not provide revert data for estimate
            if (
                isinstance(
                    self._chain_interface,
                    (AnvilChainInterface, GethLikeChainInterfaceAbc),
                )
                and e.data["code"] == 3
            ):
                revert_data = e.data["data"]
            elif (
                isinstance(self._chain_interface, GanacheChainInterface)
                and e.data["code"] == -32000
            ):
                revert_data = e.data["data"]
            elif (
                isinstance(self._chain_interface, HardhatChainInterface)
                and e.data["code"] == -32603
            ):
                revert_data = e.data["data"]["data"]
            else:
                raise e from None
        except Exception:
            raise e from None

        if revert_data.startswith("0x"):
            revert_data = revert_data[2:]

        return bytes.fromhex(revert_data)

    def _process_call_revert(self, e: JsonRpcError) -> TransactionRevertedError:

        return self._process_revert_data(None, self._process_call_revert_data(e))

    def _send_transaction(
        self, tx_params: TxParams, from_: Optional[Union[Account, Address, str]]
    ) -> str:
        assert "from" in tx_params
        assert "nonce" in tx_params

        if self._chain_id in {56, 97}:
            # BSC doesn't support access lists and tx type
            tx_params.pop("type", None)
            tx_params.pop("accessList", None)

        self._confirm_transaction(tx_params)

        if self.require_signed_txs:
            if isinstance(from_, (Account, Address)):
                key = from_.private_key
            elif from_ is None and self._default_tx_account is not None:
                key = self._default_tx_account.private_key
            else:
                key = None

            tx_params["from"] = eth_utils.address.to_checksum_address(tx_params["from"])

            if "to" in tx_params:
                tx_params["to"] = eth_utils.address.to_checksum_address(tx_params["to"])

            if Account(tx_params["from"], self) in self._accounts_set:
                try:
                    tx_hash = self._chain_interface.send_transaction(tx_params)
                except (ValueError, JsonRpcError) as e:
                    try:
                        tx_hash = e.args[0]["data"]["txHash"]
                    except Exception:
                        raise e from None
            elif key is not None:
                signed_tx = bytes(
                    eth_account.Account.sign_transaction(tx_params, key).rawTransaction
                )
                try:
                    tx_hash = self._chain_interface.send_raw_transaction(signed_tx)
                except (ValueError, JsonRpcError) as e:
                    try:
                        tx_hash = e.args[0]["data"]["txHash"]
                    except Exception:
                        raise e from None
            else:
                raise ValueError(
                    f"Private key for account {tx_params['from']} not known and is not owned by the connected client either."
                )
            self._update_nonce(Address(tx_params["from"]), tx_params["nonce"] + 1)
        else:
            if isinstance(self.chain_interface, AnvilChainInterface):
                try:
                    tx_hash = self.chain_interface.send_unsigned_transaction(tx_params)
                except (ValueError, JsonRpcError) as e:
                    try:
                        tx_hash = e.args[0]["data"]["txHash"]
                    except Exception:
                        raise e
                self._update_nonce(Address(tx_params["from"]), tx_params["nonce"] + 1)
            else:
                sender = Account(tx_params["from"], self)

                with _signer_account(sender):
                    try:
                        tx_hash = self._chain_interface.send_transaction(tx_params)
                    except (ValueError, JsonRpcError) as e:
                        try:
                            tx_hash = e.args[0]["data"]["txHash"]
                        except Exception:
                            raise e
                    self._update_nonce(sender.address, tx_params["nonce"] + 1)

        self._txs.register_tx(tx_hash)

        return tx_hash

    @check_connected
    def _call(
        self,
        abi: Optional[Dict],
        arguments: Iterable,
        params: TxParams,
        return_type: Type,
        block: Union[int, str],
    ) -> Any:
        tx_params = self._build_transaction(RequestType.CALL, params, arguments, abi)
        try:
            coverage_handler = get_coverage_handler()
            if coverage_handler is not None and self._debug_trace_call_supported:
                ret = self._chain_interface.debug_trace_call(tx_params, block)
                coverage_handler.add_coverage(tx_params, self, ret)

                ret_value = ret["returnValue"]
                if ret_value.startswith("0x"):
                    ret_value = ret_value[2:]
                output = bytes.fromhex(ret_value)
                if ret["failed"]:
                    raise self._process_revert_data(None, output) from None
            else:
                output = self._chain_interface.call(tx_params, block)
        except JsonRpcError as e:
            raise self._process_call_revert(e) from None

        # deploy
        if "to" not in params:
            return bytearray(output)

        assert abi is not None
        return self._process_return_data(None, output, abi, return_type)

    @check_connected
    def _estimate(
        self,
        abi: Optional[Dict],
        arguments: Iterable,
        params: TxParams,
        block: Union[int, str],
    ) -> int:
        tx_params = self._build_transaction(
            RequestType.ESTIMATE, params, arguments, abi
        )
        try:
            return self._chain_interface.estimate_gas(tx_params, block)
        except JsonRpcError as e:
            raise self._process_call_revert(e) from None

    @check_connected
    def _access_list(
        self,
        abi: Optional[Dict],
        arguments: Iterable,
        params: TxParams,
        block: Union[int, str],
    ):
        tx_params = self._build_transaction(
            RequestType.ACCESS_LIST, params, arguments, abi
        )
        try:
            response = self._chain_interface.create_access_list(tx_params, block)
            return {
                Address(e["address"]): [int(s, 16) for s in e["storageKeys"]]
                for e in response["accessList"]
            }, int(response["gasUsed"], 16)
        except JsonRpcError as e:
            raise self._process_call_revert(e) from None

    @check_connected
    def _transact(
        self,
        abi: Optional[Dict],
        arguments: Iterable,
        params: TxParams,
        return_tx: bool,
        return_type: Type,
        confirmations: Optional[int],
        from_: Optional[Union[Account, Address, str]],
    ) -> Any:
        tx_params = self._build_transaction(RequestType.TX, params, arguments, abi)

        tx_hash = self._send_transaction(tx_params, from_)

        if "type" not in tx_params:
            from wake.development.transactions import LegacyTransaction

            tx_type = LegacyTransaction[return_type]
        elif tx_params["type"] == 1:
            from wake.development.transactions import Eip2930Transaction

            tx_type = Eip2930Transaction[return_type]
        elif tx_params["type"] == 2:
            from wake.development.transactions import Eip1559Transaction

            tx_type = Eip1559Transaction[return_type]
        else:
            raise ValueError(f"Unknown transaction type {tx_params['type']}")

        tx = tx_type(
            tx_hash,
            tx_params,
            abi,
            return_type,
            self,
        )

        if confirmations != 0:
            tx.wait(confirmations)

            coverage_handler = get_coverage_handler()
            if coverage_handler is not None:
                tx._fetch_debug_trace_transaction()
                coverage_handler.add_coverage(
                    tx_params,
                    self,
                    tx._debug_trace_transaction,  # pyright: ignore reportGeneralTypeIssues
                )

            if self.tx_callback is not None:
                self.tx_callback(tx)

            if tx.error is not None:
                raise tx.error

        if return_tx:
            return tx

        return tx.return_value


@contextmanager
def _signer_account(sender: Account):
    chain = sender.chain
    chain_interface = chain.chain_interface
    account_created = True
    if sender not in chain.accounts:
        account_created = False
        if isinstance(chain_interface, (AnvilChainInterface, HardhatChainInterface)):
            chain_interface.impersonate_account(str(sender))
        elif isinstance(chain_interface, GanacheChainInterface):
            chain_interface.add_account(str(sender), "")
            chain.update_accounts()
        else:
            raise NotImplementedError()

    try:
        yield
    finally:
        if not account_created and isinstance(
            chain_interface, (AnvilChainInterface, HardhatChainInterface)
        ):
            chain_interface.stop_impersonating_account(str(sender))


def get_fqn_from_creation_code(creation_code: bytes) -> Tuple[str, int]:
    for creation_code_segments, fqn in creation_code_index:

        length, h = creation_code_segments[0]
        if length > len(creation_code):
            continue
        segment_h = BLAKE2b.new(data=creation_code[:length], digest_bits=256).digest()
        if segment_h != h:
            continue

        creation_code = creation_code[length:]
        found = True
        constructor_offset = length

        for length, h in creation_code_segments[1:]:
            if length + 20 > len(creation_code):
                found = False
                break
            creation_code = creation_code[20:]
            segment_h = BLAKE2b.new(
                data=creation_code[:length], digest_bits=256
            ).digest()
            if segment_h != h:
                found = False
                break
            creation_code = creation_code[length:]
            constructor_offset += length + 20

        if found:
            return fqn, constructor_offset

    raise ValueError("Could not find contract definition from creation code")


def get_fqn_from_address(
    addr: Address, block_number: Union[int, str], chain: Chain
) -> Optional[str]:
    code = chain.chain_interface.get_code(str(addr), block_number)
    metadata_length = int.from_bytes(code[-2:], "big")
    metadata = code[-metadata_length - 2 : -2]
    if metadata in contracts_by_metadata:
        return contracts_by_metadata[metadata]
    else:
        return None


def get_contract_from_fqn(fqn: str):
    return contracts_by_fqn[fqn]


def process_debug_trace_for_fqn_overrides(
    tx: TransactionAbc,
    debug_trace: Dict[str, Any],
    fqn_overrides: ChainMap[Address, Optional[str]],
) -> None:
    if tx.status == 0:
        return

    trace_is_create = [tx.to is None]
    addresses: List[Optional[Address]] = [tx.to.address if tx.to is not None else None]
    fqns: List[Optional[str]] = []

    fqn_overrides.maps.insert(0, {})

    if tx.to is None:
        fqns.append(None)  # contract is not deployed yet
    else:
        if tx.to.address in fqn_overrides:
            fqns.append(fqn_overrides[tx.to.address])
        else:
            fqns.append(
                get_fqn_from_address(tx.to.address, tx.block.number - 1, tx.chain)
            )

    for i, trace in enumerate(debug_trace["structLogs"]):
        if i > 0:
            prev_trace = debug_trace["structLogs"][i - 1]
            if (
                prev_trace["op"] in {"CALL", "CALLCODE", "DELEGATECALL", "STATICCALL"}
                and prev_trace["depth"] == trace["depth"]
            ):
                # precompiled contract was called in the previous trace
                fqn_overrides.maps[1].update(fqn_overrides.maps[0])
                fqn_overrides.maps.pop(0)
                trace_is_create.pop()
                addresses.pop()
                fqns.pop()

        if trace["op"] in {"CALL", "CALLCODE", "DELEGATECALL", "STATICCALL"}:
            trace_is_create.append(False)
            addr = Address(int(trace["stack"][-2], 16))
            addresses.append(addr)
            if addr in fqn_overrides:
                fqns.append(fqn_overrides[addr])
            else:
                fqns.append(get_fqn_from_address(addr, tx.block.number - 1, tx.chain))

            fqn_overrides.maps.insert(0, {})
        elif trace["op"] in {"CREATE", "CREATE2"}:
            offset = int(trace["stack"][-2], 16)
            length = int(trace["stack"][-3], 16)
            creation_code = read_from_memory(offset, length, trace["memory"])

            trace_is_create.append(True)
            addresses.append(None)
            fqns.append(get_fqn_from_creation_code(creation_code)[0])
            fqn_overrides.maps.insert(0, {})
        elif trace["op"] in {"INVALID", "RETURN", "REVERT", "STOP", "SELFDESTRUCT"}:
            if trace["op"] == "SELFDESTRUCT":
                if addresses[-1] is not None:
                    fqn_overrides.maps[0][addresses[-1]] = None

            if trace["op"] not in {"INVALID", "REVERT"} and len(fqn_overrides.maps) > 1:
                fqn_overrides.maps[1].update(fqn_overrides.maps[0])
            fqn_overrides.maps.pop(0)
            addresses.pop()

            if trace_is_create.pop():
                try:
                    addr = Address(
                        int(debug_trace["structLogs"][i + 1]["stack"][-1], 16)
                    )
                    if addr != Address(0):
                        fqn_overrides.maps[0][addr] = fqns[-1]
                except IndexError:
                    pass
            fqns.pop()


def process_debug_trace_for_revert(
    tx: TransactionAbc,
    debug_trace: Dict,
    fqn_overrides: ChainMap[Address, Optional[str]],
) -> str:
    if tx.to is None:
        origin = get_fqn_from_creation_code(tx.data)[0]
    elif tx.to.address in fqn_overrides:
        origin = fqn_overrides[tx.to.address]
    else:
        origin = get_fqn_from_address(tx.to.address, tx.block.number - 1, tx.chain)

    addresses: List[Optional[Address]] = [tx.to.address if tx.to is not None else None]
    fqns: List[Optional[str]] = [origin]
    trace_is_create: List[bool] = [tx.to is None]
    last_revert_origin = None

    for i, trace in enumerate(debug_trace["structLogs"]):
        if i > 0:
            prev_trace = debug_trace["structLogs"][i - 1]
            if (
                prev_trace["op"] in {"CALL", "CALLCODE", "DELEGATECALL", "STATICCALL"}
                and prev_trace["depth"] == trace["depth"]
            ):
                # precompiled contract was called in the previous trace
                fqn_overrides.maps[1].update(fqn_overrides.maps[0])
                fqn_overrides.maps.pop(0)
                addresses.pop()
                fqns.pop()
                trace_is_create.pop()

        if trace["op"] in {"CALL", "CALLCODE", "DELEGATECALL", "STATICCALL"}:
            trace_is_create.append(False)
            addr = Address(int(trace["stack"][-2], 16))
            addresses.append(addr)
            if addr in fqn_overrides:
                fqns.append(fqn_overrides[addr])
            else:
                fqns.append(get_fqn_from_address(addr, tx.block.number - 1, tx.chain))

            fqn_overrides.maps.insert(0, {})
        elif trace["op"] in {"CREATE", "CREATE2"}:
            offset = int(trace["stack"][-2], 16)
            length = int(trace["stack"][-3], 16)
            creation_code = read_from_memory(offset, length, trace["memory"])

            trace_is_create.append(True)
            addresses.append(None)
            fqns.append(get_fqn_from_creation_code(creation_code)[0])
            fqn_overrides.maps.insert(0, {})
        elif trace["op"] in {"INVALID", "REVERT"}:
            pc = trace["pc"]
            fqn_overrides.maps.pop(0)
            fqn = fqns.pop()
            addresses.pop()
            is_create = trace_is_create.pop()

            if trace["op"] == "REVERT":
                if (
                    is_create
                    and fqn in contracts_revert_constructor_index
                    and pc in contracts_revert_constructor_index[fqn]
                ):
                    last_revert_origin = fqn
                elif (
                    not is_create
                    and fqn in contracts_revert_index
                    and pc in contracts_revert_index[fqn]
                ):
                    last_revert_origin = fqn
        elif trace["op"] in {"RETURN", "STOP", "SELFDESTRUCT"}:
            if len(fqn_overrides.maps) > 1:
                fqn_overrides.maps[1].update(fqn_overrides.maps[0])
            fqn_overrides.maps.pop(0)
            addresses.pop()

            if trace_is_create.pop():
                try:
                    addr = Address(
                        int(debug_trace["structLogs"][i + 1]["stack"][-1], 16)
                    )
                    if addr != Address(0):
                        fqn_overrides.maps[0][addr] = fqns[-1]
                except IndexError:
                    pass
            fqns.pop()

    if last_revert_origin is None:
        raise ValueError("Could not find revert origin")
    return last_revert_origin


def process_debug_trace_for_events(
    tx: TransactionAbc,
    debug_trace: Dict,
    fqn_overrides: ChainMap[Address, Optional[str]],
) -> List[Tuple[bytes, Optional[str]]]:
    if tx.to is None:
        origin = get_fqn_from_creation_code(tx.data)[0]
    elif tx.to.address in fqn_overrides:
        origin = fqn_overrides[tx.to.address]
    else:
        origin = get_fqn_from_address(tx.to.address, tx.block.number - 1, tx.chain)

    addresses: List[Optional[Address]] = [tx.to.address if tx.to is not None else None]
    fqns: List[Optional[str]] = [origin]
    trace_is_create: List[bool] = [tx.to is None]
    event_fqns = []

    for i, trace in enumerate(debug_trace["structLogs"]):
        if i > 0:
            prev_trace = debug_trace["structLogs"][i - 1]
            if (
                prev_trace["op"] in {"CALL", "CALLCODE", "DELEGATECALL", "STATICCALL"}
                and prev_trace["depth"] == trace["depth"]
            ):
                # precompiled contract was called in the previous trace
                fqn_overrides.maps[1].update(fqn_overrides.maps[0])
                fqn_overrides.maps.pop(0)
                trace_is_create.pop()
                addresses.pop()
                fqns.pop()

        if trace["op"] in {"CALL", "CALLCODE", "DELEGATECALL", "STATICCALL"}:
            trace_is_create.append(False)
            addr = Address(int(trace["stack"][-2], 16))
            addresses.append(addr)
            if addr in fqn_overrides:
                fqns.append(fqn_overrides[addr])
            else:
                fqns.append(get_fqn_from_address(addr, tx.block.number - 1, tx.chain))

            fqn_overrides.maps.insert(0, {})
        elif trace["op"] in {"CREATE", "CREATE2"}:
            offset = int(trace["stack"][-2], 16)
            length = int(trace["stack"][-3], 16)
            creation_code = read_from_memory(offset, length, trace["memory"])

            trace_is_create.append(True)
            addresses.append(None)
            fqns.append(get_fqn_from_creation_code(creation_code)[0])
            fqn_overrides.maps.insert(0, {})
        elif trace["op"] in {"INVALID", "RETURN", "REVERT", "STOP", "SELFDESTRUCT"}:
            if trace["op"] not in {"INVALID", "REVERT"} and len(fqn_overrides.maps) > 1:
                fqn_overrides.maps[1].update(fqn_overrides.maps[0])
            fqn_overrides.maps.pop(0)
            addresses.pop()

            if trace_is_create.pop():
                try:
                    addr = Address(
                        int(debug_trace["structLogs"][i + 1]["stack"][-1], 16)
                    )
                    if addr != Address(0):
                        fqn_overrides.maps[0][addr] = fqns[-1]
                except IndexError:
                    pass
            fqns.pop()
        elif trace["op"] in {"LOG1", "LOG2", "LOG3", "LOG4"}:
            selector = trace["stack"][-3]
            if selector.startswith("0x"):
                selector = selector[2:]
            selector = bytes.fromhex(selector.zfill(64))
            event_fqns.append((selector, fqns[-1]))

    return event_fqns


LIBRARY_PLACEHOLDER_REGEX = re.compile(r"__\$[0-9a-fA-F]{34}\$__")


class Contract(Account):
    _abi: Dict[
        Union[bytes, Literal["constructor"], Literal["fallback"], Literal["receive"]],
        Any,
    ]
    _creation_code: str

    def __init__(
        self, addr: Union[Account, Address, str], chain: Optional[Chain] = None
    ):
        if isinstance(addr, Account):
            if chain is None:
                chain = addr.chain
            elif addr.chain != chain:
                raise ValueError("Account and chain must be from the same chain")
            addr = addr.address
        super().__init__(addr, chain)

    def __str__(self):
        return self._chain._labels.get(
            self.address, f"{self.__class__.__name__}({self.address})"
        )

    __repr__ = __str__

    @classmethod
    def _get_creation_code(
        cls, libraries: Dict[bytes, Tuple[Union[Account, Address], str]]
    ) -> bytes:
        creation_code = cls._creation_code
        for match in LIBRARY_PLACEHOLDER_REGEX.finditer(creation_code):
            lib_id = bytes.fromhex(match.group(0)[3:-3])
            assert (
                lib_id in libraries
            ), f"Address of library {libraries[lib_id][1]} required to generate creation code"

            lib = libraries[lib_id][0]
            if isinstance(lib, Account):
                lib_addr = str(lib.address)[2:]
            elif isinstance(lib, Address):
                lib_addr = str(lib)[2:]
            else:
                raise TypeError()

            creation_code = (
                creation_code[: match.start()] + lib_addr + creation_code[match.end() :]
            )
        return bytes.fromhex(creation_code)

    @classmethod
    def _deploy(
        cls,
        request_type: RequestType,
        arguments: Iterable,
        return_tx: bool,
        return_type: Type,
        from_: Optional[Union[Account, Address, str]],
        value: Union[int, str],
        gas_limit: Optional[Union[int, Literal["max"], Literal["auto"]]],
        libraries: Dict[bytes, Tuple[Union[Account, Address, None], str]],
        chain: Optional[Chain],
        gas_price: Optional[Union[int, str]],
        max_fee_per_gas: Optional[Union[int, str]],
        max_priority_fee_per_gas: Optional[Union[int, str]],
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ],
        type: Optional[int],
        block: Optional[Union[int, str]],
        confirmations: Optional[int],
    ) -> Any:
        if chain is None:
            chain = detect_default_chain()

        creation_code = cls._creation_code
        for match in LIBRARY_PLACEHOLDER_REGEX.finditer(creation_code):
            lib_id = bytes.fromhex(match.group(0)[3:-3])
            assert lib_id in libraries

            lib = libraries[lib_id][0]
            if lib is not None:
                if isinstance(lib, Account):
                    lib_addr = str(lib.address)[2:]
                else:
                    lib_addr = str(lib)[2:]
            elif lib_id in chain._deployed_libraries:
                lib_addr = str(chain._deployed_libraries[lib_id][-1].address)[2:]
            else:
                raise ValueError(f"Library {libraries[lib_id][1]} not deployed")

            creation_code = (
                creation_code[: match.start()] + lib_addr + creation_code[match.end() :]
            )

        return cls._execute(
            chain,
            request_type,
            creation_code,
            arguments,
            return_tx,
            return_type,
            from_,
            None,
            value,
            gas_limit,
            gas_price,
            max_fee_per_gas,
            max_priority_fee_per_gas,
            access_list,
            type,
            block,
            confirmations,
        )

    @classmethod
    def _execute(
        cls,
        chain: Chain,
        request_type: RequestType,
        data: str,
        arguments: Iterable,
        return_tx: bool,
        return_type: Type,
        from_: Optional[Union[Account, Address, str]],
        to: Optional[Union[Account, Address, str]],
        value: Union[int, str],
        gas_limit: Optional[Union[int, Literal["max"], Literal["auto"]]],
        gas_price: Optional[Union[int, str]],
        max_fee_per_gas: Optional[Union[int, str]],
        max_priority_fee_per_gas: Optional[Union[int, str]],
        access_list: Optional[
            Union[Dict[Union[Account, Address, str], List[int]], Literal["auto"]]
        ],
        type: Optional[int],
        block: Optional[Union[int, str]],
        confirmations: Optional[int],
    ):
        if request_type == RequestType.TX and block is not None:
            raise ValueError("block cannot be specified for contract transactions")
        if request_type != RequestType.TX and return_tx:
            raise ValueError("return_tx cannot be specified for non-tx requests")
        if request_type != RequestType.TX and confirmations is not None:
            raise ValueError("confirmations cannot be specified for non-tx requests")
        if confirmations == 0 and not return_tx:
            raise ValueError("confirmations=0 is only valid when return_tx=True")
        if request_type == RequestType.ACCESS_LIST and access_list is not None:
            raise ValueError("access_list cannot be specified for access list requests")

        params: TxParams = {}
        if from_ is not None:
            if isinstance(from_, Account):
                if from_.chain != chain:
                    raise ValueError("`from_` account must belong to this chain")
                params["from"] = str(from_.address)
            else:
                params["from"] = str(from_)

        if isinstance(value, str):
            value = Wei.from_str(value)
        params["value"] = value

        if gas_limit == "max":
            params["gas"] = chain.block_gas_limit
        elif gas_limit == "auto":
            params["gas"] = "auto"
        elif isinstance(gas_limit, int):
            params["gas"] = gas_limit
        elif gas_limit is None:
            pass
        else:
            raise TypeError("`gas_limit` must be an int, 'max', 'auto', or None")

        if to is not None:
            if isinstance(to, Account):
                if to.chain != chain:
                    raise ValueError("`to` account must belong to this chain")
                params["to"] = str(to.address)
            else:
                params["to"] = str(to)

        if gas_price is not None:
            if isinstance(gas_price, str):
                gas_price = Wei.from_str(gas_price)
            params["gasPrice"] = gas_price

        if max_fee_per_gas is not None:
            if isinstance(max_fee_per_gas, str):
                max_fee_per_gas = Wei.from_str(max_fee_per_gas)
            params["maxFeePerGas"] = max_fee_per_gas

        if max_priority_fee_per_gas is not None:
            if isinstance(max_priority_fee_per_gas, str):
                max_priority_fee_per_gas = Wei.from_str(max_priority_fee_per_gas)
            params["maxPriorityFeePerGas"] = max_priority_fee_per_gas

        if access_list == "auto":
            params["accessList"] = "auto"
        elif access_list is not None:
            # normalize access_list, all keys should be Address
            tmp_access_list = defaultdict(list)
            for k, v in access_list.items():
                if isinstance(k, Account):
                    k = k.address
                elif isinstance(k, str):
                    k = Address(k)
                elif not isinstance(k, Address):
                    raise TypeError("access_list keys must be Account, Address or str")
                tmp_access_list[k].extend(v)
            access_list = tmp_access_list
            params["accessList"] = [
                {"address": str(k), "storageKeys": [hex(i) for i in v]}
                for k, v in access_list.items()
            ]

        if type is not None:
            params["type"] = type

        params["data"] = bytes.fromhex(data)

        if to is None:
            abi = (
                cls._abi["constructor"]
                if hasattr(cls, "_abi") and "constructor" in cls._abi
                else None
            )
        else:
            abi = cls._abi[params["data"]]

        if request_type == RequestType.TX:
            return chain._transact(
                abi,
                arguments,
                params,
                return_tx,
                return_type,
                confirmations,
                from_,
            )
        elif request_type == RequestType.CALL:
            if block is None:
                block = "latest"
            return chain._call(abi, arguments, params, return_type, block)
        elif request_type == RequestType.ESTIMATE:
            if block is None:
                block = "pending"

            return chain._estimate(abi, arguments, params, block)
        elif request_type == RequestType.ACCESS_LIST:
            if block is None:
                block = "pending"

            return chain._access_list(abi, arguments, params, block)
        else:
            raise ValueError("invalid request type")


class Library(Contract):
    _library_id: bytes

    @classmethod
    def _deploy(
        cls,
        request_type: RequestType,
        arguments: Iterable,
        return_tx: bool,
        return_type: Type,
        from_: Optional[Union[Account, Address, str]],
        value: Union[int, str],
        gas_limit: Optional[Union[int, Literal["max"], Literal["auto"]]],
        libraries: Dict[bytes, Tuple[Union[Account, Address, None], str]],
        chain: Optional[Chain],
        gas_price: Optional[Union[int, str]],
        max_fee_per_gas: Optional[Union[int, str]],
        max_priority_fee_per_gas: Optional[Union[int, str]],
        access_list: Optional[Dict[Union[Account, Address, str], List[int]]],
        type: Optional[int],
        block: Optional[Union[int, str]],
        confirmations: Optional[int],
    ) -> Any:
        if chain is None:
            chain = detect_default_chain()

        lib = super()._deploy(
            request_type,
            arguments,
            return_tx,
            return_type,
            from_,
            value,
            gas_limit,
            libraries,
            chain,
            gas_price,
            max_fee_per_gas,
            max_priority_fee_per_gas,
            access_list,
            type,
            block,
            confirmations,
        )
        if confirmations != 0:
            if return_tx:
                chain._deployed_libraries[cls._library_id].append(lib.return_value)
            else:
                chain._deployed_libraries[cls._library_id].append(lib)
        return lib
