Skip to content

Commit 4eb207c

Browse files
authored
Feat: estimated gas based on tx (#209)
* fix: use estimate_gas in can_transact for accurate limit checks * feat: implement _simulate_create_tx_flow to estimate gas for specific Superfluid transactions * feat: add can_transact check in _execute_operation_with_account to prevent underfunded tx * fix: remove unnecessary can_start_flow check in create_flow * fix: should use MIN_ETH_BALANCE_WEI instead of MIN_ETH_BALANCE * fix: ensure _provider exist while using can_transact * fix: return false if error got returned while trying to estimate the gas cost in _simulate_create_tx_flow * Fix: gas estimations + error handling for gas / Aleph token * fix: linting error `hatch` * Feature: gas estimations unit test * fix: linting * fix: mypy cannot assign method
1 parent 90b9da0 commit 4eb207c

File tree

3 files changed

+225
-20
lines changed

3 files changed

+225
-20
lines changed

src/aleph/sdk/chains/ethereum.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from eth_keys.exceptions import BadSignature as EthBadSignatureError
1212
from superfluid import Web3FlowInfo
1313
from web3 import Web3
14+
from web3.exceptions import ContractCustomError
1415
from web3.middleware import ExtraDataToPOAMiddleware
1516
from web3.types import TxParams, TxReceipt
1617

@@ -21,7 +22,6 @@
2122
from ..connectors.superfluid import Superfluid
2223
from ..evm_utils import (
2324
BALANCEOF_ABI,
24-
MIN_ETH_BALANCE,
2525
MIN_ETH_BALANCE_WEI,
2626
FlowUpdate,
2727
from_wei_token,
@@ -119,14 +119,34 @@ def connect_chain(self, chain: Optional[Chain] = None):
119119
def switch_chain(self, chain: Optional[Chain] = None):
120120
self.connect_chain(chain=chain)
121121

122-
def can_transact(self, block=True) -> bool:
123-
balance = self.get_eth_balance()
124-
valid = balance > MIN_ETH_BALANCE_WEI if self.chain else False
122+
def can_transact(self, tx: TxParams, block=True) -> bool:
123+
balance_wei = self.get_eth_balance()
124+
try:
125+
assert self._provider is not None
126+
127+
estimated_gas = self._provider.eth.estimate_gas(tx)
128+
129+
gas_price = tx.get("gasPrice", self._provider.eth.gas_price)
130+
131+
if "maxFeePerGas" in tx:
132+
max_fee = tx["maxFeePerGas"]
133+
total_fee_wei = estimated_gas * max_fee
134+
else:
135+
total_fee_wei = estimated_gas * gas_price
136+
137+
total_fee_wei = int(total_fee_wei * 1.2)
138+
139+
except ContractCustomError:
140+
total_fee_wei = MIN_ETH_BALANCE_WEI # Fallback if estimation fails
141+
142+
required_fee_wei = total_fee_wei + (tx.get("value", 0))
143+
144+
valid = balance_wei > required_fee_wei if self.chain else False
125145
if not valid and block:
126146
raise InsufficientFundsError(
127147
token_type=TokenType.GAS,
128-
required_funds=MIN_ETH_BALANCE,
129-
available_funds=float(from_wei_token(balance)),
148+
required_funds=float(from_wei_token(required_fee_wei)),
149+
available_funds=float(from_wei_token(balance_wei)),
130150
)
131151
return valid
132152

@@ -136,14 +156,14 @@ async def _sign_and_send_transaction(self, tx_params: TxParams) -> str:
136156
@param tx_params - Transaction parameters
137157
@returns - str - Transaction hash
138158
"""
139-
self.can_transact()
140159

141160
def sign_and_send() -> TxReceipt:
142161
if self._provider is None:
143162
raise ValueError("Provider not connected")
144163
signed_tx = self._provider.eth.account.sign_transaction(
145164
tx_params, self._account.key
146165
)
166+
147167
tx_hash = self._provider.eth.send_raw_transaction(signed_tx.raw_transaction)
148168
tx_receipt = self._provider.eth.wait_for_transaction_receipt(
149169
tx_hash, settings.TX_TIMEOUT

src/aleph/sdk/connectors/superfluid.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from eth_utils import to_normalized_address
77
from superfluid import CFA_V1, Operation, Web3FlowInfo
8+
from web3.exceptions import ContractCustomError
89

910
from aleph.sdk.evm_utils import (
1011
FlowUpdate,
@@ -37,6 +38,32 @@ def __init__(self, account: ETHAccount):
3738
self.super_token = str(get_super_token_address(account.chain))
3839
self.cfaV1Instance = CFA_V1(account.rpc, account.chain_id)
3940

41+
def _simulate_create_tx_flow(self, flow: Decimal, block=True) -> bool:
42+
try:
43+
operation = self.cfaV1Instance.create_flow(
44+
sender=self.normalized_address,
45+
receiver=to_normalized_address(
46+
"0x0000000000000000000000000000000000000001"
47+
), # Fake Address we do not sign/send this transactions
48+
super_token=self.super_token,
49+
flow_rate=int(to_wei_token(flow)),
50+
)
51+
52+
populated_transaction = operation._get_populated_transaction_request(
53+
self.account.rpc, self.account._account.key
54+
)
55+
return self.account.can_transact(tx=populated_transaction, block=block)
56+
except ContractCustomError as e:
57+
if getattr(e, "data", None) == "0xea76c9b3":
58+
balance = self.account.get_super_token_balance()
59+
MIN_FLOW_4H = to_wei_token(flow) * Decimal(self.MIN_4_HOURS)
60+
raise InsufficientFundsError(
61+
token_type=TokenType.ALEPH,
62+
required_funds=float(from_wei_token(MIN_FLOW_4H)),
63+
available_funds=float(from_wei_token(balance)),
64+
)
65+
return False
66+
4067
async def _execute_operation_with_account(self, operation: Operation) -> str:
4168
"""
4269
Execute an operation using the provided ETHAccount
@@ -46,26 +73,16 @@ async def _execute_operation_with_account(self, operation: Operation) -> str:
4673
populated_transaction = operation._get_populated_transaction_request(
4774
self.account.rpc, self.account._account.key
4875
)
76+
self.account.can_transact(tx=populated_transaction)
77+
4978
return await self.account._sign_and_send_transaction(populated_transaction)
5079

5180
def can_start_flow(self, flow: Decimal, block=True) -> bool:
5281
"""Check if the account has enough funds to start a Superfluid flow of the given size."""
53-
valid = False
54-
if self.account.can_transact(block=block):
55-
balance = self.account.get_super_token_balance()
56-
MIN_FLOW_4H = to_wei_token(flow) * Decimal(self.MIN_4_HOURS)
57-
valid = balance > MIN_FLOW_4H
58-
if not valid and block:
59-
raise InsufficientFundsError(
60-
token_type=TokenType.ALEPH,
61-
required_funds=float(from_wei_token(MIN_FLOW_4H)),
62-
available_funds=float(from_wei_token(balance)),
63-
)
64-
return valid
82+
return self._simulate_create_tx_flow(flow=flow, block=block)
6583

6684
async def create_flow(self, receiver: str, flow: Decimal) -> str:
6785
"""Create a Superfluid flow between two addresses."""
68-
self.can_start_flow(flow)
6986
return await self._execute_operation_with_account(
7087
operation=self.cfaV1Instance.create_flow(
7188
sender=self.normalized_address,

tests/unit/test_gas_estimation.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from decimal import Decimal
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
from aleph_message.models import Chain
6+
from web3.exceptions import ContractCustomError
7+
from web3.types import TxParams
8+
9+
from aleph.sdk.chains.ethereum import ETHAccount
10+
from aleph.sdk.connectors.superfluid import Superfluid
11+
from aleph.sdk.exceptions import InsufficientFundsError
12+
from aleph.sdk.types import TokenType
13+
14+
15+
@pytest.fixture
16+
def mock_eth_account():
17+
private_key = b"\x01" * 32
18+
account = ETHAccount(
19+
private_key,
20+
chain=Chain.ETH,
21+
)
22+
account._provider = MagicMock()
23+
account._provider.eth = MagicMock()
24+
account._provider.eth.gas_price = 20_000_000_000 # 20 Gwei
25+
account._provider.eth.estimate_gas = MagicMock(
26+
return_value=100_000
27+
) # 100k gas units
28+
29+
# Mock get_eth_balance to return a specific balance
30+
with patch.object(account, "get_eth_balance", return_value=10**18): # 1 ETH
31+
yield account
32+
33+
34+
@pytest.fixture
35+
def mock_superfluid(mock_eth_account):
36+
superfluid = Superfluid(mock_eth_account)
37+
superfluid.cfaV1Instance = MagicMock()
38+
superfluid.cfaV1Instance.create_flow = MagicMock()
39+
superfluid.super_token = "0xsupertokenaddress"
40+
superfluid.normalized_address = "0xsenderaddress"
41+
42+
# Mock the operation
43+
operation = MagicMock()
44+
operation._get_populated_transaction_request = MagicMock(
45+
return_value={"value": 0, "gas": 100000, "gasPrice": 20_000_000_000}
46+
)
47+
superfluid.cfaV1Instance.create_flow.return_value = operation
48+
49+
return superfluid
50+
51+
52+
class TestGasEstimation:
53+
def test_can_transact_with_sufficient_funds(self, mock_eth_account):
54+
tx = TxParams({"to": "0xreceiver", "value": 0})
55+
56+
# Should pass with 1 ETH balance against ~0.002 ETH gas cost
57+
assert mock_eth_account.can_transact(tx=tx, block=True) is True
58+
59+
def test_can_transact_with_insufficient_funds(self, mock_eth_account):
60+
tx = TxParams({"to": "0xreceiver", "value": 0})
61+
62+
# Set balance to almost zero
63+
with patch.object(mock_eth_account, "get_eth_balance", return_value=1000):
64+
# Should raise InsufficientFundsError
65+
with pytest.raises(InsufficientFundsError) as exc_info:
66+
mock_eth_account.can_transact(tx=tx, block=True)
67+
68+
assert exc_info.value.token_type == TokenType.GAS
69+
70+
def test_can_transact_with_legacy_gas_price(self, mock_eth_account):
71+
tx = TxParams(
72+
{"to": "0xreceiver", "value": 0, "gasPrice": 30_000_000_000} # 30 Gwei
73+
)
74+
75+
# Should use the tx's gasPrice instead of default
76+
mock_eth_account.can_transact(tx=tx, block=True)
77+
78+
# It should have used the tx's gasPrice for calculation
79+
mock_eth_account._provider.eth.estimate_gas.assert_called_once()
80+
81+
def test_can_transact_with_eip1559_gas(self, mock_eth_account):
82+
tx = TxParams(
83+
{"to": "0xreceiver", "value": 0, "maxFeePerGas": 40_000_000_000} # 40 Gwei
84+
)
85+
86+
# Should use the tx's maxFeePerGas
87+
mock_eth_account.can_transact(tx=tx, block=True)
88+
89+
# It should have used the tx's maxFeePerGas for calculation
90+
mock_eth_account._provider.eth.estimate_gas.assert_called_once()
91+
92+
def test_can_transact_with_contract_error(self, mock_eth_account):
93+
tx = TxParams({"to": "0xreceiver", "value": 0})
94+
95+
# Make estimate_gas throw a ContractCustomError
96+
mock_eth_account._provider.eth.estimate_gas.side_effect = ContractCustomError(
97+
"error"
98+
)
99+
100+
# Should fallback to MIN_ETH_BALANCE_WEI
101+
mock_eth_account.can_transact(tx=tx, block=True)
102+
103+
# It should have called estimate_gas
104+
mock_eth_account._provider.eth.estimate_gas.assert_called_once()
105+
106+
107+
class TestSuperfluidFlowEstimation:
108+
@pytest.mark.asyncio
109+
async def test_simulate_create_tx_flow_success(
110+
self, mock_superfluid, mock_eth_account
111+
):
112+
# Patch the can_transact method to simulate a successful transaction
113+
with patch.object(mock_eth_account, "can_transact", return_value=True):
114+
result = mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005"))
115+
assert result is True
116+
117+
# Verify the flow was correctly simulated but not executed
118+
mock_superfluid.cfaV1Instance.create_flow.assert_called_once()
119+
assert "0x0000000000000000000000000000000000000001" in str(
120+
mock_superfluid.cfaV1Instance.create_flow.call_args
121+
)
122+
123+
@pytest.mark.asyncio
124+
async def test_simulate_create_tx_flow_contract_error(
125+
self, mock_superfluid, mock_eth_account
126+
):
127+
# Setup a contract error code for insufficient deposit
128+
error = ContractCustomError("Insufficient deposit")
129+
error.data = "0xea76c9b3" # This is the specific error code checked in the code
130+
131+
# Mock can_transact to throw the error
132+
with patch.object(mock_eth_account, "can_transact", side_effect=error):
133+
# Also mock get_super_token_balance for the error case
134+
with patch.object(
135+
mock_eth_account, "get_super_token_balance", return_value=0
136+
):
137+
# Should raise InsufficientFundsError for ALEPH token
138+
with pytest.raises(InsufficientFundsError) as exc_info:
139+
mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005"))
140+
141+
assert exc_info.value.token_type == TokenType.ALEPH
142+
143+
@pytest.mark.asyncio
144+
async def test_simulate_create_tx_flow_other_error(
145+
self, mock_superfluid, mock_eth_account
146+
):
147+
# Setup a different contract error code
148+
error = ContractCustomError("Other error")
149+
error.data = "0xsomeothercode"
150+
151+
# Mock can_transact to throw the error
152+
with patch.object(mock_eth_account, "can_transact", side_effect=error):
153+
# Should return False for other errors
154+
result = mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005"))
155+
assert result is False
156+
157+
@pytest.mark.asyncio
158+
async def test_can_start_flow_uses_simulation(self, mock_superfluid):
159+
# Mock _simulate_create_tx_flow to verify it's called
160+
with patch.object(
161+
mock_superfluid, "_simulate_create_tx_flow", return_value=True
162+
) as mock_simulate:
163+
result = mock_superfluid.can_start_flow(Decimal("0.00000005"))
164+
165+
assert result is True
166+
mock_simulate.assert_called_once_with(
167+
flow=Decimal("0.00000005"), block=True
168+
)

0 commit comments

Comments
 (0)