Skip to content

Feat: estimated gas based on tx #209

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
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
34 changes: 27 additions & 7 deletions src/aleph/sdk/chains/ethereum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -136,14 +156,14 @@ 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:
raise ValueError("Provider not connected")
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
Expand Down
43 changes: 30 additions & 13 deletions src/aleph/sdk/connectors/superfluid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
168 changes: 168 additions & 0 deletions tests/unit/test_gas_estimation.py
Original file line number Diff line number Diff line change
@@ -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
)
Loading