Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: add ws_uri config to ape-node #2194

Merged
merged 8 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions docs/userguides/networks.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,25 @@ To configure network URIs in `node`, you can use the `ape-config.yaml` file:

```yaml
node:
# When managing or running a node, configure an IPC path globally (optional)
ipc_path: path/to/geth.ipc

ethereum:
mainnet:
uri: https://foo.node.bar
# For `uri`, you can use either HTTP, WS, or IPC values.
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
# **Most often, you only need HTTP!**
uri: https://foo.node.example.com
# uri: wss://bar.feed.example.com
# uri: path/to/mainnet/geth.ipc

# For strict HTTP connections, you can configure a http_uri directly.
http_uri: https://foo.node.example.com

# You can also configure a websockets URI (used by Silverback SDK).
ws_uri: wss://bar.feed.example.com

# Specify per-network IPC paths as well.
ipc_path: path/to/mainnet/geth.ipc
```

## Network Config
Expand All @@ -380,7 +396,7 @@ The following example shows how to do this.
(note: even though this example uses `ethereum:mainnet`, you can use any of the L2 networks mentioned above, as they all have these config properties).

```yaml
ethereum:
ethereum:
antazoey marked this conversation as resolved.
Show resolved Hide resolved
mainnet:
# Ethereum mainnet in Ape uses EIP-1559 by default,
# but we can change that here. Note: most plugins
Expand Down
180 changes: 130 additions & 50 deletions src/ape_ethereum/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from evmchains import get_random_rpc
from pydantic.dataclasses import dataclass
from requests import HTTPError
from web3 import HTTPProvider, IPCProvider, Web3
from web3 import HTTPProvider, IPCProvider, Web3, WebsocketProvider
from web3.exceptions import ContractLogicError as Web3ContractLogicError
from web3.exceptions import (
ExtraDataLengthError,
Expand Down Expand Up @@ -177,27 +177,46 @@ def web3(self) -> Web3:

@property
def http_uri(self) -> Optional[str]:
"""
The connected HTTP URI. If using providers
like `ape-node`, configure your URI and that will
be returned here instead.
"""
try:
web3 = self.web3
except ProviderNotConnectedError:
if uri := getattr(self, "uri", None):
if _is_http_url(uri):
return uri

return None

if (
hasattr(self.web3.provider, "endpoint_uri")
and isinstance(self.web3.provider.endpoint_uri, str)
and self.web3.provider.endpoint_uri.startswith("http")
hasattr(web3.provider, "endpoint_uri")
and isinstance(web3.provider.endpoint_uri, str)
and web3.provider.endpoint_uri.startswith("http")
):
return self.web3.provider.endpoint_uri
return web3.provider.endpoint_uri

elif uri := getattr(self, "uri", None):
# NOTE: Some providers define this
return uri
if uri := getattr(self, "uri", None):
if _is_http_url(uri):
return uri

return None

@property
def ws_uri(self) -> Optional[str]:
try:
web3 = self.web3
except ProviderNotConnectedError:
return None

if (
hasattr(self.web3.provider, "endpoint_uri")
and isinstance(self.web3.provider.endpoint_uri, str)
and self.web3.provider.endpoint_uri.startswith("ws")
hasattr(web3.provider, "endpoint_uri")
and isinstance(web3.provider.endpoint_uri, str)
and web3.provider.endpoint_uri.startswith("ws")
):
return self.web3.provider.endpoint_uri
return web3.provider.endpoint_uri

return None

Expand Down Expand Up @@ -587,7 +606,7 @@ def get_receipt(
transaction_hash=txn_hash, error_message=msg_str
) from err

ecosystem_config = self.network.ecosystem_config.model_dump(by_alias=True)
ecosystem_config = self.network.ecosystem_config
network_config: dict = ecosystem_config.get(self.network.name, {})
max_retries = network_config.get("max_get_transaction_retries", DEFAULT_MAX_RETRIES_TX)
txn = {}
Expand Down Expand Up @@ -1195,9 +1214,11 @@ class EthereumNodeProvider(Web3Provider, ABC):
def uri(self) -> str:
if "url" in self.provider_settings:
raise ConfigError("Unknown provider setting 'url'. Did you mean 'uri'?")
elif "uri" in self.provider_settings:
# Use adhoc, scripted value
return self.provider_settings["uri"]
elif uri := self.provider_settings.get("uri"):
if _is_uri(uri):
return uri
else:
raise TypeError(f"Not an URI: {uri}")

config = self.config.model_dump().get(self.network.ecosystem.name, None)
if config is None:
Expand All @@ -1214,16 +1235,57 @@ def uri(self) -> str:

if "url" in network_config:
raise ConfigError("Unknown provider setting 'url'. Did you mean 'uri'?")
elif "uri" not in network_config:
if rpc := self._get_random_rpc():
return rpc
elif "http_uri" in network_config:
key = "http_uri"
elif "uri" in network_config:
key = "uri"
elif "ipc_path" in network_config:
key = "ipc_path"
elif "ws_uri" in network_config:
key = "ws_uri"
elif rpc := self._get_random_rpc():
return rpc
else:
key = "uri"

settings_uri = network_config.get("uri", DEFAULT_SETTINGS["uri"])
if _is_url(settings_uri):
settings_uri = network_config.get(key, DEFAULT_SETTINGS["uri"])
if _is_uri(settings_uri):
return settings_uri

# Likely was an IPC Path and will connect that way.
return ""
# Likely was an IPC Path (or websockets) and will connect that way.
return super().http_uri or ""

@property
def http_uri(self) -> Optional[str]:
uri = self.uri
return uri if _is_http_url(uri) else None

@property
def ws_uri(self) -> Optional[str]:
if "ws_uri" in self.provider_settings:
# Use adhoc, scripted value
return self.provider_settings["ws_uri"]

elif "uri" in self.provider_settings and _is_ws_url(self.provider_settings["uri"]):
return self.provider_settings["uri"]

config: dict = self.config.get(self.network.ecosystem.name, {})
if config == {}:
return super().ws_uri

# Use value from config file
network_config = config.get(self.network.name) or DEFAULT_SETTINGS
if "ws_uri" not in network_config:
if "uri" in network_config and _is_ws_url(network_config["uri"]):
return network_config["uri"]

return super().ws_uri

settings_uri = network_config.get("ws_uri")
if settings_uri and _is_ws_url(settings_uri):
return settings_uri

return super().ws_uri

def _get_random_rpc(self) -> Optional[str]:
if self.network.is_dev:
Expand All @@ -1248,11 +1310,26 @@ def connection_id(self) -> Optional[str]:

@property
def _clean_uri(self) -> str:
return sanitize_url(self.uri) if _is_url(self.uri) else self.uri
uri = self.uri
return sanitize_url(uri) if _is_http_url(uri) or _is_ws_url(uri) else uri

@property
def ipc_path(self) -> Path:
return self.settings.ipc_path or self.data_dir / "geth.ipc"
if ipc := self.settings.ipc_path:
return ipc

config: dict = self.config.get(self.network.ecosystem.name, {})
network_config = config.get(self.network.name, {})
if ipc := network_config.get("ipc_path"):
return Path(ipc)

# Check `uri:` config.
uri = self.uri
if _is_ipc_path(uri):
return Path(uri)

# Default (used by geth-process).
return self.data_dir / "geth.ipc"

@property
def data_dir(self) -> Path:
Expand Down Expand Up @@ -1280,7 +1357,10 @@ def _ots_api_level(self) -> Optional[int]:
def _set_web3(self):
# Clear cached version when connecting to another URI.
self._client_version = None
self._web3 = _create_web3(self.uri, ipc_path=self.ipc_path)
if uri := self.http_uri:
self._web3 = _create_web3(uri, ipc_path=self.ipc_path, ws_uri=self.ws_uri)
else:
raise ProviderError("Missing URI.")

def _complete_connect(self):
client_version = self.client_version.lower()
Expand Down Expand Up @@ -1375,25 +1455,18 @@ def connect(self):
self._complete_connect()


def _create_web3(uri: str, ipc_path: Optional[Path] = None):
# Separated into helper method for testing purposes.
def http_provider():
return HTTPProvider(uri, request_kwargs={"timeout": 30 * 60})

def ipc_provider():
# NOTE: This mypy complaint seems incorrect.
if not (path := ipc_path):
raise ValueError("IPC Path required.")

return IPCProvider(ipc_path=path)

def _create_web3(uri: str, ipc_path: Optional[Path] = None, ws_uri: Optional[str] = None):
# NOTE: This list is ordered by try-attempt.
# Try ENV, then IPC, and then HTTP last.
providers = [load_provider_from_environment]
if ipc_path:
providers.append(ipc_provider)
if uri:
providers.append(http_provider)
providers: list = [load_provider_from_environment]
if ipc := ipc_path:
providers.append(lambda: IPCProvider(ipc_path=ipc))
if http := uri:
providers.append(
lambda: HTTPProvider(endpoint_uri=http, request_kwargs={"timeout": 30 * 60})
)
if ws := ws_uri:
providers.append(lambda: WebsocketProvider(endpoint_uri=ws))

provider = AutoProvider(potential_providers=providers)
return Web3(provider)
Expand All @@ -1417,10 +1490,17 @@ def _get_default_data_dir() -> Path:
)


def _is_url(val: str) -> bool:
return (
val.startswith("https://")
or val.startswith("http://")
or val.startswith("wss://")
or val.startswith("ws://")
)
def _is_uri(val: str) -> bool:
return _is_http_url(val) or _is_ws_url(val) or _is_ipc_path(val)


def _is_http_url(val: str) -> bool:
return val.startswith("https://") or val.startswith("http://")


def _is_ws_url(val: str) -> bool:
return val.startswith("wss://") or val.startswith("ws://")


def _is_ipc_path(val: str) -> bool:
return val.endswith(".ipc")
46 changes: 46 additions & 0 deletions tests/functional/test_provider.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from pathlib import Path
from unittest import mock

import pytest
Expand Down Expand Up @@ -493,3 +494,48 @@ def test_account_balance_state(project, eth_tester_provider, owner):
provider.connect()
bal = provider.get_balance(owner.address)
assert bal == amount


@pytest.mark.parametrize(
"uri,key",
[("ws://example.com", "ws_uri"), ("wss://example.com", "ws_uri"), ("wss://example.com", "uri")],
)
def test_node_ws_uri(project, uri, key):
node = project.network_manager.ethereum.sepolia.get_provider("node")
assert node.ws_uri is None
config = {"ethereum": {"sepolia": {key: uri}}}
with project.temp_config(node=config):
node = project.network_manager.ethereum.sepolia.get_provider("node")
assert node.ws_uri == uri

if key != "ws_uri":
assert node.uri == uri
# else: uri gets to set to random HTTP from default settings,
# but we may want to change that behavior.
# TODO: 0.9 investigate not using random if ws set.


@pytest.mark.parametrize("http_key", ("uri", "http_uri"))
def test_node_http_uri_with_ws_uri(project, http_key):
http = "http://example.com"
ws = "ws://example.com"
# Showing `uri:` as an HTTP and `ws_uri`: as an additional ws.
with project.temp_config(node={"ethereum": {"sepolia": {http_key: http, "ws_uri": ws}}}):
node = project.network_manager.ethereum.sepolia.get_provider("node")
assert node.uri == http
assert node.http_uri == http
assert node.ws_uri == ws


@pytest.mark.parametrize("key", ("uri", "ipc_path"))
def test_ipc_per_network(project, key):
ipc = "path/to/example.ipc"
with project.temp_config(node={"ethereum": {"sepolia": {key: ipc}}}):
node = project.network_manager.ethereum.sepolia.get_provider("node")
if key != "ipc_path":
assert node.uri == ipc
# else: uri gets to set to random HTTP from default settings,
# but we may want to change that behavior.
# TODO: 0.9 investigate not using random if ipc set.

assert node.ipc_path == Path(ipc)
Loading