From 6d9458c24243e861101c2a23759f2041c66d49d8 Mon Sep 17 00:00:00 2001 From: techdigger <72038289+techdigger@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:14:31 +0000 Subject: [PATCH] Add SAFE_CLI_MAX_FEE_PER_GAS safe-cli takes maxFeePerGas from eth_feeHistory and gives the user no way to change it. A network that charges more than its fee history forecasts refuses each transaction safe-cli sends, and on a busy network the user may want a higher cap. The environment variable SAFE_CLI_MAX_FEE_PER_GAS, in gwei, sets maxFeePerGas for the EIP-1559 transactions safe-cli sends: execution with a local key, approve_hash and execution with a hardware wallet. The tip stays the estimate, lowered to the cap if needed. Without the variable safe-cli behaves as before and makes no extra request. safe-cli refuses a value that is not a positive decimal number when it starts. When it sends, it refuses a cap of 9.9 times the node's gas price or more, which catches one zero too many. --- README.md | 8 + src/safe_cli/operators/ethereum_client.py | 65 ++++++++ src/safe_cli/operators/safe_operator.py | 3 +- tests/test_ethereum_client.py | 177 ++++++++++++++++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 src/safe_cli/operators/ethereum_client.py create mode 100644 tests/test_ethereum_client.py diff --git a/README.md b/README.md index 8254ee93..e8483a83 100644 --- a/README.md +++ b/README.md @@ -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= +``` + +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 diff --git a/src/safe_cli/operators/ethereum_client.py b/src/safe_cli/operators/ethereum_client.py new file mode 100644 index 00000000..ebe05c01 --- /dev/null +++ b/src/safe_cli/operators/ethereum_client.py @@ -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 diff --git a/src/safe_cli/operators/safe_operator.py b/src/safe_cli/operators/safe_operator.py index 0c94779b..610e71d0 100644 --- a/src/safe_cli/operators/safe_operator.py +++ b/src/safe_cli/operators/safe_operator.py @@ -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 @@ -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: diff --git a/tests/test_ethereum_client.py b/tests/test_ethereum_client.py new file mode 100644 index 00000000..6630c865 --- /dev/null +++ b/tests/test_ethereum_client.py @@ -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()