Skip to content
Closed
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
34 changes: 34 additions & 0 deletions src/safe_cli/ethereum_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from safe_eth.eth import EthereumClient, TxSpeed
from web3.types import TxParams, Wei

# Headroom of maxFeePerGas over the forecast base fee. ethers, viem and web3.py's default strategy use the same
# factor: the base fee can rise 12.5% per block, so 2x keeps a transaction valid for about six full blocks in a row.
BASE_FEE_MULTIPLIER = 2


class SafeCliEthereumClient(EthereumClient):
"""
``EthereumClient`` whose EIP-1559 fees leave headroom over the forecast base fee.

``EthereumClient.set_eip1559_fees`` sets ``maxFeePerGas`` to the next block's forecast base fee plus the tip,
with no headroom. If the base fee rises before the transaction is included, the cap falls below it and the
transaction waits. A chain that charges more than its fee history forecasts (a fixed-fee chain whose node still
projects a falling base fee) refuses the transaction outright.

``maxFeePerGas`` is a cap, not a price: the sender still pays the base fee plus the tip. The sender's balance
must cover ``gas * maxFeePerGas``, so a higher cap needs a larger balance.
"""

def set_eip1559_fees(
self, tx: TxParams, tx_speed: TxSpeed = TxSpeed.NORMAL
) -> TxParams:
"""
:return: TxParams in EIP1559 format, with ``maxFeePerGas = BASE_FEE_MULTIPLIER * base fee + tip``
:raises: ValueError if EIP1559 not supported
"""
tx = super().set_eip1559_fees(tx, tx_speed)
# The parent sets maxFeePerGas = base fee + tip
tip = tx["maxPriorityFeePerGas"]
base_fee = tx["maxFeePerGas"] - tip
tx["maxFeePerGas"] = Wei(BASE_FEE_MULTIPLIER * base_fee + tip)
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 @@ -38,6 +38,7 @@
from web3.contract import Contract
from web3.exceptions import BadFunctionCallOutput

from safe_cli.ethereum_client import SafeCliEthereumClient
from safe_cli.ethereum_hd_wallet import get_account_from_words
from safe_cli.operators.exceptions import (
AccountNotLoadedException,
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
103 changes: 103 additions & 0 deletions tests/test_ethereum_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import unittest
from unittest import mock

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

from safe_cli.ethereum_client import BASE_FEE_MULTIPLIER, SafeCliEthereumClient
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 _fees(self, base_fee: int, tip: int) -> dict:
ethereum_client = SafeCliEthereumClient(self.ethereum_node_url)
with mock.patch.object(
EthereumClient, "estimate_fee_eip1559", return_value=(base_fee, tip)
):
return ethereum_client.set_eip1559_fees({"gasPrice": 1})

def test_headroom_over_the_forecast(self):
self.assertEqual(BASE_FEE_MULTIPLIER, 2)
fees = self._fees(20 * GWEI, 2 * GWEI)
self.assertEqual(fees["maxFeePerGas"], 42 * GWEI)
self.assertEqual(fees["maxPriorityFeePerGas"], 2 * GWEI)
self.assertNotIn("gasPrice", fees)

fees = self._fees(0, 0)
self.assertEqual(fees["maxFeePerGas"], 0)

def test_chain_charging_more_than_it_forecasts(self):
# A node that forecasts 625,625 gwei for the next block and charges 715,000: the cap clears it
fees = self._fees(625_625 * GWEI, 0)
self.assertEqual(fees["maxFeePerGas"], 1_251_250 * GWEI)
self.assertEqual(fees["maxPriorityFeePerGas"], 0)

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 _assert_included_with_headroom(self, transaction, forecast: int):
self.assertEqual(transaction["type"], 2)
self.assertEqual(
transaction["maxFeePerGas"],
BASE_FEE_MULTIPLIER * forecast + transaction["maxPriorityFeePerGas"],
)
self.assertGreaterEqual(
transaction["maxFeePerGas"],
self.w3.eth.get_block(transaction["blockNumber"])["baseFeePerGas"],
)

def test_every_way_of_sending_uses_it(self):
safe_operator = self.setup_operator()
owner = self.ethereum_test_account
safe_operator.accounts.add(owner)
safe_operator.default_sender = owner
self.w3.eth.send_transaction(
{"from": owner.address, "to": safe_operator.address, "value": 10}
)
# A fee history that forecasts 0.6 x the base fee the chain charges. Without headroom the cap is below
# the base fee and the node refuses the transaction: "max fee per gas less than block base fee".
forecast = self.w3.eth.get_block("latest")["baseFeePerGas"] * 6 // 10
with mock.patch.object(
EthereumClient, "estimate_fee_eip1559", return_value=(forecast, 0)
):
# 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._assert_included_with_headroom(transaction, forecast)

# approve_hash
block = self.w3.eth.block_number
self.assertTrue(
safe_operator.approve_hash(Web3.keccak(text="headroom"), owner.address)
)
(transaction,) = self._sent_to(safe_operator.address, block)
self._assert_included_with_headroom(transaction, forecast)

# 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._assert_included_with_headroom(transaction, forecast)


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