Repository: ApeWorX/ape

Issue:
Add support for running a local reth node
### Describe

I am able to attempt to use reth via

```
node:
  executable: reth
```

but some of the arguments it passes to the binary are clearly wrong. Also, it just doesn't start up, it times out. It would be nice to have better support here so we can use reth

Also the full command is `reth node` but having multi-words here doesnt work either, so have to use an alias

### Specification

_No response_

### Dependencies

_No response_

Discussion:
@linear: <p><a href="https://linear.app/apeworx/issue/APE-1974/add-support-for-running-a-local-reth-node">APE-1974 Add support for running a local reth node</a></p>

Solution patch:
diff --git a/docs/userguides/networks.md b/docs/userguides/networks.md
index 7bf3a6257..cfc7c8318 100644
--- a/docs/userguides/networks.md
+++ b/docs/userguides/networks.md
@@ -435,12 +435,33 @@ Out-of-the-box, Ape ships with two development providers you can use for the `lo
 - An Ephemeral Node (defaults to Geth) process
 
 ```bash
-ape test --network ::test
-ape test --network ::node  # Launch a local development node (geth) process
+ape test --network ::test  # Launch an Ethereum Tester in-process node.
+ape test --network ::node  # Launch a local development node (e.g. geth) process.
 ```
 
 To learn more about testing in ape, follow [this guide](./testing.html).
 
+### Ethereum Tester
+
+`ethereum:local:test` is an in-process Pythonic Ethereum tester node, which is fast but limited in its feature-set.
+It is the default tester node in Ape because it doesn't require any additional dependencies, such as `geth`.
+
+### Local Node
+
+Using `ethereum:local:node`, you are telling Ape to use node software in `--dev` mode e.g. `geth --dev` or `reth --dev`.
+By default, Ape will use `geth --dev` unless geth is not installed, than it will search for others.
+
+To use specific node software, configure it directly in Ape.
+Here is an `ape-config.yaml` example for how to do that:
+
+```yaml
+node:
+  executable: $HOME/RustProjects/reth/target/release/reth node
+```
+
+In this case, `reth` was built locally is in the repository's `target` directory.
+Link to the `reth` binary so `ape` launches and uses your local `reth` node.
+
 ## Live Networks
 
 Use the core plugin `ape-node` to connect to local or remote nodes via URI.
diff --git a/src/ape_ethereum/provider.py b/src/ape_ethereum/provider.py
index 2182d1c29..cf26bb2ed 100644
--- a/src/ape_ethereum/provider.py
+++ b/src/ape_ethereum/provider.py
@@ -182,12 +182,16 @@ class Web3Provider(ProviderAPI, ABC):
 
         # Patching the provider to call a post send_transaction() hook
         def post_tx_hook(send_tx):
+            if getattr(send_tx, "_is_post_tx_wrapped", False):
+                return send_tx
+
             @wraps(send_tx)
             def send_tx_wrapper(self, txn: TransactionAPI) -> ReceiptAPI:
                 receipt = send_tx(self, txn)
                 _post_send_transaction(txn, receipt)
                 return receipt
 
+            send_tx_wrapper._is_post_tx_wrapped = True  # type: ignore
             return send_tx_wrapper
 
         send_tx_wrapper = post_tx_hook(cls.send_transaction)
diff --git a/src/ape_node/provider.py b/src/ape_node/provider.py
index 365c4758f..ebe45128c 100644
--- a/src/ape_node/provider.py
+++ b/src/ape_node/provider.py
@@ -1,10 +1,13 @@
 import atexit
+import os.path
 import re
 import shutil
 from pathlib import Path
 from subprocess import DEVNULL, PIPE, Popen
 from typing import TYPE_CHECKING, Any, Optional, Union
+from urllib.error import HTTPError, URLError
 from urllib.parse import urlparse
+from urllib.request import urlopen
 
 from eth_utils import add_0x_prefix, to_hex
 from geth.chain import initialize_chain as initialize_gethdev_chain
@@ -93,6 +96,10 @@ def create_genesis_data(alloc: Alloc, chain_id: int) -> "GenesisDataTypedDict":
     }
 
 
+def get_node_name_from_executable(executable: str) -> str:
+    return executable.split(os.path.sep)[-1] or "geth"
+
+
 class GethDevProcess(BaseGethProcess):
     """
     A developer-configured geth that only exists until disconnected.
@@ -111,7 +118,7 @@ class GethDevProcess(BaseGethProcess):
         number_of_accounts: int = DEFAULT_NUMBER_OF_TEST_ACCOUNTS,
         chain_id: int = DEFAULT_TEST_CHAIN_ID,
         initial_balance: Union[str, int] = DEFAULT_TEST_ACCOUNT_BALANCE,
-        executable: Optional[str] = None,
+        executable: Optional[Union[list[str], str]] = None,
         auto_disconnect: bool = True,
         extra_funded_accounts: Optional[list[str]] = None,
         hd_path: Optional[str] = DEFAULT_TEST_HD_PATH,
@@ -119,19 +126,46 @@ class GethDevProcess(BaseGethProcess):
         generate_accounts: bool = True,
         initialize_chain: bool = True,
         background: bool = False,
+        verify_bin: bool = True,
     ):
-        executable = executable or "geth"
-        if not shutil.which(executable):
-            raise NodeSoftwareNotInstalledError()
+        if isinstance(executable, str):
+            # Legacy.
+            executable = [executable]
+
+        if executable:
+            if verify_bin and (
+                not Path(executable[0]).exists() and not shutil.which(executable[0])
+            ):
+                raise NodeSoftwareNotInstalledError()
+
+        else:
+            # Executable not specified: attempt to find one in $PATH.
+            if shutil.which("geth"):
+                executable = ["geth"]
+            elif shutil.which("reth"):
+                executable = ["reth", "node"]
+            elif verify_bin:
+                # TODO: Add support for more nodes, such as erigon.
+                raise NodeSoftwareNotInstalledError()
+            else:
+                # This probably won't work if started, but the user knows that.
+                executable = ["geth"]
 
+        self.executable = executable or ["geth"]
         self._data_dir = data_dir
         self.is_running = False
         self._auto_disconnect = auto_disconnect
         self.background = background
 
+        is_reth = executable[0].endswith("reth")
+
+        if is_reth and hostname == "localhost":
+            # Reth only supports IP format.
+            hostname = "127.0.0.1"
+
         kwargs_ctor: dict = {
             "data_dir": self.data_dir,
-            "geth_executable": executable,
+            "geth_executable": executable[0],
             "network_id": f"{chain_id}",
         }
         if hostname is not None:
@@ -151,10 +185,20 @@ class GethDevProcess(BaseGethProcess):
             kwargs_ctor["ws_enabled"] = False
             kwargs_ctor["ws_addr"] = None
             kwargs_ctor["ws_port"] = None
-        if block_time is not None:
+        if block_time is not None and not is_reth:
             kwargs_ctor["dev_period"] = f"{block_time}"
 
         geth_kwargs = construct_test_chain_kwargs(**kwargs_ctor)
+        if is_reth:
+            geth_kwargs.pop("max_peers", None)
+            geth_kwargs.pop("network_id", None)
+            geth_kwargs.pop("no_discover", None)
+
+            # NOTE: --verbosity _is_ available in reth, but it is a different type (flag only).
+            #   It isn't really needed anyway (I don't think).
+            geth_kwargs.pop("verbosity", None)
+
+            geth_kwargs.pop("password", None)
 
         # Ensure a clean data-dir.
         self._clean()
@@ -179,6 +223,10 @@ class GethDevProcess(BaseGethProcess):
 
         super().__init__(geth_kwargs)
 
+        # Correct multi-word executable.
+        idx = self.command.index(executable[0])
+        self.command = self.command[:idx] + executable + self.command[idx + 1 :]
+
     @classmethod
     def from_uri(cls, uri: str, data_folder: Path, **kwargs):
         mnemonic = kwargs.get("mnemonic", DEFAULT_TEST_MNEMONIC)
@@ -189,16 +237,19 @@ class GethDevProcess(BaseGethProcess):
         if isinstance(block_time, int):
             block_time = f"{block_time}"
 
+        executable = kwargs.get("executable")
+
         process_kwargs = {
             "auto_disconnect": kwargs.get("auto_disconnect", True),
             "background": kwargs.get("background", False),
             "block_time": block_time,
-            "executable": kwargs.get("executable"),
+            "executable": executable,
             "extra_funded_accounts": extra_accounts,
             "hd_path": kwargs.get("hd_path", DEFAULT_TEST_HD_PATH),
             "initial_balance": balance,
             "mnemonic": mnemonic,
             "number_of_accounts": number_of_accounts,
+            "verify_bin": kwargs.get("verify_bin", True),
         }
 
         parsed_uri = urlparse(uri)
@@ -213,8 +264,9 @@ class GethDevProcess(BaseGethProcess):
 
         elif hostname := parsed_uri.hostname:
             if hostname not in ("localhost", "127.0.0.1"):
+                name = get_node_name_from_executable(executable) if executable else "geth"
                 raise ConnectionError(
-                    f"Unable to start Geth on non-local host {parsed_uri.hostname}."
+                    f"Unable to start {name} on non-local host {parsed_uri.hostname}."
                 )
 
             if parsed_uri.scheme.startswith("ws"):
@@ -232,6 +284,30 @@ class GethDevProcess(BaseGethProcess):
     def data_dir(self) -> str:
         return f"{self._data_dir}"
 
+    @property
+    def process_name(self) -> str:
+        return get_node_name_from_executable(self.executable[0])
+
+    @property
+    def is_rpc_ready(self) -> bool:
+        # Overridden: This is overridden to work with other nodes besides `geth`.
+        #   Otherwise, the RPC is never declared as ready even though it is.
+        try:
+            urlopen(f"http://{self.rpc_host}:{self.rpc_port}")
+
+        except HTTPError:
+            # Reth nodes (and maybe others) might throw an HTTP error here, like "method not found".
+            # This means the RPC is ready.
+            return True
+
+        except URLError:
+            # Nothing found at all yet, most likely.
+            return False
+
+        else:
+            # No error occurs on Geth nodes when the RPC is ready.
+            return True
+
     @property
     def _hostname(self) -> Optional[str]:
         return self.geth_kwargs.get("rpc_addr")
@@ -276,8 +352,7 @@ class GethDevProcess(BaseGethProcess):
                 ws_log = f"{ws_log}:{port}"
 
         connection_logs = ", ".join(x for x in (http_log, ipc_log, ws_log) if x)
-
-        logger.info(f"Starting geth ({connection_logs}).")
+        logger.info(f"Starting {self.process_name} ({connection_logs}).")
 
     def start(self):
         if self.is_running:
@@ -299,7 +374,7 @@ class GethDevProcess(BaseGethProcess):
 
     def disconnect(self):
         if self.is_running:
-            logger.info("Stopping 'geth' process.")
+            logger.info(f"Stopping '{self.process_name}' process.")
             self.stop()
 
         self._clean()
@@ -362,7 +437,7 @@ class EthereumNodeConfig(PluginConfig):
     such as which URIs to use for each network.
     """
 
-    executable: Optional[str] = None
+    executable: Optional[list[str]] = None
     """
     For starting nodes, select the executable. Defaults to using
     ``shutil.which("geth")``.
@@ -399,6 +474,17 @@ class EthereumNodeConfig(PluginConfig):
         # This handles nicer config values.
         return None if value is None else TraceApproach.from_key(value)
 
+    @field_validator("executable", mode="before")
+    @classmethod
+    def validate_executable(cls, value):
+        if not value:
+            return None
+
+        elif isinstance(value, str):
+            return value.split(" ")
+
+        return value
+
 
 class NodeSoftwareNotInstalledError(ConnectionError):
     def __init__(self):
@@ -419,7 +505,10 @@ class GethDev(EthereumNodeProvider, TestProviderAPI, SubprocessProvider):
 
     @property
     def process_name(self) -> str:
-        return self.name
+        if self._process:
+            return self._process.process_name
+
+        return "geth"
 
     @property
     def chain_id(self) -> int:
@@ -456,6 +545,20 @@ class GethDev(EthereumNodeProvider, TestProviderAPI, SubprocessProvider):
     def auto_mine(self, value):
         raise NotImplementedError("'auto_mine' setter not implemented.")
 
+    @property
+    def ipc_path(self) -> Optional[Path]:
+        if rpc := self._configured_ipc_path:
+            # "ipc_path" found in config/settings
+            return Path(rpc)
+
+        elif rpc := self._configured_uri:
+            if f"{rpc}".endswith(".ipc"):
+                # "uri" found in config/settings and is IPC.
+                return Path(rpc)
+
+        # Default (used by geth-process).
+        return self.data_dir / f"{self.process_name}.ipc"
+
     def connect(self):
         self._set_web3()
         if self.is_connected:
@@ -471,7 +574,8 @@ class GethDev(EthereumNodeProvider, TestProviderAPI, SubprocessProvider):
         geth_dev.connect(timeout=timeout)
         if not self.web3.is_connected():
             geth_dev.disconnect()
-            raise ConnectionError("Unable to connect to locally running geth.")
+
+            raise ConnectionError(f"Unable to connect to locally running {geth_dev.process_name}.")
         else:
             self.web3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
 


Test patch:
diff --git a/tests/functional/geth/test_provider.py b/tests/functional/geth/test_provider.py
index 3d707b358..87875b23e 100644
--- a/tests/functional/geth/test_provider.py
+++ b/tests/functional/geth/test_provider.py
@@ -2,6 +2,7 @@ import re
 from abc import ABC
 from pathlib import Path
 from typing import Optional, cast
+from urllib.error import HTTPError, URLError
 
 import pytest
 from eth_pydantic_types import HexBytes32
@@ -930,67 +931,119 @@ def test_auto_mine(geth_provider, geth_account, geth_contract):
     assert not receipt.confirmed
 
 
-@geth_process_test
-def test_geth_dev_from_uri_http(data_folder):
-    geth_dev = GethDevProcess.from_uri("http://localhost:6799", data_folder)
-    kwargs = geth_dev.geth_kwargs
-    assert kwargs["rpc_addr"] == "localhost"
-    assert kwargs["rpc_port"] == "6799"
-    assert kwargs["ws_enabled"] is False
-    assert kwargs.get("ws_api") is None
-    assert kwargs.get("ws_addr") is None
-    assert kwargs.get("ws_port") is None
-
-
-@geth_process_test
-def test_geth_dev_from_uri_ws(data_folder):
-    geth_dev = GethDevProcess.from_uri("ws://localhost:6799", data_folder)
-    kwargs = geth_dev.geth_kwargs
-    assert kwargs.get("rpc_addr") is None
-    assert kwargs["ws_enabled"] is True
-    assert kwargs["ws_addr"] == "localhost"
-    assert kwargs["ws_port"] == "6799"
-
-
-@geth_process_test
-def test_geth_dev_from_uri_ipc(data_folder):
-    geth_dev = GethDevProcess.from_uri("path/to/geth.ipc", data_folder)
-    kwargs = geth_dev.geth_kwargs
-    assert kwargs["ipc_path"] == "path/to/geth.ipc"
-    assert kwargs.get("ws_api") is None
-    assert kwargs.get("ws_addr") is None
-    assert kwargs.get("rpc_addr") is None
-
-
-@geth_process_test
-def test_geth_dev_block_period(data_folder):
-    geth_dev = GethDevProcess.from_uri(
-        "path/to/geth.ipc",
-        data_folder,
-        block_time=1,
-        generate_accounts=False,
-        initialize_chain=False,
-    )
-    assert geth_dev.geth_kwargs["dev_period"] == "1"
-
-
-def test_geth_dev_disconnect_does_not_delete_unrelated_files_in_given_data_dir():
+class TestGethDevProcess:
     """
-    One time, I used a data-dir containing other files I didn't want to lose. GethDevProcess
-    deleted the entire folder during `.disconnect()`, and it was tragic. Ensure this does
-    not happen to anyone else.
+    Tests targeting the process-starter directly.
     """
-    with create_tempdir() as temp_dir:
-        file = temp_dir / "dont_delete_me_plz.txt"
-        file.write_text("Please don't delete me.")
 
+    @geth_process_test
+    def test_from_uri_http(self, data_folder):
+        geth_dev = GethDevProcess.from_uri("http://localhost:6799", data_folder)
+        kwargs = geth_dev.geth_kwargs
+        assert kwargs["rpc_addr"] == "localhost"
+        assert kwargs["rpc_port"] == "6799"
+        assert kwargs["ws_enabled"] is False
+        assert kwargs.get("ws_api") is None
+        assert kwargs.get("ws_addr") is None
+        assert kwargs.get("ws_port") is None
+
+    @geth_process_test
+    def test_from_uri_ws(self, data_folder):
+        geth_dev = GethDevProcess.from_uri("ws://localhost:6799", data_folder)
+        kwargs = geth_dev.geth_kwargs
+        assert kwargs.get("rpc_addr") is None
+        assert kwargs["ws_enabled"] is True
+        assert kwargs["ws_addr"] == "localhost"
+        assert kwargs["ws_port"] == "6799"
+
+    @geth_process_test
+    def test_from_uri_ipc(self, data_folder):
+        geth_dev = GethDevProcess.from_uri("path/to/geth.ipc", data_folder)
+        kwargs = geth_dev.geth_kwargs
+        assert kwargs["ipc_path"] == "path/to/geth.ipc"
+        assert kwargs.get("ws_api") is None
+        assert kwargs.get("ws_addr") is None
+        assert kwargs.get("rpc_addr") is None
+
+    @geth_process_test
+    def test_block_period(self, data_folder):
         geth_dev = GethDevProcess.from_uri(
             "path/to/geth.ipc",
-            temp_dir,
+            data_folder,
             block_time=1,
             generate_accounts=False,
             initialize_chain=False,
         )
-        geth_dev.disconnect()
-        assert file.is_file()
-        assert not (temp_dir / "genesis.json").is_file()
+        assert geth_dev.geth_kwargs["dev_period"] == "1"
+
+    def test_disconnect_does_not_delete_unrelated_files_in_given_data_dir(self):
+        """
+        One time, I used a data-dir containing other files I didn't want to lose. GethDevProcess
+        deleted the entire folder during `.disconnect()`, and it was tragic. Ensure this does
+        not happen to anyone else.
+        """
+        with create_tempdir() as temp_dir:
+            file = temp_dir / "dont_delete_me_plz.txt"
+            file.write_text("Please don't delete me.")
+
+            geth_dev = GethDevProcess.from_uri(
+                "path/to/geth.ipc",
+                temp_dir,
+                block_time=1,
+                generate_accounts=False,
+                initialize_chain=False,
+            )
+            geth_dev.disconnect()
+            assert file.is_file()
+            assert not (temp_dir / "genesis.json").is_file()
+
+    def test_is_rpc_ready_false(self, mocker, data_folder):
+        """
+        Both Geth and Reth nodes raise simple URLError when the node is not running.
+        """
+        urlopen_patch = mocker.patch("ape_node.provider.urlopen")
+        urlopen_patch.side_effect = URLError("Unable to connect")
+        geth_dev = GethDevProcess.from_uri("path/to/geth.ipc", data_folder)
+        assert not geth_dev.is_rpc_ready
+
+    def test_is_rpc_ready_true_geth(self, mocker, data_folder):
+        """
+        Geth has no error when the RPC is ready.
+        """
+        urlopen_patch = mocker.patch("ape_node.provider.urlopen")
+        urlopen_patch.return_value = None
+        geth_dev = GethDevProcess.from_uri("path/to/geth.ipc", data_folder)
+        assert geth_dev.is_rpc_ready
+
+    def test_is_rpc_ready_true_reth(self, mocker, data_folder):
+        """
+        Reth raises HTTPError("Method not found") when the RPC is ready.
+        """
+        urlopen_patch = mocker.patch("ape_node.provider.urlopen")
+        urlopen_patch.side_effect = HTTPError("127.0.0.1", 404, "method not found", 0, 0)  # type: ignore
+        geth_dev = GethDevProcess.from_uri("path/to/geth.ipc", data_folder)
+        assert geth_dev.is_rpc_ready
+
+    def test_command_reth(self, mocker, data_folder):
+        """
+        Showing we get usable kwargs for a reth --dev node.
+        """
+        # Trick py-geth into thinking reth is available even when it isn't.
+        is_exec_check_patch = mocker.patch("geth.wrapper.is_executable_available")
+        is_exec_check_patch.return_value = True
+
+        reth_dev = GethDevProcess.from_uri(
+            "path/to/geth.ipc", data_folder, executable=["reth", "node"], verify_bin=False
+        )
+        actual = reth_dev.command
+        assert "reth" in actual
+        assert "node" in actual
+        assert "--http.port" in actual
+        assert "--dev" in actual
+
+        # Geth only
+        assert "localhost" not in actual
+        assert "--maxpeers" not in actual
+        assert "--password" not in actual
+        assert "--nodiscover" not in actual
+        assert "--networkid" not in actual
diff --git a/tests/functional/test_config.py b/tests/functional/test_config.py
index 48f12ad54..2c206d8a2 100644
--- a/tests/functional/test_config.py
+++ b/tests/functional/test_config.py
@@ -161,7 +161,7 @@ def test_model_validate_handles_environment_variables():
         NetworkConfig, "required_confirmations", "APE_ETHEREUM_REQUIRED_CONFIRMATIONS", "6498", 6498
     )
     run_test(EthereumNetworkConfig, "mainnet", "APE_NODE_MAINNET", '{"a":"b"}', {"a": "b"})
-    run_test(EthereumNodeConfig, "executable", "APE_NODE_EXECUTABLE", "40613177e494")
+    run_test(EthereumNodeConfig, "executable", "APE_NODE_EXECUTABLE", "geth", expected=["geth"])
     run_test(CoverageReportsConfig, "terminal", "APE_TEST_TERMINAL", "false", False)
     run_test(GasConfig, "reports", "APE_TEST_REPORTS", '["terminal"]', ["terminal"])
     run_test(GasExclusion, "method_name", "APE_TEST_METHOD_NAME", "32aa54e3c5d2")