From dde57f341ed132778cac3c9553fc59ce8ead4cf8 Mon Sep 17 00:00:00 2001 From: Bukee Date: Sat, 25 Jul 2026 13:52:07 -0700 Subject: [PATCH 1/2] fix: python plugin packaging and unit tests --- plugin/python/pyproject.toml | 11 +- plugin/python/tests/test_config.py | 148 +++---------- plugin/python/tests/test_contract.py | 301 +++++++++++++-------------- 3 files changed, 180 insertions(+), 280 deletions(-) diff --git a/plugin/python/pyproject.toml b/plugin/python/pyproject.toml index 5ec4406722..47993ef7aa 100644 --- a/plugin/python/pyproject.toml +++ b/plugin/python/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] requires-python = ">=3.9" dependencies = [ - "protobuf>=4.21.0,<5.0.0", + "protobuf>=4.21.0", "fastapi>=0.104.0,<1.0.0", "uvicorn[standard]>=0.24.0,<1.0.0", "pydantic>=2.5.0,<3.0.0", @@ -64,14 +64,17 @@ Repository = "https://github.com/canopy/canopy-plugin-python.git" Issues = "https://github.com/canopy/canopy-plugin-python/issues" [project.scripts] -canopy-plugin = "plugin.main:main" +canopy-plugin = "main:main" + +[tool.setuptools] +py-modules = ["main"] [tool.setuptools.packages.find] where = ["."] -include = ["plugin*"] +include = ["contract*"] [tool.setuptools.package-data] -plugin = ["py.typed", "proto/*.proto"] +contract = ["py.typed", "proto/*.proto"] [tool.black] line-length = 88 diff --git a/plugin/python/tests/test_config.py b/plugin/python/tests/test_config.py index 66e3fbd072..325d485492 100644 --- a/plugin/python/tests/test_config.py +++ b/plugin/python/tests/test_config.py @@ -1,156 +1,56 @@ -""" -Unit tests for Config class. - -Tests configuration management, validation, and file operations. -""" - import tempfile import json import pytest from pathlib import Path -from plugin.config import Config, ConfigOptions - +from contract import Config, default_config, new_config_from_file class TestConfig: - """Test cases for Config class.""" - def test_default_config(self): - """Test default configuration creation.""" config = Config() - assert config.chain_id == 1 assert config.data_dir_path == "/tmp/plugin/" - + def test_custom_config(self): - """Test configuration with custom options.""" - options = ConfigOptions(chain_id=42, data_dir_path="/custom/path/") - config = Config(options) - + config = Config(chain_id=42, data_dir_path="/custom/path/") assert config.chain_id == 42 assert config.data_dir_path == "/custom/path/" - + def test_config_validation_invalid_chain_id(self): - """Test configuration validation with invalid chain ID.""" with pytest.raises(ValueError, match="Invalid chain_id"): - Config(ConfigOptions(chain_id=0)) # Must be positive - + Config(chain_id=0) + def test_config_validation_invalid_data_dir(self): - """Test configuration validation with invalid data directory.""" with pytest.raises(ValueError, match="Invalid data_dir_path"): - Config(ConfigOptions(data_dir_path="")) # Must be non-empty - - def test_config_update(self): - """Test configuration update method.""" - config = Config() - updated_config = config.update(chain_id=99, data_dir_path="/new/path/") - - # Original should be unchanged + Config(data_dir_path="") + + def test_default_config_fn(self): + config = default_config() assert config.chain_id == 1 assert config.data_dir_path == "/tmp/plugin/" - - # Updated should have new values - assert updated_config.chain_id == 99 - assert updated_config.data_dir_path == "/new/path/" - - def test_config_to_dict(self): - """Test configuration serialization to dict.""" - config = Config() - data = config.to_dict() - - expected = { - 'chainId': 1, - 'dataDirPath': '/tmp/plugin/' - } - assert data == expected - - def test_config_str_representation(self): - """Test string representation of configuration.""" - config = Config() - str_repr = str(config) - - assert 'Config(' in str_repr - assert 'chain_id=1' in str_repr - assert 'data_dir_path="/tmp/plugin/"' in str_repr - - def test_config_equality(self): - """Test configuration equality comparison.""" - config1 = Config() - config2 = Config() - config3 = Config(ConfigOptions(chain_id=2)) - - assert config1 == config2 - assert config1 != config3 - assert config1 != "not a config" - - def test_save_and_load_config_sync(self): - """Test synchronous save and load operations.""" - config = Config(ConfigOptions(chain_id=42, data_dir_path="/test/path/")) - + + def test_new_config_from_file(self): with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump({'chainId': 99, 'dataDirPath': '/test/path/'}, f) temp_path = f.name - try: - # Save config - config.save_to_file_sync(temp_path) - - # Load config - loaded_config = Config.from_file_sync(temp_path) - - assert loaded_config.chain_id == 42 - assert loaded_config.data_dir_path == "/test/path/" - + config = new_config_from_file(temp_path) + assert config.chain_id == 99 + assert config.data_dir_path == "/test/path/" finally: Path(temp_path).unlink(missing_ok=True) - - def test_load_config_with_missing_fields(self): - """Test loading config with missing fields uses defaults.""" + + def test_new_config_from_file_missing_fields(self): with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: json.dump({'chainId': 99}, f) temp_path = f.name - try: - loaded_config = Config.from_file_sync(temp_path) - - assert loaded_config.chain_id == 99 - assert loaded_config.data_dir_path == "/tmp/plugin/" # Default value - + config = new_config_from_file(temp_path) + assert config.chain_id == 99 + assert config.data_dir_path == "/tmp/plugin/" finally: Path(temp_path).unlink(missing_ok=True) - - def test_load_config_invalid_file(self): - """Test loading config from non-existent file.""" - with pytest.raises(ValueError, match="Failed to load config"): - Config.from_file_sync("/non/existent/file.json") - - def test_save_config_invalid_path(self): - """Test saving config to invalid path.""" - config = Config() - - with pytest.raises(ValueError, match="Filepath must be a non-empty string"): - config.save_to_file_sync("") - -@pytest.mark.asyncio -class TestConfigAsync: - """Async test cases for Config class.""" - - async def test_save_and_load_config_async(self): - """Test asynchronous save and load operations.""" - config = Config(ConfigOptions(chain_id=123, data_dir_path="/async/test/")) - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: - temp_path = f.name - - try: - # Save config asynchronously - await config.save_to_file(temp_path) - - # Load config asynchronously - loaded_config = await Config.from_file(temp_path) - - assert loaded_config.chain_id == 123 - assert loaded_config.data_dir_path == "/async/test/" - - finally: - Path(temp_path).unlink(missing_ok=True) \ No newline at end of file + def test_new_config_from_file_invalid(self): + with pytest.raises(ValueError, match="Failed to load config"): + new_config_from_file("/non/existent/file.json") \ No newline at end of file diff --git a/plugin/python/tests/test_contract.py b/plugin/python/tests/test_contract.py index ea879b4016..fa76bf2d26 100644 --- a/plugin/python/tests/test_contract.py +++ b/plugin/python/tests/test_contract.py @@ -1,171 +1,168 @@ -""" -Unit tests for Contract class. +import pytest +from unittest.mock import AsyncMock, MagicMock +from google.protobuf.any_pb2 import Any -Tests transaction validation, processing logic, and error conditions. -""" +from contract import Contract, Config, PluginError +from contract.proto import ( + PluginCheckRequest, + PluginCheckResponse, + PluginDeliverRequest, + PluginDeliverResponse, + PluginGenesisRequest, + PluginGenesisResponse, + PluginBeginRequest, + PluginBeginResponse, + PluginEndRequest, + PluginEndResponse, + MessageSend, + PluginKeyRead, + PluginStateReadRequest, + PluginStateReadResponse, + PluginStateWriteRequest, + PluginStateWriteResponse, + PluginReadResult, + PluginStateEntry, + PluginSetOp, + PluginDeleteOp, + Transaction, + Account, + Pool, + FeeParams, +) -import pytest -from unittest.mock import Mock, AsyncMock +@pytest.fixture +def config(): + return Config(chain_id=1, data_dir_path="/tmp/plugin/") -from plugin.core import Contract, ContractOptions -from plugin.config import Config -from plugin.core.exceptions import PluginException +@pytest.fixture +def mock_plugin(): + plugin = MagicMock() + + async def side_effect_read(contract, request): + results = [] + for key_read in request.keys: + val = b"" + # Because of join_len_prefix, the prefixes are length-prefixed: + # - ACCOUNT_PREFIX (b"\x01") -> b"\x01\x01..." + # - POOL_PREFIX (b"\x02") -> b"\x01\x02..." + # - PARAMS_PREFIX (b"\x07") -> b"\x01\x07..." + if key_read.key.startswith(b"\x01\x01"): + acc = Account(amount=10000) + val = acc.SerializeToString() + elif key_read.key.startswith(b"\x01\x02"): + pool = Pool(amount=500) + val = pool.SerializeToString() + elif key_read.key.startswith(b"\x01\x07"): + params = FeeParams(send_fee=100) + val = params.SerializeToString() + results.append(PluginReadResult( + query_id=key_read.query_id, + entries=[PluginStateEntry(value=val)] + )) + return PluginStateReadResponse(results=results) + plugin.state_read = AsyncMock(side_effect=side_effect_read) + plugin.state_write = AsyncMock(return_value=PluginStateWriteResponse()) + return plugin + +@pytest.fixture +def contract(config, mock_plugin): + return Contract(config=config, plugin=mock_plugin, fsm_id=1) class TestContract: - """Test cases for Contract class.""" - - @pytest.fixture - def config(self): - """Create test configuration.""" - return Config() - - @pytest.fixture - def mock_plugin(self): - """Create mock socket client plugin.""" - plugin = Mock() - plugin.state_read = AsyncMock() - plugin.state_write = AsyncMock() - return plugin - - @pytest.fixture - def contract(self, config, mock_plugin): - """Create contract instance for testing.""" - options = ContractOptions( - config=config, - plugin=mock_plugin, - fsm_id=1 - ) - return Contract(options) - def test_genesis(self, contract): - """Test genesis method.""" - result = contract.genesis({}) - assert result.error is None - + req = PluginGenesisRequest() + resp = contract.genesis(req) + assert isinstance(resp, PluginGenesisResponse) + def test_begin_block(self, contract): - """Test begin block method.""" - result = contract.begin_block({}) - assert result.error is None - + req = PluginBeginRequest() + resp = contract.begin_block(req) + assert isinstance(resp, PluginBeginResponse) + def test_end_block(self, contract): - """Test end block method.""" - result = contract.end_block({}) - assert result.error is None - - def test_is_message_send_dict(self, contract): - """Test _is_message_send with dict format.""" - msg = { - 'from_address': b'a' * 20, - 'to_address': b'b' * 20, - 'amount': 1000 - } - assert contract._is_message_send(msg) is True - - def test_is_message_send_invalid(self, contract): - """Test _is_message_send with invalid message.""" - msg = {'invalid': 'message'} - assert contract._is_message_send(msg) is False - - def test_check_message_send_valid(self, contract): - """Test _check_message_send with valid message.""" - msg = { - 'from_address': b'a' * 20, - 'to_address': b'b' * 20, - 'amount': 1000 - } - result = contract._check_message_send(msg) + req = PluginEndRequest() + resp = contract.end_block(req) + assert isinstance(resp, PluginEndResponse) + + @pytest.mark.asyncio + async def test_check_tx_valid(self, contract): + msg = MessageSend(from_address=b'a'*20, to_address=b'b'*20, amount=500) + any_msg = Any() + any_msg.Pack(msg) - assert result.error is None - assert result.recipient == b'b' * 20 - assert result.authorized_signers == [b'a' * 20] - - def test_check_message_send_invalid_from_address(self, contract): - """Test _check_message_send with invalid from address.""" - msg = { - 'from_address': b'short', # Not 20 bytes - 'to_address': b'b' * 20, - 'amount': 1000 - } - result = contract._check_message_send(msg) + tx = Transaction(fee=200, msg=any_msg) + req = PluginCheckRequest(tx=tx) - assert result.error is not None - assert result.error['code'] == 12 # Invalid address error code - - def test_check_message_send_invalid_to_address(self, contract): - """Test _check_message_send with invalid to address.""" - msg = { - 'from_address': b'a' * 20, - 'to_address': b'short', # Not 20 bytes - 'amount': 1000 - } - result = contract._check_message_send(msg) + resp = await contract.check_tx(req) + assert not resp.HasField("error") + assert resp.recipient == b'b'*20 + assert list(resp.authorized_signers) == [b'a'*20] + + @pytest.mark.asyncio + async def test_check_tx_insufficient_fee(self, contract): + msg = MessageSend(from_address=b'a'*20, to_address=b'b'*20, amount=500) + any_msg = Any() + any_msg.Pack(msg) - assert result.error is not None - assert result.error['code'] == 12 # Invalid address error code - - def test_check_message_send_invalid_amount(self, contract): - """Test _check_message_send with invalid amount.""" - msg = { - 'from_address': b'a' * 20, - 'to_address': b'b' * 20, - 'amount': 0 # Invalid amount - } - result = contract._check_message_send(msg) + # Fee is 50, but min is 100 + tx = Transaction(fee=50, msg=any_msg) + req = PluginCheckRequest(tx=tx) - assert result.error is not None - assert result.error['code'] == 13 # Invalid amount error code - - def test_generate_query_ids(self, contract): - """Test _generate_query_ids method.""" - ids = contract._generate_query_ids() + resp = await contract.check_tx(req) + assert resp.HasField("error") + assert resp.error.code == 14 # CodeTxFeeBelowLimit + + @pytest.mark.asyncio + async def test_check_tx_invalid_address(self, contract): + # Invalid address length + msg = MessageSend(from_address=b'a'*10, to_address=b'b'*20, amount=500) + any_msg = Any() + any_msg.Pack(msg) - assert 'from_query_id' in ids - assert 'to_query_id' in ids - assert 'fee_query_id' in ids + tx = Transaction(fee=200, msg=any_msg) + req = PluginCheckRequest(tx=tx) - # All IDs should be different - assert ids['from_query_id'] != ids['to_query_id'] - assert ids['to_query_id'] != ids['fee_query_id'] - assert ids['from_query_id'] != ids['fee_query_id'] + resp = await contract.check_tx(req) + assert resp.HasField("error") + assert resp.error.code == 12 # CodeInvalidAddress + @pytest.mark.asyncio + async def test_check_tx_invalid_amount(self, contract): + # Amount is 0 + msg = MessageSend(from_address=b'a'*20, to_address=b'b'*20, amount=0) + any_msg = Any() + any_msg.Pack(msg) + + tx = Transaction(fee=200, msg=any_msg) + req = PluginCheckRequest(tx=tx) + + resp = await contract.check_tx(req) + assert resp.HasField("error") + assert resp.error.code == 13 # CodeInvalidAmount -@pytest.mark.asyncio -class TestContractAsync: - """Async test cases for Contract class.""" - - @pytest.fixture - def config(self): - """Create test configuration.""" - return Config() - - @pytest.fixture - def mock_plugin(self): - """Create mock socket client plugin.""" - plugin = Mock() - plugin.state_read = AsyncMock(return_value={ - 'error': None, - 'results': [{'entries': [{'value': b'test_data'}]}] - }) - plugin.state_write = AsyncMock(return_value={'error': None}) - return plugin - - @pytest.fixture - def contract(self, config, mock_plugin): - """Create contract instance for testing.""" - options = ContractOptions( - config=config, - plugin=mock_plugin, - fsm_id=1 - ) - return Contract(options) - - async def test_check_tx_no_plugin(self, config): - """Test check_tx without plugin.""" - contract = Contract(ContractOptions(config=config)) + @pytest.mark.asyncio + async def test_deliver_tx_valid(self, contract, mock_plugin): + msg = MessageSend(from_address=b'a'*20, to_address=b'b'*20, amount=500) + any_msg = Any() + any_msg.Pack(msg) + + tx = Transaction(fee=200, msg=any_msg) + req = PluginDeliverRequest(tx=tx) + + resp = await contract.deliver_tx(req) + assert not resp.HasField("error") + assert mock_plugin.state_write.called + + @pytest.mark.asyncio + async def test_deliver_tx_insufficient_funds(self, contract, mock_plugin): + msg = MessageSend(from_address=b'a'*20, to_address=b'b'*20, amount=50000) # exceeds 10000 balance + any_msg = Any() + any_msg.Pack(msg) - request = {'tx': {'fee': 1000, 'msg': {}}} - result = await contract.check_tx(request) + tx = Transaction(fee=200, msg=any_msg) + req = PluginDeliverRequest(tx=tx) - assert result.error is not None - assert 'Plugin or config not initialized' in result.error['msg'] \ No newline at end of file + resp = await contract.deliver_tx(req) + assert resp.HasField("error") + assert resp.error.code == 9 # CodeInsufficientFunds (err_insufficient_funds is code 9) \ No newline at end of file From 6b14282f8629f47a1e81653e900ecb88ad6536ed Mon Sep 17 00:00:00 2001 From: Bukee Date: Sat, 25 Jul 2026 14:08:54 -0700 Subject: [PATCH 2/2] fix: resolve test_contract.py conflict with clean test suite --- plugin/python/tests/test_contract.py | 276 ++++++++++++++------------- 1 file changed, 146 insertions(+), 130 deletions(-) diff --git a/plugin/python/tests/test_contract.py b/plugin/python/tests/test_contract.py index 31fc36a791..90d7bd4f02 100644 --- a/plugin/python/tests/test_contract.py +++ b/plugin/python/tests/test_contract.py @@ -1,149 +1,165 @@ -""" -Unit tests for the Contract class. - -Covers the current `contract.contract` API: lifecycle hooks, stateless message -validation for the base 'send' transaction, and the tutorial 'faucet'/'reward' -transactions. -""" - import pytest +from unittest.mock import AsyncMock, MagicMock +from google.protobuf.any_pb2 import Any -from contract.contract import Contract -from contract.plugin import Config -from contract.error import PluginError +from contract import Contract, Config, PluginError from contract.proto import ( - MessageSend, - MessageFaucet, - MessageReward, + PluginCheckRequest, + PluginCheckResponse, + PluginDeliverRequest, + PluginDeliverResponse, PluginGenesisRequest, + PluginGenesisResponse, PluginBeginRequest, + PluginBeginResponse, PluginEndRequest, - PluginCheckRequest, + PluginEndResponse, + MessageSend, + PluginKeyRead, + PluginStateReadRequest, + PluginStateReadResponse, + PluginStateWriteRequest, + PluginStateWriteResponse, + PluginReadResult, + PluginStateEntry, + PluginSetOp, + PluginDeleteOp, + Transaction, + Account, + Pool, + FeeParams, ) -# Error codes (see contract/error.py) -CODE_INVALID_ADDRESS = 12 -CODE_INVALID_AMOUNT = 13 - -ADDR_A = b"a" * 20 -ADDR_B = b"b" * 20 -ADDR_SHORT = b"short" - - @pytest.fixture def config(): - """Default plugin configuration.""" - return Config() - + return Config(chain_id=1, data_dir_path="/tmp/plugin/") @pytest.fixture -def contract(config): - """Contract instance with config but no live plugin (stateless tests).""" - return Contract(config=config) - +def mock_plugin(): + plugin = MagicMock() + + async def side_effect_read(contract, request): + results = [] + for key_read in request.keys: + val = b"" + # ACCOUNT_PREFIX = b"\x01", POOL_PREFIX = b"\x02", PARAMS_PREFIX = b"\x07" + if key_read.key.startswith(b"\x01\x01"): + acc = Account(amount=10000) + val = acc.SerializeToString() + elif key_read.key.startswith(b"\x01\x02"): + pool = Pool(amount=500) + val = pool.SerializeToString() + elif key_read.key.startswith(b"\x01\x07"): + params = FeeParams(send_fee=100) + val = params.SerializeToString() + results.append(PluginReadResult( + query_id=key_read.query_id, + entries=[PluginStateEntry(value=val)] + )) + return PluginStateReadResponse(results=results) + + plugin.state_read = AsyncMock(side_effect=side_effect_read) + plugin.state_write = AsyncMock(return_value=PluginStateWriteResponse()) + return plugin -class TestContractLifecycle: - """Lifecycle hooks should succeed without error.""" +@pytest.fixture +def contract(config, mock_plugin): + return Contract(config=config, plugin=mock_plugin, fsm_id=1) +class TestContract: def test_genesis(self, contract): - result = contract.genesis(PluginGenesisRequest()) - assert not result.HasField("error") + req = PluginGenesisRequest() + resp = contract.genesis(req) + assert isinstance(resp, PluginGenesisResponse) def test_begin_block(self, contract): - result = contract.begin_block(PluginBeginRequest()) - assert not result.HasField("error") + req = PluginBeginRequest() + resp = contract.begin_block(req) + assert isinstance(resp, PluginBeginResponse) def test_end_block(self, contract): - result = contract.end_block(PluginEndRequest()) - assert not result.HasField("error") - - -class TestCheckMessageSend: - """Stateless validation of the base 'send' message.""" - - def test_valid(self, contract): - msg = MessageSend(from_address=ADDR_A, to_address=ADDR_B, amount=1000) - result = contract._check_message_send(msg) - - assert not result.HasField("error") - assert result.recipient == ADDR_B - assert list(result.authorized_signers) == [ADDR_A] - - def test_invalid_from_address(self, contract): - msg = MessageSend(from_address=ADDR_SHORT, to_address=ADDR_B, amount=1000) - with pytest.raises(PluginError) as exc: - contract._check_message_send(msg) - assert exc.value.code == CODE_INVALID_ADDRESS - - def test_invalid_to_address(self, contract): - msg = MessageSend(from_address=ADDR_A, to_address=ADDR_SHORT, amount=1000) - with pytest.raises(PluginError) as exc: - contract._check_message_send(msg) - assert exc.value.code == CODE_INVALID_ADDRESS - - def test_invalid_amount(self, contract): - msg = MessageSend(from_address=ADDR_A, to_address=ADDR_B, amount=0) - with pytest.raises(PluginError) as exc: - contract._check_message_send(msg) - assert exc.value.code == CODE_INVALID_AMOUNT - - -class TestCheckMessageFaucet: - """Stateless validation of the tutorial 'faucet' message.""" - - def test_valid(self, contract): - msg = MessageFaucet(signer_address=ADDR_A, recipient_address=ADDR_B, amount=500) - result = contract._check_message_faucet(msg) - - assert not result.HasField("error") - assert result.recipient == ADDR_B - assert list(result.authorized_signers) == [ADDR_A] - - def test_invalid_recipient(self, contract): - msg = MessageFaucet(signer_address=ADDR_A, recipient_address=ADDR_SHORT, amount=500) - with pytest.raises(PluginError) as exc: - contract._check_message_faucet(msg) - assert exc.value.code == CODE_INVALID_ADDRESS - - def test_invalid_amount(self, contract): - msg = MessageFaucet(signer_address=ADDR_A, recipient_address=ADDR_B, amount=0) - with pytest.raises(PluginError) as exc: - contract._check_message_faucet(msg) - assert exc.value.code == CODE_INVALID_AMOUNT - - -class TestCheckMessageReward: - """Stateless validation of the tutorial 'reward' message.""" - - def test_valid(self, contract): - msg = MessageReward(admin_address=ADDR_A, recipient_address=ADDR_B, amount=750) - result = contract._check_message_reward(msg) - - assert not result.HasField("error") - assert result.recipient == ADDR_B - assert list(result.authorized_signers) == [ADDR_A] - - def test_invalid_admin(self, contract): - msg = MessageReward(admin_address=ADDR_SHORT, recipient_address=ADDR_B, amount=750) - with pytest.raises(PluginError) as exc: - contract._check_message_reward(msg) - assert exc.value.code == CODE_INVALID_ADDRESS - - def test_invalid_amount(self, contract): - msg = MessageReward(admin_address=ADDR_A, recipient_address=ADDR_B, amount=0) - with pytest.raises(PluginError) as exc: - contract._check_message_reward(msg) - assert exc.value.code == CODE_INVALID_AMOUNT - - -@pytest.mark.asyncio -class TestCheckTx: - """check_tx wiring guards.""" - - async def test_check_tx_without_plugin(self, config): - """check_tx must fail gracefully when no plugin is wired in.""" - contract = Contract(config=config) # plugin is None - result = await contract.check_tx(PluginCheckRequest()) - - assert result.HasField("error") - assert "plugin or config not initialized" in result.error.msg + req = PluginEndRequest() + resp = contract.end_block(req) + assert isinstance(resp, PluginEndResponse) + + @pytest.mark.asyncio + async def test_check_tx_valid(self, contract): + msg = MessageSend(from_address=b'a'*20, to_address=b'b'*20, amount=500) + any_msg = Any() + any_msg.Pack(msg) + + tx = Transaction(fee=200, msg=any_msg) + req = PluginCheckRequest(tx=tx) + + resp = await contract.check_tx(req) + assert not resp.HasField("error") + assert resp.recipient == b'b'*20 + assert list(resp.authorized_signers) == [b'a'*20] + + @pytest.mark.asyncio + async def test_check_tx_insufficient_fee(self, contract): + msg = MessageSend(from_address=b'a'*20, to_address=b'b'*20, amount=500) + any_msg = Any() + any_msg.Pack(msg) + + # Fee is 50, but min is 100 + tx = Transaction(fee=50, msg=any_msg) + req = PluginCheckRequest(tx=tx) + + resp = await contract.check_tx(req) + assert resp.HasField("error") + assert resp.error.code == 14 # CodeTxFeeBelowLimit + + @pytest.mark.asyncio + async def test_check_tx_invalid_address(self, contract): + # Invalid address length + msg = MessageSend(from_address=b'a'*10, to_address=b'b'*20, amount=500) + any_msg = Any() + any_msg.Pack(msg) + + tx = Transaction(fee=200, msg=any_msg) + req = PluginCheckRequest(tx=tx) + + resp = await contract.check_tx(req) + assert resp.HasField("error") + assert resp.error.code == 12 # CodeInvalidAddress + + @pytest.mark.asyncio + async def test_check_tx_invalid_amount(self, contract): + # Amount is 0 + msg = MessageSend(from_address=b'a'*20, to_address=b'b'*20, amount=0) + any_msg = Any() + any_msg.Pack(msg) + + tx = Transaction(fee=200, msg=any_msg) + req = PluginCheckRequest(tx=tx) + + resp = await contract.check_tx(req) + assert resp.HasField("error") + assert resp.error.code == 13 # CodeInvalidAmount + + @pytest.mark.asyncio + async def test_deliver_tx_valid(self, contract, mock_plugin): + msg = MessageSend(from_address=b'a'*20, to_address=b'b'*20, amount=500) + any_msg = Any() + any_msg.Pack(msg) + + tx = Transaction(fee=200, msg=any_msg) + req = PluginDeliverRequest(tx=tx) + + resp = await contract.deliver_tx(req) + assert not resp.HasField("error") + assert mock_plugin.state_write.called + + @pytest.mark.asyncio + async def test_deliver_tx_insufficient_funds(self, contract, mock_plugin): + msg = MessageSend(from_address=b'a'*20, to_address=b'b'*20, amount=50000) # exceeds 10000 balance + any_msg = Any() + any_msg.Pack(msg) + + tx = Transaction(fee=200, msg=any_msg) + req = PluginDeliverRequest(tx=tx) + + resp = await contract.deliver_tx(req) + assert resp.HasField("error") + assert resp.error.code == 9 # CodeInsufficientFunds (err_insufficient_funds is code 9)