Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 2 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,9 @@ jobs:
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Create Ganache network
run: docker network create ganache
- name: Setup and run ganache
run: docker run --detach --publish 8545:8545 --network ganache -e DOCKER=true trufflesuite/ganache:latest --defaultBalanceEther 10000 --gasLimit 10000000 -a 30 --chain.chainId 1337 --chain.networkId 1337 -d
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run anvil
run: docker compose --profile develop up --detach --wait anvil
- name: Install uv
uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1
with:
Expand Down
15 changes: 11 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ services:
context: .
dockerfile: ./Dockerfile

ganache:
image: trufflesuite/ganache:latest
anvil:
image: ghcr.io/foundry-rs/foundry:v1.9.0
ports:
- "8545:8545"
command: --defaultBalanceEther 10000 --gasLimit 10000000 -a 10 --chain.chainId 1337 --chain.networkId 1337 -d --host 0.0.0.0
command: >-
"anvil --host 0.0.0.0 --chain-id 1337 --accounts 10 --balance 10000
--gas-limit 200000000
--mnemonic 'myth like bonus scare over problem client lizard pioneer submit female collect'"
Comment thread
Uxio0 marked this conversation as resolved.
Resolved
healthcheck:
test: bash -c "echo 'hello' > /dev/tcp/localhost/8545"
test: ["CMD", "cast", "block-number"]
interval: 1s
timeout: 5s
retries: 10
start_period: 5s
profiles:
- develop
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ dependencies = [
"prompt_toolkit>=3",
"pygments>=2",
"requests>=2",
"safe-eth-py>=7.20.0",
"safe-eth-py>=7.29.1",
"tabulate>=0.8",
"typer>=0.14.0",
]
Expand Down
10 changes: 5 additions & 5 deletions run_tests.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#/usr/bin/env sh
docker compose --profile develop up -d ganache
pip install -e .
pytest
docker compose down --profile develop
#!/usr/bin/env sh
trap 'docker compose --profile develop down' EXIT
docker compose --profile develop up --detach --wait anvil || exit 1
uv sync --frozen --group dev --all-extras || exit 1
uv run --frozen pytest
35 changes: 33 additions & 2 deletions src/safe_cli/safe_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@
import os
import sys

import requests
from eth_typing import ChecksumAddress
from prompt_toolkit import HTML, PromptSession, print_formatted_text
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import html
from prompt_toolkit.history import FileHistory
from prompt_toolkit.lexers import PygmentsLexer
from web3.exceptions import (
ContractLogicError,
ProviderConnectionError,
TimeExhausted,
Web3RPCError,
)

from .operators import (
SafeCliTerminationException,
Expand All @@ -18,6 +26,23 @@
from .safe_completer import SafeCompleter
from .safe_lexer import SafeLexer

NODE_ERRORS = (
Web3RPCError,
ContractLogicError,
TimeExhausted,
ProviderConnectionError,
requests.exceptions.RequestException,
)


def print_node_error(node_error: Exception) -> None:
print_formatted_text(
HTML(
f"<ansired>{type(node_error).__name__}: "
f"{html.html_escape(str(node_error))}</ansired>"
)
)


class SafeCli:
def __init__(self, safe_address: ChecksumAddress, node_url: str, history: bool):
Expand Down Expand Up @@ -76,11 +101,16 @@ def parse_operator_mode(self, command: str) -> SafeOperator | None:
split_command = command.split()
try:
if (split_command[0]) == "tx-service":
tx_service_operator = SafeTxServiceOperator(
self.safe_address, self.node_url
)
tx_service_operator.refresh_safe_cli_info()
print_formatted_text(
HTML("<b><ansigreen>Sending txs to tx service</ansigreen></b>")
)
return SafeTxServiceOperator(self.safe_address, self.node_url)
return tx_service_operator
elif split_command[0] == "blockchain":
self.safe_operator.refresh_safe_cli_info()
print_formatted_text(
HTML("<b><ansigreen>Sending txs to blockchain</ansigreen></b>")
)
Expand Down Expand Up @@ -109,7 +139,6 @@ def loop(self):
new_operator = self.parse_operator_mode(command)
if new_operator:
self.prompt_parser = PromptParser(new_operator)
new_operator.refresh_safe_cli_info() # ClI info needs to be initialized
else:
self.prompt_parser.process_command(command)
except SafeCliTerminationException:
Expand All @@ -120,3 +149,5 @@ def loop(self):
continue
except (argparse.ArgumentError, argparse.ArgumentTypeError, SystemExit):
pass
except NODE_ERRORS as node_error:
print_node_error(node_error)
4 changes: 3 additions & 1 deletion src/safe_cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ def get_erc_20_list(
addresses = set()
for i in range(from_block, to_block + 1, block_step):
events = ethereum_client.erc20.get_total_transfer_history(
from_block=i, to_block=i + (block_step - 1), addresses=[safe_address]
from_block=i,
to_block=min(i + block_step - 1, to_block),
addresses=[safe_address],
)
for event in events:
if "value" in event["args"]:
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import os

# safe-eth-py leaves `ethereum_node_url` as None when ETHEREUM_NODE_URL is not
# set, and the CLI commands under test need the node URL as an argument
os.environ.setdefault("ETHEREUM_NODE_URL", "http://localhost:8545")
9 changes: 4 additions & 5 deletions tests/test_hw_wallet_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def test_execute(
tx_parameters = {
"from": owner.address,
"gasPrice": safe_tx.w3.eth.gas_price,
"nonce": 0,
"nonce": self.ethereum_client.get_nonce_for_account(owner.address),
"gas": safe_tx.recommended_gas(),
}
safe_tx.tx = safe_tx.w3_tx.build_transaction(tx_parameters)
Expand All @@ -196,7 +196,7 @@ def test_execute(
)

mocked_signed_transaction_response = SignedTransaction(
nonce=0,
nonce=safe_tx.tx["nonce"],
gas_price=safe_tx.tx["gasPrice"],
gas_limit=safe_tx.tx["gas"],
destination=HexBytes(safe_tx.tx["to"]),
Expand All @@ -210,6 +210,5 @@ def test_execute(
mock_ledger_create_transaction.return_value = mocked_signed_transaction_response
tx_hash, tx = hw_wallet_manager.execute_safe_tx(safe_tx)
self.assertEqual(tx["data"], safe_tx.tx["data"])
self.assertIsNotNone(
self.ethereum_client.w3.eth.get_transaction_receipt(tx_hash)
)
self.assertEqual(tx["nonce"], tx_parameters["nonce"])
self.assertEqual(self.wait_for_receipt(tx_hash)["status"], 1)
68 changes: 68 additions & 0 deletions tests/test_safe_cli.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import unittest
from unittest import mock

import requests
from eth_account import Account
from safe_eth.safe import Safe
from safe_eth.util.util import to_0x_hex_str
from web3 import Web3
from web3.exceptions import Web3RPCError

from safe_cli.operators import SafeCliTerminationException
from safe_cli.operators.safe_operator import SafeOperator
from safe_cli.prompt_parser import PromptParser
from safe_cli.safe_cli import SafeCli

from .safe_cli_test_case_mixin import SafeCliTestCaseMixin

Expand Down Expand Up @@ -81,6 +85,70 @@ def test_safe_cli_happy_path(self):
with self.assertRaises(SafeCliTerminationException):
prompt_parser.process_command("exit")

def test_node_error_keeps_prompt_running(self):
"""A node or connection error in a command is printed and the prompt keeps running."""
safe_cli = SafeCli(
self.setup_operator().address, self.ethereum_node_url, history=False
)
node_errors = [
Web3RPCError("block <height> is out of range"),
requests.exceptions.ConnectionError("node <down>"),
]

for node_error in node_errors:
with (
self.subTest(node_error=type(node_error).__name__),
mock.patch.object(
SafeCli,
"get_command",
side_effect=[f"drain {Account.create().address}", "exit"],
),
mock.patch.object(SafeOperator, "drain", side_effect=node_error),
mock.patch("safe_cli.safe_cli.print_formatted_text") as print_mock,
):
safe_cli.loop()

printed_texts = [
call.args[0].value for call in print_mock.call_args_list
]
node_error_texts = [
printed_text
for printed_text in printed_texts
if type(node_error).__name__ in printed_text
]
self.assertEqual(len(node_error_texts), 1)
# The node message is escaped, so it does not break the HTML formatting
self.assertIn("&lt;", node_error_texts[0])

def test_node_error_on_operator_switch_keeps_current_mode(self):
"""A node error while switching operator mode is printed, no switch message is shown and the prompt keeps the current mode."""
safe_cli = SafeCli(
self.setup_operator().address, self.ethereum_node_url, history=False
)
previous_prompt_parser = safe_cli.prompt_parser

with (
mock.patch.object(
SafeCli, "get_command", side_effect=["blockchain", "exit"]
),
mock.patch.object(
SafeOperator,
"refresh_safe_cli_info",
side_effect=requests.exceptions.ConnectionError("node down"),
),
mock.patch("safe_cli.safe_cli.print_formatted_text") as print_mock,
):
safe_cli.loop()

printed_texts = [call.args[0].value for call in print_mock.call_args_list]
self.assertTrue(
any("ConnectionError" in printed_text for printed_text in printed_texts)
)
self.assertFalse(
any("Sending txs to" in printed_text for printed_text in printed_texts)
)
self.assertIs(safe_cli.prompt_parser, previous_prompt_parser)


if __name__ == "__main__":
unittest.main()
Loading
Loading