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_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)