From bb9dccc35bd7cef5eef8985de78be3b90d424d45 Mon Sep 17 00:00:00 2001 From: Sebastian Liu Date: Sun, 10 Aug 2025 13:21:23 -0700 Subject: [PATCH 1/3] feat: add custom nonce support to transaction utils - Allow passing custom nonce via tx_options parameter - Add validation to ensure nonce is a non-negative integer - Fallback to web3.eth.get_transaction_count() when nonce not provided - Add comprehensive unit tests for nonce handling This enables advanced transaction management scenarios like: - Transaction replacement with same nonce - Manual nonce management for high-throughput applications - Filling nonce gaps from failed transactions --- .../utils/transaction_utils.py | 16 +- tests/unit/utils/__init__.py | 1 + tests/unit/utils/test_transaction_utils.py | 272 ++++++++++++++++++ 3 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 tests/unit/utils/__init__.py create mode 100644 tests/unit/utils/test_transaction_utils.py diff --git a/src/story_protocol_python_sdk/utils/transaction_utils.py b/src/story_protocol_python_sdk/utils/transaction_utils.py index 3c14c6a..0d80b62 100644 --- a/src/story_protocol_python_sdk/utils/transaction_utils.py +++ b/src/story_protocol_python_sdk/utils/transaction_utils.py @@ -19,7 +19,8 @@ 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. + :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. :raises Exception: If there is an error during the transaction process. """ @@ -28,9 +29,20 @@ def build_and_send_transaction( transaction_options = { "from": account.address, - "nonce": web3.eth.get_transaction_count(account.address), } + if "nonce" in tx_options: + nonce = tx_options["nonce"] + if not isinstance(nonce, int) or nonce < 0: + raise ValueError( + f"Invalid nonce value: {nonce}. Nonce must be a non-negative integer." + ) + transaction_options["nonce"] = nonce + else: + transaction_options["nonce"] = web3.eth.get_transaction_count( + account.address + ) + # Add value if it exists in tx_options if "value" in tx_options: transaction_options["value"] = tx_options["value"] diff --git a/tests/unit/utils/__init__.py b/tests/unit/utils/__init__.py new file mode 100644 index 0000000..1824c39 --- /dev/null +++ b/tests/unit/utils/__init__.py @@ -0,0 +1 @@ +# Unit tests for utils module diff --git a/tests/unit/utils/test_transaction_utils.py b/tests/unit/utils/test_transaction_utils.py new file mode 100644 index 0000000..f14c86e --- /dev/null +++ b/tests/unit/utils/test_transaction_utils.py @@ -0,0 +1,272 @@ +from unittest.mock import Mock + +import pytest +from web3 import Web3 + +from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction + + +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 + def mock_client_function(self): + """Create a mock client function.""" + return Mock(return_value={"to": "0xabc", "data": "0x123"}) + + def test_custom_nonce_is_used(self, mock_web3, mock_account, mock_client_function): + """Test that custom nonce from tx_options is used when provided.""" + custom_nonce = 42 + tx_options = {"nonce": custom_nonce} + mock_web3.eth.get_transaction_count.return_value = 10 # Should not be used + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhash") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + build_and_send_transaction( + mock_web3, + mock_account, + 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( + self, mock_web3, mock_account, mock_client_function + ): + """Test that nonce is fetched from chain when not provided in tx_options.""" + chain_nonce = 25 + mock_web3.eth.get_transaction_count.return_value = chain_nonce + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhash") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options={}, + ) + + mock_web3.eth.get_transaction_count.assert_called_once_with( + mock_account.address + ) + mock_client_function.assert_called_once() + call_args = mock_client_function.call_args[0][-1] + assert call_args["nonce"] == chain_nonce + + def test_nonce_validation_negative_value_raises_error( + self, mock_web3, mock_account, mock_client_function + ): + """Test that negative nonce values raise ValueError.""" + tx_options = {"nonce": -1} + + with pytest.raises(ValueError) as exc_info: + build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options=tx_options, + ) + + assert "Invalid nonce value: -1" in str(exc_info.value) + assert "must be a non-negative integer" in str(exc_info.value) + + def test_nonce_validation_string_value_raises_error( + self, mock_web3, mock_account, mock_client_function + ): + """Test that string nonce values raise ValueError.""" + tx_options = {"nonce": "123"} + + with pytest.raises(ValueError) as exc_info: + build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options=tx_options, + ) + + assert "Invalid nonce value: 123" in str(exc_info.value) + assert "must be a non-negative integer" in str(exc_info.value) + + def test_nonce_validation_float_value_raises_error( + self, mock_web3, mock_account, mock_client_function + ): + """Test that float nonce values raise ValueError.""" + tx_options = {"nonce": 42.5} + + with pytest.raises(ValueError) as exc_info: + build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options=tx_options, + ) + + assert "Invalid nonce value: 42.5" in str(exc_info.value) + assert "must be a non-negative integer" in str(exc_info.value) + + def test_nonce_validation_none_value_raises_error( + self, mock_web3, mock_account, mock_client_function + ): + """Test that None nonce values raise ValueError.""" + tx_options = {"nonce": None} + + with pytest.raises(ValueError) as exc_info: + build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options=tx_options, + ) + + assert "Invalid nonce value: None" in str(exc_info.value) + assert "must be a non-negative integer" in str(exc_info.value) + + def test_zero_nonce_is_valid(self, mock_web3, mock_account, mock_client_function): + """Test that zero nonce is accepted as valid.""" + custom_nonce = 0 + tx_options = {"nonce": custom_nonce} + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhash") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + 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"] == 0 + + def test_large_nonce_is_valid(self, mock_web3, mock_account, mock_client_function): + """Test that large nonce values are accepted.""" + custom_nonce = 999999999 + tx_options = {"nonce": custom_nonce} + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhash") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + 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"] == custom_nonce + + def test_nonce_with_other_tx_options( + self, mock_web3, mock_account, mock_client_function + ): + """Test that nonce works correctly alongside other transaction options.""" + tx_options = { + "nonce": 42, + "value": 1000, + "gasPrice": 20, + } + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhash") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + 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 + assert call_args["gasPrice"] == 20000000000 # 20 gwei in wei + + def test_encoded_tx_data_only_with_custom_nonce( + self, mock_web3, mock_account, mock_client_function + ): + """Test that custom nonce works with encodedTxDataOnly option.""" + custom_nonce = 42 + tx_options = { + "nonce": custom_nonce, + "encodedTxDataOnly": True, + } + expected_tx = {"to": "0xabc", "data": "0x123", "nonce": custom_nonce} + 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_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() + + def test_no_tx_options_uses_default_nonce( + self, mock_web3, mock_account, mock_client_function + ): + """Test that when tx_options is None, nonce is fetched from chain.""" + chain_nonce = 15 + mock_web3.eth.get_transaction_count.return_value = chain_nonce + mock_web3.eth.send_raw_transaction.return_value = Mock( + hex=Mock(return_value="0xhash") + ) + mock_web3.eth.wait_for_transaction_receipt.return_value = {"status": 1} + mock_account.sign_transaction.return_value = Mock(raw_transaction=b"signed_tx") + + build_and_send_transaction( + mock_web3, + mock_account, + mock_client_function, + tx_options=None, # Explicitly passing None + ) + + mock_web3.eth.get_transaction_count.assert_called_once_with( + mock_account.address + ) + mock_client_function.assert_called_once() + call_args = mock_client_function.call_args[0][-1] + assert call_args["nonce"] == chain_nonce From 8915cd638fe71b6e86d398211f339e9790d959da Mon Sep 17 00:00:00 2001 From: Sebastian Liu Date: Sun, 10 Aug 2025 20:46:40 -0700 Subject: [PATCH 2/3] test: add integration tests for custom nonce --- pytest.ini | 2 + .../test_integration_transaction_utils.py | 130 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 tests/integration/test_integration_transaction_utils.py diff --git a/pytest.ini b/pytest.ini index 9ccddb9..e0533d9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,6 +6,8 @@ markers = unit: marks tests as unit tests integration: marks tests as integration tests addopts = -v -ra +filterwarnings = + ignore::DeprecationWarning:websockets.legacy # ignore directories norecursedirs = *.egg .git .* _darcs build dist venv diff --git a/tests/integration/test_integration_transaction_utils.py b/tests/integration/test_integration_transaction_utils.py new file mode 100644 index 0000000..a0e3113 --- /dev/null +++ b/tests/integration/test_integration_transaction_utils.py @@ -0,0 +1,130 @@ +# tests/integration/test_integration_transaction_utils.py + +import pytest + +from story_protocol_python_sdk.story_client import StoryClient +from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction + +from .setup_for_integration import MockERC20, account, web3 + + +class TestTransactionUtils: + """Integration tests for transaction utilities with custom nonce support.""" + + @pytest.fixture(scope="module") + def story_client(self): + """Create a StoryClient instance for testing.""" + return StoryClient(web3, account, chain_id=1315) + + def test_custom_nonce_with_sequential_transactions(self, story_client: StoryClient): + """Test custom nonce works correctly with sequential transactions.""" + current_nonce = web3.eth.get_transaction_count(account.address, "pending") + + 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} + ) + + assert result["tx_receipt"]["status"] == 1 + tx = web3.eth.get_transaction(result["tx_hash"]) + assert tx["nonce"] == current_nonce + + def test_automatic_nonce_fallback(self, story_client: StoryClient): + """Test backward compatibility - automatic nonce when not 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={}) + + assert result["tx_receipt"]["status"] == 1 + tx = web3.eth.get_transaction(result["tx_hash"]) + assert tx["nonce"] >= 0 + + def test_invalid_nonce_validation(self, story_client: StoryClient): + """Test that invalid nonce values are properly rejected.""" + + def dummy_func(tx_options): + return { + "to": account.address, + "value": 0, + "data": "0x", + "gas": 21000, + "gasPrice": web3.eth.gas_price, + "chainId": 1315, + **tx_options, + } + + invalid_nonces = [ + (-1, "negative"), + ("10", "string"), + (10.5, "float"), + (None, "None"), + ] + + for nonce_value, nonce_type in invalid_nonces: + with pytest.raises(ValueError) as exc_info: + build_and_send_transaction( + web3, account, dummy_func, tx_options={"nonce": nonce_value} + ) + assert "Invalid nonce value" in str(exc_info.value) + assert "must be a non-negative integer" in str(exc_info.value) + + def test_nonce_with_contract_interaction(self, story_client: StoryClient): + """Test custom nonce works with actual contract calls.""" + 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", + } + ], + ) + + current_nonce = web3.eth.get_transaction_count(account.address) + + def approve_func(tx_options): + return erc20_contract.functions.approve( + account.address, 100 + ).build_transaction(tx_options) + + result = build_and_send_transaction( + web3, account, approve_func, tx_options={"nonce": current_nonce} + ) + + assert result["tx_receipt"]["status"] == 1 + tx = web3.eth.get_transaction(result["tx_hash"]) + assert tx["nonce"] == current_nonce From 28ac44a1056cc9c84b077744cccb99860de229cb Mon Sep 17 00:00:00 2001 From: Sebastian Liu Date: Mon, 11 Aug 2025 10:42:48 -0700 Subject: [PATCH 3/3] chore(test): remove unused story client --- .../test_integration_transaction_utils.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/integration/test_integration_transaction_utils.py b/tests/integration/test_integration_transaction_utils.py index a0e3113..b88b828 100644 --- a/tests/integration/test_integration_transaction_utils.py +++ b/tests/integration/test_integration_transaction_utils.py @@ -2,7 +2,6 @@ import pytest -from story_protocol_python_sdk.story_client import StoryClient from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction from .setup_for_integration import MockERC20, account, web3 @@ -11,12 +10,7 @@ class TestTransactionUtils: """Integration tests for transaction utilities with custom nonce support.""" - @pytest.fixture(scope="module") - def story_client(self): - """Create a StoryClient instance for testing.""" - return StoryClient(web3, account, chain_id=1315) - - def test_custom_nonce_with_sequential_transactions(self, story_client: StoryClient): + def test_custom_nonce_with_sequential_transactions(self): """Test custom nonce works correctly with sequential transactions.""" current_nonce = web3.eth.get_transaction_count(account.address, "pending") @@ -43,7 +37,7 @@ def build_tx(tx_options): tx = web3.eth.get_transaction(result["tx_hash"]) assert tx["nonce"] == current_nonce - def test_automatic_nonce_fallback(self, story_client: StoryClient): + def test_automatic_nonce_fallback(self): """Test backward compatibility - automatic nonce when not specified.""" def create_transfer_tx(to_address, value): @@ -67,7 +61,7 @@ def build_tx(tx_options): tx = web3.eth.get_transaction(result["tx_hash"]) assert tx["nonce"] >= 0 - def test_invalid_nonce_validation(self, story_client: StoryClient): + def test_invalid_nonce_validation(self): """Test that invalid nonce values are properly rejected.""" def dummy_func(tx_options): @@ -96,7 +90,7 @@ def dummy_func(tx_options): assert "Invalid nonce value" in str(exc_info.value) assert "must be a non-negative integer" in str(exc_info.value) - def test_nonce_with_contract_interaction(self, story_client: StoryClient): + def test_nonce_with_contract_interaction(self): """Test custom nonce works with actual contract calls.""" erc20_contract = web3.eth.contract( address=MockERC20,