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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ safe-cli tx-builder 0xsafeaddress https://sepolia.drpc.org ./path/to/exported/t

It is possible to use the environment variable `SAFE_CLI_INTERACTIVE=0` to avoid user interactions. The `--non-interactive` option have more priority than environment variable.

The environment variable `SAFE_CLI_MAX_FEE_PER_GAS` sets `maxFeePerGas`, in gwei, for the EIP-1559 transactions `safe-cli` sends. Without it `safe-cli` takes the value from the fee history. Use it on a network that refuses that value, or to raise the cap on a busy one:

```bash
export SAFE_CLI_MAX_FEE_PER_GAS=<gwei>
```

The tip still comes from the fee history. `safe-cli` refuses a value of 9.9 times the node's gas price or more. On a network without EIP-1559 fees the variable has no effect. `safe-creator` does not read it.

**Note:** To use tx-service mode, set your API key as follows:

```bash
Expand Down
65 changes: 65 additions & 0 deletions src/safe_cli/operators/ethereum_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import os
import re
from decimal import Decimal

import typer
from safe_eth.eth import EthereumClient, TxSpeed
from web3 import Web3
from web3.types import TxParams, Wei

from .exceptions import SafeOperatorException

MAX_FEE_PER_GAS_ENV = "SAFE_CLI_MAX_FEE_PER_GAS"
# A cap of 9.9 times the node's gas price, or more, is refused: one zero too many is 10 times
MAX_FEE_PER_GAS_LIMIT = Decimal("9.9")


def max_fee_per_gas_from_environment() -> int | None:
"""
:return: ``SAFE_CLI_MAX_FEE_PER_GAS`` in wei, or ``None`` if it is not set
:raises typer.BadParameter: unless the value is a positive decimal number of gwei, such as ``30`` or ``1.5``
"""
value = os.environ.get(MAX_FEE_PER_GAS_ENV)
if not value:
return None
# Digits only, so that "1e3" or "1_0" is not read as another amount; at most 9 decimals, a whole number of wei
if re.fullmatch(r"[0-9]+(\.[0-9]{1,9})?", value):
max_fee_per_gas = int(Decimal(value) * 10**9)
if max_fee_per_gas > 0:
return max_fee_per_gas
raise typer.BadParameter(
f"expected a positive number of gwei, got {value!r}",
param_hint=MAX_FEE_PER_GAS_ENV,
)


def _gwei(wei: int) -> str:
return format(Web3.from_wei(wei, "gwei"), "f")


class SafeCliEthereumClient(EthereumClient):
"""
``EthereumClient`` that sends EIP-1559 transactions with the ``maxFeePerGas`` set in the environment variable
``SAFE_CLI_MAX_FEE_PER_GAS``, in gwei. Without the variable it behaves as ``EthereumClient``.
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.max_fee_per_gas = max_fee_per_gas_from_environment()

def set_eip1559_fees(
self, tx: TxParams, tx_speed: TxSpeed = TxSpeed.NORMAL
) -> TxParams:
tx = super().set_eip1559_fees(tx, tx_speed)
if self.max_fee_per_gas is None:
return tx
node_price = self.w3.eth.gas_price
if node_price and self.max_fee_per_gas >= MAX_FEE_PER_GAS_LIMIT * node_price:
raise SafeOperatorException(
f"{MAX_FEE_PER_GAS_ENV} ({_gwei(self.max_fee_per_gas)} gwei) is {MAX_FEE_PER_GAS_LIMIT} times the "
f"node's gas price ({_gwei(node_price)} gwei) or more. Nothing was sent."
)
tx["maxFeePerGas"] = Wei(self.max_fee_per_gas)
# A node rejects a tip above the cap
tx["maxPriorityFeePerGas"] = min(tx["maxPriorityFeePerGas"], tx["maxFeePerGas"])
return tx
3 changes: 2 additions & 1 deletion src/safe_cli/operators/safe_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
)

from ..contracts import safe_to_l2_migration
from .ethereum_client import SafeCliEthereumClient
from .hw_wallets.hw_wallet import HwWallet
from .hw_wallets.hw_wallet_manager import HwWalletType, get_hw_wallet_manager

Expand Down Expand Up @@ -162,7 +163,7 @@ def __init__(
):
self.address = address
self.node_url = node_url
self.ethereum_client = EthereumClient(self.node_url)
self.ethereum_client = SafeCliEthereumClient(self.node_url)
self.ens = ENS.from_web3(self.ethereum_client.w3)
self.network: EthereumNetwork = self.ethereum_client.get_network()
try:
Expand Down
177 changes: 177 additions & 0 deletions tests/test_ethereum_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import os
import unittest
from unittest import mock

import typer
from eth_account import Account
from safe_eth.eth import EthereumClient
from web3 import Web3

from safe_cli.operators.ethereum_client import (
MAX_FEE_PER_GAS_ENV,
SafeCliEthereumClient,
)
from safe_cli.operators.exceptions import SafeOperatorException
from safe_cli.operators.hw_wallets.hw_wallet_manager import HwWalletManager

from .safe_cli_test_case_mixin import SafeCliTestCaseMixin

GWEI = 10**9


class TestSafeCliEthereumClient(SafeCliTestCaseMixin, unittest.TestCase):
def _client(self, max_fee_per_gas: str | None) -> SafeCliEthereumClient:
with mock.patch.dict(os.environ):
if max_fee_per_gas is None:
os.environ.pop(MAX_FEE_PER_GAS_ENV, None)
else:
os.environ[MAX_FEE_PER_GAS_ENV] = max_fee_per_gas
return SafeCliEthereumClient(self.ethereum_node_url)

def _fees(
self,
ethereum_client: SafeCliEthereumClient,
base_fee: int,
tip: int,
node_price: int = 20 * GWEI,
) -> tuple[dict, mock.PropertyMock]:
with (
mock.patch.object(
EthereumClient, "estimate_fee_eip1559", return_value=(base_fee, tip)
),
mock.patch.object(
type(ethereum_client.w3.eth),
"gas_price",
new_callable=mock.PropertyMock,
return_value=node_price,
) as gas_price,
):
return ethereum_client.set_eip1559_fees({"gasPrice": 1}), gas_price

def test_without_the_variable_nothing_changes(self):
for value in (None, ""):
ethereum_client = self._client(value)
self.assertIsNone(ethereum_client.max_fee_per_gas)
fees, gas_price = self._fees(ethereum_client, 20 * GWEI, 2 * GWEI)
self.assertEqual(fees["maxFeePerGas"], 22 * GWEI)
self.assertEqual(fees["maxPriorityFeePerGas"], 2 * GWEI)
# No request is added either
gas_price.assert_not_called()

def test_max_fee_per_gas(self):
for value, wei in (
("715000", 715_000 * GWEI),
("1.5", 1_500_000_000),
("0.000000001", 1),
):
self.assertEqual(self._client(value).max_fee_per_gas, wei)

# A node that forecasts 0.875 x the fee it charges, with no tip
fees, _ = self._fees(
self._client("715000"), 625_625 * GWEI, 0, node_price=715_000 * GWEI
)
self.assertEqual(fees["maxFeePerGas"], 715_000 * GWEI)
self.assertEqual(fees["maxPriorityFeePerGas"], 0)
self.assertNotIn("gasPrice", fees)

# A cap below the estimated tip lowers the tip to the cap
fees, _ = self._fees(self._client("1.5"), 20 * GWEI, 2 * GWEI)
self.assertEqual(fees["maxFeePerGas"], 1_500_000_000)
self.assertEqual(fees["maxPriorityFeePerGas"], 1_500_000_000)

def test_invalid_values(self):
for value in (
"0",
"0.0",
"-1",
"nan",
"inf",
"fast",
"30gwei",
"1e3",
"1e999999",
"1_0",
" 30 ",
"30,5",
"0x1e",
"٣٠",
"1.0000000001",
):
with self.subTest(value=value):
with self.assertRaisesRegex(
typer.BadParameter, "positive number of gwei"
):
self._client(value)

def test_limit(self):
# The node's gas price is 20 gwei: 197 gwei is under 9.9 times, 198 is 9.9 times
fees, _ = self._fees(self._client("197"), 20 * GWEI, 0)
self.assertEqual(fees["maxFeePerGas"], 197 * GWEI)
for value in ("198", "200", "2000"):
with self.subTest(value=value):
with self.assertRaisesRegex(
SafeOperatorException,
r"\(200 gwei\) is 9.9 times the node's gas price \(20 gwei\)"
if value == "200"
else "9.9 times the node's gas price",
):
self._fees(self._client(value), 20 * GWEI, 0)

# A node that reports a gas price of 0 gives nothing to compare with
fees, _ = self._fees(self._client("200"), 20 * GWEI, 0, node_price=0)
self.assertEqual(fees["maxFeePerGas"], 200 * GWEI)

def _sent_to(self, address: str, since_block: int) -> list:
return [
transaction
for number in range(since_block + 1, self.w3.eth.block_number + 1)
for transaction in self.w3.eth.get_block(number, True)["transactions"]
if transaction["to"] == address
]

def test_every_way_of_sending_uses_it(self):
owner = self.ethereum_test_account
# Above the base fee, so the node includes it, and below the limit
cap = self.w3.eth.get_block("latest")["baseFeePerGas"] * 3
with mock.patch.dict(
os.environ, {MAX_FEE_PER_GAS_ENV: format(Web3.from_wei(cap, "gwei"), "f")}
):
safe_operator = self.setup_operator()
self.assertEqual(safe_operator.ethereum_client.max_fee_per_gas, cap)
safe_operator.accounts.add(owner)
safe_operator.default_sender = owner
self.w3.eth.send_transaction(
{"from": owner.address, "to": safe_operator.address, "value": 10}
)

# Executing a Safe transaction with a local key
block = self.w3.eth.block_number
self.assertTrue(safe_operator.send_ether(Account.create().address, 1))
(transaction,) = self._sent_to(safe_operator.address, block)
self.assertEqual((transaction["type"], transaction["maxFeePerGas"]), (2, cap))

# approve_hash
block = self.w3.eth.block_number
self.assertTrue(
safe_operator.approve_hash(Web3.keccak(text="fee cap"), owner.address)
)
(transaction,) = self._sent_to(safe_operator.address, block)
self.assertEqual((transaction["type"], transaction["maxFeePerGas"]), (2, cap))

# Executing a Safe transaction with a hardware wallet
sender = mock.Mock()
sender.address = owner.address
sender.get_signed_raw_transaction.side_effect = lambda tx, chain_id: (
self.w3.eth.account.sign_transaction(tx, owner.key).raw_transaction
)
safe_operator.default_sender = None
safe_operator.hw_wallet_manager = HwWalletManager()
safe_operator.hw_wallet_manager.sender = sender
block = self.w3.eth.block_number
self.assertTrue(safe_operator.send_ether(Account.create().address, 1))
(transaction,) = self._sent_to(safe_operator.address, block)
self.assertEqual((transaction["type"], transaction["maxFeePerGas"]), (2, cap))


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