Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions plugin/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
276 changes: 146 additions & 130 deletions plugin/python/tests/test_contract.py
Original file line number Diff line number Diff line change
@@ -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)