diff --git a/src/aleph/sdk/chains/ethereum.py b/src/aleph/sdk/chains/ethereum.py index 8815825e..02bebd8f 100644 --- a/src/aleph/sdk/chains/ethereum.py +++ b/src/aleph/sdk/chains/ethereum.py @@ -11,6 +11,7 @@ from eth_keys.exceptions import BadSignature as EthBadSignatureError from superfluid import Web3FlowInfo from web3 import Web3 +from web3.exceptions import ContractCustomError from web3.middleware import ExtraDataToPOAMiddleware from web3.types import TxParams, TxReceipt @@ -21,7 +22,6 @@ from ..connectors.superfluid import Superfluid from ..evm_utils import ( BALANCEOF_ABI, - MIN_ETH_BALANCE, MIN_ETH_BALANCE_WEI, FlowUpdate, from_wei_token, @@ -119,14 +119,34 @@ def connect_chain(self, chain: Optional[Chain] = None): def switch_chain(self, chain: Optional[Chain] = None): self.connect_chain(chain=chain) - def can_transact(self, block=True) -> bool: - balance = self.get_eth_balance() - valid = balance > MIN_ETH_BALANCE_WEI if self.chain else False + def can_transact(self, tx: TxParams, block=True) -> bool: + balance_wei = self.get_eth_balance() + try: + assert self._provider is not None + + estimated_gas = self._provider.eth.estimate_gas(tx) + + gas_price = tx.get("gasPrice", self._provider.eth.gas_price) + + if "maxFeePerGas" in tx: + max_fee = tx["maxFeePerGas"] + total_fee_wei = estimated_gas * max_fee + else: + total_fee_wei = estimated_gas * gas_price + + total_fee_wei = int(total_fee_wei * 1.2) + + except ContractCustomError: + total_fee_wei = MIN_ETH_BALANCE_WEI # Fallback if estimation fails + + required_fee_wei = total_fee_wei + (tx.get("value", 0)) + + valid = balance_wei > required_fee_wei if self.chain else False if not valid and block: raise InsufficientFundsError( token_type=TokenType.GAS, - required_funds=MIN_ETH_BALANCE, - available_funds=float(from_wei_token(balance)), + required_funds=float(from_wei_token(required_fee_wei)), + available_funds=float(from_wei_token(balance_wei)), ) return valid @@ -136,7 +156,6 @@ async def _sign_and_send_transaction(self, tx_params: TxParams) -> str: @param tx_params - Transaction parameters @returns - str - Transaction hash """ - self.can_transact() def sign_and_send() -> TxReceipt: if self._provider is None: @@ -144,6 +163,7 @@ def sign_and_send() -> TxReceipt: signed_tx = self._provider.eth.account.sign_transaction( tx_params, self._account.key ) + tx_hash = self._provider.eth.send_raw_transaction(signed_tx.raw_transaction) tx_receipt = self._provider.eth.wait_for_transaction_receipt( tx_hash, settings.TX_TIMEOUT diff --git a/src/aleph/sdk/connectors/superfluid.py b/src/aleph/sdk/connectors/superfluid.py index 76bbf907..cd971b74 100644 --- a/src/aleph/sdk/connectors/superfluid.py +++ b/src/aleph/sdk/connectors/superfluid.py @@ -5,6 +5,7 @@ from eth_utils import to_normalized_address from superfluid import CFA_V1, Operation, Web3FlowInfo +from web3.exceptions import ContractCustomError from aleph.sdk.evm_utils import ( FlowUpdate, @@ -37,6 +38,32 @@ def __init__(self, account: ETHAccount): self.super_token = str(get_super_token_address(account.chain)) self.cfaV1Instance = CFA_V1(account.rpc, account.chain_id) + def _simulate_create_tx_flow(self, flow: Decimal, block=True) -> bool: + try: + operation = self.cfaV1Instance.create_flow( + sender=self.normalized_address, + receiver=to_normalized_address( + "0x0000000000000000000000000000000000000001" + ), # Fake Address we do not sign/send this transactions + super_token=self.super_token, + flow_rate=int(to_wei_token(flow)), + ) + + populated_transaction = operation._get_populated_transaction_request( + self.account.rpc, self.account._account.key + ) + return self.account.can_transact(tx=populated_transaction, block=block) + except ContractCustomError as e: + if getattr(e, "data", None) == "0xea76c9b3": + balance = self.account.get_super_token_balance() + MIN_FLOW_4H = to_wei_token(flow) * Decimal(self.MIN_4_HOURS) + raise InsufficientFundsError( + token_type=TokenType.ALEPH, + required_funds=float(from_wei_token(MIN_FLOW_4H)), + available_funds=float(from_wei_token(balance)), + ) + return False + async def _execute_operation_with_account(self, operation: Operation) -> str: """ Execute an operation using the provided ETHAccount @@ -46,26 +73,16 @@ async def _execute_operation_with_account(self, operation: Operation) -> str: populated_transaction = operation._get_populated_transaction_request( self.account.rpc, self.account._account.key ) + self.account.can_transact(tx=populated_transaction) + return await self.account._sign_and_send_transaction(populated_transaction) def can_start_flow(self, flow: Decimal, block=True) -> bool: """Check if the account has enough funds to start a Superfluid flow of the given size.""" - valid = False - if self.account.can_transact(block=block): - balance = self.account.get_super_token_balance() - MIN_FLOW_4H = to_wei_token(flow) * Decimal(self.MIN_4_HOURS) - valid = balance > MIN_FLOW_4H - if not valid and block: - raise InsufficientFundsError( - token_type=TokenType.ALEPH, - required_funds=float(from_wei_token(MIN_FLOW_4H)), - available_funds=float(from_wei_token(balance)), - ) - return valid + return self._simulate_create_tx_flow(flow=flow, block=block) async def create_flow(self, receiver: str, flow: Decimal) -> str: """Create a Superfluid flow between two addresses.""" - self.can_start_flow(flow) return await self._execute_operation_with_account( operation=self.cfaV1Instance.create_flow( sender=self.normalized_address, diff --git a/tests/unit/test_gas_estimation.py b/tests/unit/test_gas_estimation.py new file mode 100644 index 00000000..abbd8c5c --- /dev/null +++ b/tests/unit/test_gas_estimation.py @@ -0,0 +1,168 @@ +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest +from aleph_message.models import Chain +from web3.exceptions import ContractCustomError +from web3.types import TxParams + +from aleph.sdk.chains.ethereum import ETHAccount +from aleph.sdk.connectors.superfluid import Superfluid +from aleph.sdk.exceptions import InsufficientFundsError +from aleph.sdk.types import TokenType + + +@pytest.fixture +def mock_eth_account(): + private_key = b"\x01" * 32 + account = ETHAccount( + private_key, + chain=Chain.ETH, + ) + account._provider = MagicMock() + account._provider.eth = MagicMock() + account._provider.eth.gas_price = 20_000_000_000 # 20 Gwei + account._provider.eth.estimate_gas = MagicMock( + return_value=100_000 + ) # 100k gas units + + # Mock get_eth_balance to return a specific balance + with patch.object(account, "get_eth_balance", return_value=10**18): # 1 ETH + yield account + + +@pytest.fixture +def mock_superfluid(mock_eth_account): + superfluid = Superfluid(mock_eth_account) + superfluid.cfaV1Instance = MagicMock() + superfluid.cfaV1Instance.create_flow = MagicMock() + superfluid.super_token = "0xsupertokenaddress" + superfluid.normalized_address = "0xsenderaddress" + + # Mock the operation + operation = MagicMock() + operation._get_populated_transaction_request = MagicMock( + return_value={"value": 0, "gas": 100000, "gasPrice": 20_000_000_000} + ) + superfluid.cfaV1Instance.create_flow.return_value = operation + + return superfluid + + +class TestGasEstimation: + def test_can_transact_with_sufficient_funds(self, mock_eth_account): + tx = TxParams({"to": "0xreceiver", "value": 0}) + + # Should pass with 1 ETH balance against ~0.002 ETH gas cost + assert mock_eth_account.can_transact(tx=tx, block=True) is True + + def test_can_transact_with_insufficient_funds(self, mock_eth_account): + tx = TxParams({"to": "0xreceiver", "value": 0}) + + # Set balance to almost zero + with patch.object(mock_eth_account, "get_eth_balance", return_value=1000): + # Should raise InsufficientFundsError + with pytest.raises(InsufficientFundsError) as exc_info: + mock_eth_account.can_transact(tx=tx, block=True) + + assert exc_info.value.token_type == TokenType.GAS + + def test_can_transact_with_legacy_gas_price(self, mock_eth_account): + tx = TxParams( + {"to": "0xreceiver", "value": 0, "gasPrice": 30_000_000_000} # 30 Gwei + ) + + # Should use the tx's gasPrice instead of default + mock_eth_account.can_transact(tx=tx, block=True) + + # It should have used the tx's gasPrice for calculation + mock_eth_account._provider.eth.estimate_gas.assert_called_once() + + def test_can_transact_with_eip1559_gas(self, mock_eth_account): + tx = TxParams( + {"to": "0xreceiver", "value": 0, "maxFeePerGas": 40_000_000_000} # 40 Gwei + ) + + # Should use the tx's maxFeePerGas + mock_eth_account.can_transact(tx=tx, block=True) + + # It should have used the tx's maxFeePerGas for calculation + mock_eth_account._provider.eth.estimate_gas.assert_called_once() + + def test_can_transact_with_contract_error(self, mock_eth_account): + tx = TxParams({"to": "0xreceiver", "value": 0}) + + # Make estimate_gas throw a ContractCustomError + mock_eth_account._provider.eth.estimate_gas.side_effect = ContractCustomError( + "error" + ) + + # Should fallback to MIN_ETH_BALANCE_WEI + mock_eth_account.can_transact(tx=tx, block=True) + + # It should have called estimate_gas + mock_eth_account._provider.eth.estimate_gas.assert_called_once() + + +class TestSuperfluidFlowEstimation: + @pytest.mark.asyncio + async def test_simulate_create_tx_flow_success( + self, mock_superfluid, mock_eth_account + ): + # Patch the can_transact method to simulate a successful transaction + with patch.object(mock_eth_account, "can_transact", return_value=True): + result = mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005")) + assert result is True + + # Verify the flow was correctly simulated but not executed + mock_superfluid.cfaV1Instance.create_flow.assert_called_once() + assert "0x0000000000000000000000000000000000000001" in str( + mock_superfluid.cfaV1Instance.create_flow.call_args + ) + + @pytest.mark.asyncio + async def test_simulate_create_tx_flow_contract_error( + self, mock_superfluid, mock_eth_account + ): + # Setup a contract error code for insufficient deposit + error = ContractCustomError("Insufficient deposit") + error.data = "0xea76c9b3" # This is the specific error code checked in the code + + # Mock can_transact to throw the error + with patch.object(mock_eth_account, "can_transact", side_effect=error): + # Also mock get_super_token_balance for the error case + with patch.object( + mock_eth_account, "get_super_token_balance", return_value=0 + ): + # Should raise InsufficientFundsError for ALEPH token + with pytest.raises(InsufficientFundsError) as exc_info: + mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005")) + + assert exc_info.value.token_type == TokenType.ALEPH + + @pytest.mark.asyncio + async def test_simulate_create_tx_flow_other_error( + self, mock_superfluid, mock_eth_account + ): + # Setup a different contract error code + error = ContractCustomError("Other error") + error.data = "0xsomeothercode" + + # Mock can_transact to throw the error + with patch.object(mock_eth_account, "can_transact", side_effect=error): + # Should return False for other errors + result = mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005")) + assert result is False + + @pytest.mark.asyncio + async def test_can_start_flow_uses_simulation(self, mock_superfluid): + # Mock _simulate_create_tx_flow to verify it's called + with patch.object( + mock_superfluid, "_simulate_create_tx_flow", return_value=True + ) as mock_simulate: + result = mock_superfluid.can_start_flow(Decimal("0.00000005")) + + assert result is True + mock_simulate.assert_called_once_with( + flow=Decimal("0.00000005"), block=True + )