diff --git a/src/story_protocol_python_sdk/utils/transaction_utils.py b/src/story_protocol_python_sdk/utils/transaction_utils.py index 0d80b62..e203cff 100644 --- a/src/story_protocol_python_sdk/utils/transaction_utils.py +++ b/src/story_protocol_python_sdk/utils/transaction_utils.py @@ -1,5 +1,3 @@ -# src/story_protcol_python_sdk/utils/transaction_utils.py - from web3 import Web3 TRANSACTION_TIMEOUT = 300 @@ -19,9 +17,16 @@ def build_and_send_transaction( :param account: The account to use for signing the transaction. :param client_function: The client function to build the transaction. :param client_args: Arguments to pass to the client function. - :param tx_options dict: Optional transaction options. Can include 'nonce' to use a custom nonce value. - If not provided, nonce will be fetched from web3.eth.get_transaction_count(). - :return dict: A dictionary with the transaction hash and receipt, or encoded data if encodedTxDataOnly is True. + :param tx_options dict: Optional transaction options. Can include: + - 'nonce': Custom nonce value (int). If not provided, nonce will be fetched from web3.eth.get_transaction_count(). + - 'wait_for_receipt': Whether to wait for transaction receipt (bool, default True). + - 'timeout': Custom timeout in seconds for waiting for receipt (int/float, default TRANSACTION_TIMEOUT). + - 'encodedTxDataOnly': If True, returns encoded transaction data without sending. + - 'value': Transaction value in wei. + - 'gasPrice': Gas price in gwei. + - 'maxFeePerGas': Max fee per gas in wei. + :return dict: A dictionary with the transaction hash and optionally receipt (if wait_for_receipt is True), + or encoded data if encodedTxDataOnly is True. :raises Exception: If there is an error during the transaction process. """ try: @@ -43,7 +48,6 @@ def build_and_send_transaction( account.address ) - # Add value if it exists in tx_options if "value" in tx_options: transaction_options["value"] = tx_options["value"] @@ -56,18 +60,20 @@ def build_and_send_transaction( transaction = client_function(*client_args, transaction_options) - # If encodedTxDataOnly is True, return the transaction data without sending if tx_options.get("encodedTxDataOnly"): return {"encodedTxData": transaction} signed_txn = account.sign_transaction(transaction) tx_hash = web3.eth.send_raw_transaction(signed_txn.raw_transaction) - tx_receipt = web3.eth.wait_for_transaction_receipt( - tx_hash, timeout=TRANSACTION_TIMEOUT - ) + wait_for_receipt = tx_options.get("wait_for_receipt", True) - return {"tx_hash": tx_hash.hex(), "tx_receipt": tx_receipt} + if wait_for_receipt: + timeout = tx_options.get("timeout", TRANSACTION_TIMEOUT) + tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout) + return {"tx_hash": tx_hash.hex(), "tx_receipt": tx_receipt} + else: + return {"tx_hash": tx_hash.hex()} except Exception as e: raise e diff --git a/tests/integration/test_integration_transaction_utils.py b/tests/integration/test_integration_transaction_utils.py index b88b828..fd14c5d 100644 --- a/tests/integration/test_integration_transaction_utils.py +++ b/tests/integration/test_integration_transaction_utils.py @@ -1,6 +1,7 @@ -# tests/integration/test_integration_transaction_utils.py +import time import pytest +from web3.exceptions import TimeExhausted from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction @@ -122,3 +123,181 @@ def approve_func(tx_options): assert result["tx_receipt"]["status"] == 1 tx = web3.eth.get_transaction(result["tx_hash"]) assert tx["nonce"] == current_nonce + + def test_wait_for_receipt_false_returns_only_tx_hash(self): + """Test that wait_for_receipt=False returns immediately with only tx_hash.""" + + def create_transfer_tx(to_address, value): + def build_tx(tx_options): + return { + "to": to_address, + "value": value, + "data": "0x", + "gas": 21000, + "gasPrice": web3.eth.gas_price, + "chainId": 1315, + **tx_options, + } + + return build_tx + + tx_func = create_transfer_tx(account.address, 0) + result = build_and_send_transaction( + web3, account, tx_func, tx_options={"wait_for_receipt": False} + ) + assert "tx_hash" in result + assert "tx_receipt" not in result + assert len(result["tx_hash"]) == 64 + + tx_receipt = web3.eth.wait_for_transaction_receipt(result["tx_hash"]) + assert tx_receipt["status"] == 1 + + def test_wait_for_receipt_true_returns_receipt(self): + """Test that wait_for_receipt=True (default) returns both tx_hash and receipt.""" + + def create_transfer_tx(to_address, value): + def build_tx(tx_options): + return { + "to": to_address, + "value": value, + "data": "0x", + "gas": 21000, + "gasPrice": web3.eth.gas_price, + "chainId": 1315, + **tx_options, + } + + return build_tx + + tx_func = create_transfer_tx(account.address, 0) + result = build_and_send_transaction( + web3, account, tx_func, tx_options={"wait_for_receipt": True} + ) + + assert "tx_hash" in result + assert "tx_receipt" in result + assert result["tx_receipt"]["status"] == 1 + assert "blockNumber" in result["tx_receipt"] + assert "gasUsed" in result["tx_receipt"] + + def test_custom_timeout_with_transaction(self): + """Test that custom timeout is used when specified.""" + + def create_transfer_tx(to_address, value): + def build_tx(tx_options): + return { + "to": to_address, + "value": value, + "data": "0x", + "gas": 21000, + "gasPrice": web3.eth.gas_price, + "chainId": 1315, + **tx_options, + } + + return build_tx + + tx_func = create_transfer_tx(account.address, 0) + result = build_and_send_transaction( + web3, + account, + tx_func, + tx_options={"wait_for_receipt": True, "timeout": 120}, + ) + + assert "tx_hash" in result + assert "tx_receipt" in result + assert result["tx_receipt"]["status"] == 1 + + def test_combined_options_nonce_wait_timeout(self): + """Test that all new options work correctly together.""" + current_nonce = web3.eth.get_transaction_count(account.address) + + def create_transfer_tx(to_address, value): + def build_tx(tx_options): + return { + "to": to_address, + "value": value, + "data": "0x", + "gas": 21000, + "gasPrice": web3.eth.gas_price, + "chainId": 1315, + **tx_options, + } + + return build_tx + + tx_func = create_transfer_tx(account.address, 0) + result = build_and_send_transaction( + web3, + account, + tx_func, + tx_options={ + "nonce": current_nonce, + "wait_for_receipt": True, + "timeout": 120, + }, + ) + + assert "tx_hash" in result + assert "tx_receipt" in result + assert result["tx_receipt"]["status"] == 1 + + tx = web3.eth.get_transaction(result["tx_hash"]) + assert tx["nonce"] == current_nonce + + def test_wait_for_receipt_false_with_contract_call(self): + """Test wait_for_receipt=False with actual contract interaction.""" + erc20_contract = web3.eth.contract( + address=MockERC20, + abi=[ + { + "inputs": [ + {"name": "spender", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "name": "approve", + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "nonpayable", + "type": "function", + } + ], + ) + + def approve_func(tx_options): + return erc20_contract.functions.approve( + account.address, 200 + ).build_transaction(tx_options) + + time.time() + result = build_and_send_transaction( + web3, account, approve_func, tx_options={"wait_for_receipt": False} + ) + + assert "tx_hash" in result + assert "tx_receipt" not in result + + tx_receipt = web3.eth.wait_for_transaction_receipt(result["tx_hash"]) + assert tx_receipt["status"] == 1 + + def test_timeout_too_short_raises_exception(self): + """Test that very short timeout raises TimeExhausted exception.""" + + def build_tx(tx_options): + return { + "to": account.address, + "value": 0, + "data": "0x", + "gas": 21000, + "gasPrice": web3.eth.gas_price, + "chainId": 1315, + **tx_options, + } + + with pytest.raises(TimeExhausted): + build_and_send_transaction( + web3, + account, + build_tx, + tx_options={"wait_for_receipt": True, "timeout": 0.001}, + ) diff --git a/tests/unit/utils/test_derivative_data.py b/tests/unit/utils/test_derivative_data.py index 7014ea6..1162df4 100644 --- a/tests/unit/utils/test_derivative_data.py +++ b/tests/unit/utils/test_derivative_data.py @@ -1,7 +1,7 @@ from unittest.mock import MagicMock, patch import pytest -from _pytest.raises import raises +from pytest import raises from story_protocol_python_sdk.abi.IPAssetRegistry.IPAssetRegistry_client import ( IPAssetRegistryClient, diff --git a/tests/unit/utils/test_transaction_utils.py b/tests/unit/utils/test_transaction_utils.py index f14c86e..910dae6 100644 --- a/tests/unit/utils/test_transaction_utils.py +++ b/tests/unit/utils/test_transaction_utils.py @@ -1,7 +1,7 @@ from unittest.mock import Mock import pytest -from web3 import Web3 +from web3.exceptions import TimeExhausted from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction @@ -9,21 +9,13 @@ class TestBuildAndSendTransaction: """Test suite for build_and_send_transaction function.""" - @pytest.fixture - def mock_web3(self): - """Create a mock Web3 instance.""" - web3 = Mock(spec=Web3) - web3.eth = Mock() - web3.to_wei = Mock(side_effect=lambda value, unit: value * 1000000000) - return web3 - - @pytest.fixture - def mock_account(self): - """Create a mock account.""" - account = Mock() - account.address = "0x1234567890123456789012345678901234567890" - account.sign_transaction = Mock() - return account + @pytest.fixture(autouse=True) + def setup_mocks(self, mock_web3, mock_account): + """Configure and reset the mocks from conftest for our specific needs.""" + mock_web3.reset_mock() + mock_web3.eth.reset_mock() + mock_account.reset_mock() + mock_web3.to_wei = Mock(side_effect=lambda value, unit: value * 1000000000) @pytest.fixture def mock_client_function(self): @@ -47,11 +39,9 @@ def test_custom_nonce_is_used(self, mock_web3, mock_account, mock_client_functio mock_client_function, tx_options=tx_options, ) - # Verify the client function was called with correct nonce mock_client_function.assert_called_once() call_args = mock_client_function.call_args[0][-1] assert call_args["nonce"] == custom_nonce - # When custom nonce is provided, get_transaction_count should not be called mock_web3.eth.get_transaction_count.assert_not_called() def test_automatic_nonce_fetch_when_not_provided( @@ -241,7 +231,6 @@ def test_encoded_tx_data_only_with_custom_nonce( mock_client_function.assert_called_once() call_args = mock_client_function.call_args[0][-1] assert call_args["nonce"] == custom_nonce - # Verify transaction was not sent mock_account.sign_transaction.assert_not_called() mock_web3.eth.send_raw_transaction.assert_not_called() @@ -270,3 +259,275 @@ def test_no_tx_options_uses_default_nonce( mock_client_function.assert_called_once() call_args = mock_client_function.call_args[0][-1] assert call_args["nonce"] == chain_nonce + + def test_wait_for_receipt_default_true( + self, mock_web3, mock_account, mock_client_function + ): + """Test that wait_for_receipt defaults to True for backward compatibility.""" + mock_web3.eth.get_transaction_count.return_value = 10 + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhash123") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + result = build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options={}, + ) + + mock_web3.eth.wait_for_transaction_receipt.assert_called_once() + assert "tx_hash" in result + assert "tx_receipt" in result + assert result["tx_receipt"] == {"status": 1} + + def test_wait_for_receipt_false_returns_immediately( + self, mock_web3, mock_account, mock_client_function + ): + """Test that wait_for_receipt=False returns immediately without waiting.""" + mock_web3.eth.get_transaction_count.return_value = 10 + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhash456") + ) + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + result = build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options={"wait_for_receipt": False}, + ) + + mock_web3.eth.wait_for_transaction_receipt.assert_not_called() + assert "tx_hash" in result + assert result["tx_hash"] == "0xhash456" + assert "tx_receipt" not in result + + def test_custom_timeout_is_used( + self, mock_web3, mock_account, mock_client_function + ): + """Test that custom timeout from tx_options is used when provided.""" + custom_timeout = 60 + mock_web3.eth.get_transaction_count.return_value = 10 + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhash789") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + result = build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options={"timeout": custom_timeout}, + ) + + mock_web3.eth.wait_for_transaction_receipt.assert_called_once_with( + mock_web3.eth.send_raw_transaction.return_value, timeout=custom_timeout + ) + assert "tx_hash" in result + assert "tx_receipt" in result + + def test_timeout_ignored_when_not_waiting_for_receipt( + self, mock_web3, mock_account, mock_client_function + ): + """Test that timeout is ignored when wait_for_receipt is False.""" + mock_web3.eth.get_transaction_count.return_value = 10 + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhashabc") + ) + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + result = build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options={"wait_for_receipt": False, "timeout": 60}, + ) + + mock_web3.eth.wait_for_transaction_receipt.assert_not_called() + assert "tx_hash" in result + assert "tx_receipt" not in result + + def test_wait_for_receipt_with_custom_nonce_and_timeout( + self, mock_web3, mock_account, mock_client_function + ): + """Test that wait_for_receipt works correctly with other options.""" + tx_options = { + "nonce": 42, + "wait_for_receipt": True, + "timeout": 120, + "value": 1000, + } + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhashdef") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + result = build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options=tx_options, + ) + + mock_client_function.assert_called_once() + call_args = mock_client_function.call_args[0][-1] + assert call_args["nonce"] == 42 + assert call_args["value"] == 1000 + + mock_web3.eth.wait_for_transaction_receipt.assert_called_once_with( + mock_web3.eth.send_raw_transaction.return_value, timeout=120 + ) + assert "tx_hash" in result + assert "tx_receipt" in result + + def test_default_timeout_used_when_not_specified( + self, mock_web3, mock_account, mock_client_function + ): + """Test that default TRANSACTION_TIMEOUT is used when timeout not specified.""" + from story_protocol_python_sdk.utils.transaction_utils import ( + TRANSACTION_TIMEOUT, + ) + + mock_web3.eth.get_transaction_count.return_value = 10 + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhash999") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + result = build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options={"wait_for_receipt": True}, # No timeout specified + ) + + mock_web3.eth.wait_for_transaction_receipt.assert_called_once_with( + mock_web3.eth.send_raw_transaction.return_value, timeout=TRANSACTION_TIMEOUT + ) + assert "tx_hash" in result + assert "tx_receipt" in result + + def test_wait_for_receipt_false_with_encoded_tx_data( + self, mock_web3, mock_account, mock_client_function + ): + """Test that encodedTxDataOnly takes precedence over wait_for_receipt.""" + tx_options = { + "wait_for_receipt": False, + "encodedTxDataOnly": True, + } + expected_tx = {"to": "0xabc", "data": "0x123"} + mock_client_function.return_value = expected_tx + + result = build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options=tx_options, + ) + + assert result == {"encodedTxData": expected_tx} + mock_account.sign_transaction.assert_not_called() + mock_web3.eth.send_raw_transaction.assert_not_called() + mock_web3.eth.wait_for_transaction_receipt.assert_not_called() + + def test_very_short_timeout_value( + self, mock_web3, mock_account, mock_client_function + ): + """Test that very short timeout values are accepted.""" + mock_web3.eth.get_transaction_count.return_value = 10 + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhashxyz") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + result = build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options={"timeout": 1}, # 1 second timeout + ) + + mock_web3.eth.wait_for_transaction_receipt.assert_called_once_with( + mock_web3.eth.send_raw_transaction.return_value, timeout=1 + ) + assert "tx_hash" in result + assert "tx_receipt" in result + + def test_float_timeout_value(self, mock_web3, mock_account, mock_client_function): + """Test that float timeout values are accepted.""" + mock_web3.eth.get_transaction_count.return_value = 10 + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhashfloat") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + result = build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options={"timeout": 45.5}, # Float timeout + ) + + mock_web3.eth.wait_for_transaction_receipt.assert_called_once_with( + mock_web3.eth.send_raw_transaction.return_value, timeout=45.5 + ) + assert "tx_hash" in result + assert "tx_receipt" in result + + def test_wait_for_receipt_explicitly_true( + self, mock_web3, mock_account, mock_client_function + ): + """Test explicitly setting wait_for_receipt to True.""" + mock_web3.eth.get_transaction_count.return_value = 10 + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhashexplicit") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = { + "status": 1, + "blockNumber": 12345, + } + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + result = build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options={"wait_for_receipt": True}, + ) + + mock_web3.eth.wait_for_transaction_receipt.assert_called_once() + assert result["tx_hash"] == "0xhashexplicit" + assert result["tx_receipt"]["status"] == 1 + assert result["tx_receipt"]["blockNumber"] == 12345 + + def test_timeout_exception_is_propagated( + self, mock_web3, mock_account, mock_client_function + ): + """Test that TimeExhausted exception is properly propagated when timeout occurs.""" + mock_web3.eth.get_transaction_count.return_value = 10 + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhashtimeout") + ) + mock_web3.eth.wait_for_transaction_receipt.side_effect = TimeExhausted( + "Transaction receipt not found after 0.5 seconds" + ) + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + with pytest.raises(TimeExhausted): + build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options={"wait_for_receipt": True, "timeout": 0.5}, + ) + mock_web3.eth.wait_for_transaction_receipt.assert_called_once_with( + mock_web3.eth.send_raw_transaction.return_value, timeout=0.5 + )