Skip to main content

Get started with the Blueprint SDK — part 2

Overview

This tutorial is part 2 of a two-part introduction to the Hathor Blueprint SDK.

In part 1, you designed a simple swap blueprint. In this article, you will implement that design in Python and write automated unit tests for the blueprint.

By the end of this tutorial, you will have:

  • Created a blueprint module for a SwapDemo blueprint.
  • Implemented the initialize, swap, and is_ratio_valid methods.
  • Created a unit test module for the blueprint.
  • Tested successful contract creation, successful swap execution, and expected failure cases.
  • Run the test suite with pytest.
info

The SwapDemo blueprint is a learning example. It is useful for understanding Blueprint SDK structure, public methods, action validation, and unit testing. It should not be used as production swap logic without additional review, testing, and security analysis.

Prerequisites

Before starting, make sure you have:

What you will create

Using the Blueprint SDK, each blueprint is implemented as a Python module. The corresponding unit tests are implemented in a separate test module.

In this tutorial, you will create and review these files inside hathor-core:

FilePurpose
hathor_tests/nanocontracts/test_blueprints/swap_demo.pyContains the SwapDemo blueprint implementation.
hathor_tests/nanocontracts/blueprints/test_swap_demo.pyContains the unit tests for SwapDemo.

Before starting the step-by-step implementation, expand the following section to inspect the complete blueprint module you will build.

hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
from hathor import Blueprint, Context, NCDepositAction, NCFail, NCWithdrawalAction, TokenUid, export, public, view


@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is just a reference for blueprint developers, not for real use.
"""

# TokenA identifier and quantity multiplier.
token_a: TokenUid
multiplier_a: int

# TokenB identifier and quantity multiplier.
token_b: TokenUid
multiplier_b: int

# Count number of swaps executed.
swaps_counter: int

@public(allow_deposit=True)
def initialize(
self,
ctx: Context,
token_a: TokenUid,
token_b: TokenUid,
multiplier_a: int,
multiplier_b: int
) -> None:
"""Initialize the contract."""

if token_a == token_b:
raise NCFail

if set(ctx.actions.keys()) != {token_a, token_b}:
raise InvalidTokens

self.token_a = token_a
self.token_b = token_b
self.multiplier_a = multiplier_a
self.multiplier_b = multiplier_b
self.swaps_counter = 0

@public(allow_deposit=True, allow_withdrawal=True)
def swap(self, ctx: Context) -> None:
"""Execute a token swap."""

if set(ctx.actions.keys()) != {self.token_a, self.token_b}:
raise InvalidTokens

action_a = ctx.get_single_action(self.token_a)
action_b = ctx.get_single_action(self.token_b)

if not (
(isinstance(action_a, NCDepositAction) and isinstance(action_b, NCWithdrawalAction))
or (isinstance(action_a, NCWithdrawalAction) and isinstance(action_b, NCDepositAction))
):
raise InvalidActions

if not self.is_ratio_valid(action_a.amount, action_b.amount):
raise InvalidRatio

# All good! Let's accept the transaction.
self.swaps_counter += 1

@view
def is_ratio_valid(self, qty_a: int, qty_b: int) -> bool:
"""Check if the swap quantities are valid."""
return (self.multiplier_a * qty_a == self.multiplier_b * qty_b)


class InvalidTokens(NCFail):
pass


class InvalidActions(NCFail):
pass


class InvalidRatio(NCFail):
pass

Sequence of steps

We will represent the process of implementing and testing our blueprint's code through a hands-on walkthrough divided into this sequence of steps:

  1. Create the blueprint module.
  2. Write the initialize method.
  3. Write the swap method.
  4. Write the is_ratio_valid method.
  5. Review the blueprint module.
  6. Create the unit test module.
  7. Write the test_lifecycle method.
  8. Write the _initialize method.
  9. Write the _swap method.
  10. Review the blueprint unit test module.

Task execution

Now, it's time to get your hands dirty. In this section, we will describe the steps in detail.

To begin, let's suppose you have already opened Hathor Core source code in your code editor.

Step 1: create the blueprint module

In the project directory tree, navigate to hathor_tests/nanocontracts/test_blueprints. This directory contains blueprint modules used by the Hathor Core test suite.

  1. Create the swap_demo.py file and add the imports needed by this example:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
from hathor import Blueprint, Context, NCDepositAction, NCFail, NCWithdrawalAction, TokenUid, export, public, view

These imports are used as follows:

  • Blueprint is the base class for defining blueprints.
  • Context is the class that models the context in which the call is taking place.
  • NCFail is the base class that models failures in contract execution as exceptions.
  • NCDepositAction is the class that models deposits.
  • NCWithdrawalAction is the class that models withdrawals.
  • TokenUid is the type used for token UUIDs.
  • export is a decorator used to mark the blueprint class.
  • public is a decorator used to mark a blueprint method as public.
  • view is a decorator used to mark a blueprint methods as views.

Note that only names strictly allowed by the blueprint SDK can be imported. For the complete list of allowed names, see Imports at Blueprint SDK — guidelines.

  1. Define the SwapDemo class. This class inherits from Blueprint and models the swap contracts created from this blueprint:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
from hathor import Blueprint, Context, NCDepositAction, NCFail, NCWithdrawalAction, TokenUid, export, public, view


@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""
  1. Add the five instance attributes that make up the contract state:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
...

@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

# TokenA identifier and quantity multiplier.
token_a: TokenUid
multiplier_a: int

# TokenB identifier and quantity multiplier.
token_b: TokenUid
multiplier_b: int

# Count number of swaps executed.
swaps_counter: int

Note that the Blueprint SDK mandates specific rules for using these type annotations. To know what these rules are, see Type annotations at Blueprint development guidelines. Furthermore, only value types strictly allowed can be used for attributes. To know which types are allowed, see Allowed value types at Blueprint development guidelines.

Step 2: write the initialize method

  1. We define the following signature and docstring for our initialize method:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
...

@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

...

@public(allow_deposit=True)
def initialize(
self,
ctx: Context,
token_a: TokenUid,
token_b: TokenUid,
multiplier_a: int,
multiplier_b: int
) -> None:
"""Initialize the contract."""

For the correct initialization of the contract, we must ensure that the arguments passed in the method call are correct. the Hathor engine guarantees the implementation of default checks required for all method calls. For example, during initialization, the balance of any contract will always be zero. Therefore, if one calls the initialize method containing withdrawal requests, the method will not even be invoked. Another situation that generates invalidity is a deposit or withdrawal request for a token with an nonexistent id i.e., not registered on the blockchain.

  1. Thus, we only need to worry about the checks related to our business rules. First, let's check if the contract creator has passed two distinct tokens for the contract to operate:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
...

@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

...

@public(allow_deposit=True)
def initialize(
self,
ctx: Context,
token_a: TokenUid,
token_b: TokenUid,
multiplier_a: int,
multiplier_b: int
) -> None:
"""Initialize the contract."""

if token_a == token_b:
raise NCFail
  1. As specified during the blueprint design, the contract creator must provide initial liquidity for the contract to operate. Let's then implement a check to ensure that the initial deposits of tokens made by the contract creator are only of the two tokens that they defined for the contract's operation:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
...

@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

...

@public(allow_deposit=True)
def initialize(
self,
ctx: Context,
token_a: TokenUid,
token_b: TokenUid,
multiplier_a: int,
multiplier_b: int
) -> None:
"""Initialize the contract."""

if token_a == token_b:
raise NCFail

if set(ctx.actions.keys()) != {token_a, token_b}:
raise InvalidTokens

class InvalidTokens(NCFail):
pass

actions from ctx is a dictionary in which the keys are token_uid and the values are action themselves. Also, note that we have defined a subclass to specify the NCFail exception for cases where an attempt is made to deposit tokens that the contract does not operate with.

  1. Finally, we can assign the appropriate values to the contract's attributes:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
...

@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

...

@public(allow_deposit=True)
def initialize(
self,
ctx: Context,
token_a: TokenUid,
token_b: TokenUid,
multiplier_a: int,
multiplier_b: int
) -> None:
"""Initialize the contract."""

if token_a == token_b:
raise NCFail

if set(ctx.actions.keys()) != {token_a, token_b}:
raise InvalidTokens

self.token_a = token_a
self.token_b = token_b
self.multiplier_a = multiplier_a
self.multiplier_b = multiplier_b
self.swaps_counter = 0

class InvalidTokens(NCFail):
pass

Step 3: write the swap method

To fulfill the functionality defined in our blueprint design, we need to code just one public method to execute token swaps. The contract user should call the swap method tied to precisely two actions: the deposit of one token and the proportional withdrawal of the other.

  1. Thus, calling the swap method does not require the contract user to pass any additional arguments. All necessary information will be found within the Context created by the protocol during the transaction processing:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
...

@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

...

@public(allow_deposit=True)
def initialize(
self,
ctx: Context,
token_a: TokenUid,
token_b: TokenUid,
multiplier_a: int,
multiplier_b: int
) -> None:
"""Initialize the contract."""
...

@public(allow_deposit=True, allow_withdrawal=True)
def swap(self, ctx: Context) -> None:
"""Execute a token swap."""

Like the initialize method, we will code our swap method as follows: we perform all the checks to ensure that the contract user's request is correct and executable, and then we update the contract's state. Remember that actions encapsulated in ctx is a dictionary whose keys are token_uid. Thus, we know that each action refers to a distinct token.

  1. Let's start by checking if the contract user has sent exactly two actions, and if each of them refers to one of the tokens with which the contract operates:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
...

@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

...

@public(allow_deposit=True, allow_withdrawal=True)
def swap(self, ctx: Context) -> None:
"""Execute a token swap."""

if set(ctx.actions.keys()) != {self.token_a, self.token_b}:
raise InvalidTokens
  1. Once confirmed that we have received two actions, one for each of the tokens the contract operates, we will store them in local variables:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
...

@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

...

@public(allow_deposit=True, allow_withdrawal=True)
def swap(self, ctx: Context) -> None:
"""Execute a token swap."""

if set(ctx.actions.keys()) != {self.token_a, self.token_b}:
raise InvalidTokens

action_a = ctx.get_single_action(self.token_a)
action_b = ctx.get_single_action(self.token_b)
  1. Now let's check if the two actions correspond respectively to a deposit and a withdrawal:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
...

@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

...

@public(allow_deposit=True, allow_withdrawal=True)
def swap(self, ctx: Context) -> None:
"""Execute a token swap."""

if set(ctx.actions.keys()) != {self.token_a, self.token_b}:
raise InvalidTokens

action_a = ctx.get_single_action(self.token_a)
action_b = ctx.get_single_action(self.token_b)

if not (
(isinstance(action_a, NCDepositAction) and isinstance(action_b, NCWithdrawalAction))
or (isinstance(action_a, NCWithdrawalAction) and isinstance(action_b, NCDepositAction))
):
raise InvalidActions

...

class InvalidActions(NCFail):
pass

Note that we have defined a subclass of NCFail to specialize the exception that occurs when the method does not receive exactly one DEPOSIT action and one WITHDRAWAL action.

  1. Now, we just need to check whether the quantities of tokens requested by the user for withdrawal and deposit comply with the conversion ratio defined by the contract. Define a helper method to perform this check:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
...

@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

...

@public(allow_deposit=True, allow_withdrawal=True)
def swap(self, ctx: Context) -> None:
"""Execute a token swap."""

if set(ctx.actions.keys()) != {self.token_a, self.token_b}:
raise InvalidTokens

action_a = ctx.get_single_action(self.token_a)
action_b = ctx.get_single_action(self.token_b)

if not (
(isinstance(action_a, NCDepositAction) and isinstance(action_b, NCWithdrawalAction))
or (isinstance(action_a, NCWithdrawalAction) and isinstance(action_b, NCDepositAction))
):
raise InvalidActions

if not self.is_ratio_valid(action_a.amount, action_b.amount):
raise InvalidRatio

...

class InvalidRatio(NCFail):
pass

Note that we have defined a subclass of NCFail to specialize the exception that occurs when the conversion requested by the contract user is not compatible with that defined by the contract.

  1. At this point, the swap request has passed all checks. We update the contract's state and conclude the method execution:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
...

@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

...

@public(allow_deposit=True, allow_withdrawal=True)
def swap(self, ctx: Context) -> None:
"""Execute a token swap."""

if set(ctx.actions.keys()) != {self.token_a, self.token_b}:
raise InvalidTokens

action_a = ctx.get_single_action(self.token_a)
action_b = ctx.get_single_action(self.token_b)

if not (
(isinstance(action_a, NCDepositAction) and isinstance(action_b, NCWithdrawalAction))
or (isinstance(action_a, NCWithdrawalAction) and isinstance(action_b, NCDepositAction))
):
raise InvalidActions

if not self.is_ratio_valid(action_a.amount, action_b.amount):
raise InvalidRatio

# All good! Let's accept the transaction.
self.swaps_counter += 1

Just like any public method, if no exception is raised during execution, the method will have executed successfully, it will end returning nothing (None), the contract's state will have been updated, and after that, the Hathor engine — not the contract — will take care of updating the multi-token balance of the contract, according to the set of actions that was approved. This characterizes that all actions are then executed simultaneously on the blockchain.

Step 4: write the is_ratio_valid method

We have already implemented the two public methods necessary for the creation and execution of the contract respectively. To complete the module of our blueprint, we only need to write the is_ratio_valid method we used in the previous step. is_ratio_valid is a helper method, existing solely to make the code of our blueprint more readable, and thus it is internal.

  1. is_ratio_valid should check if the quantities of tokens the user wishes to swap are proportional to the ratio with which the contract operates. For this, it will receive as parameters (in addition to the contract instance itself) the two quantities of tokens from the two actions, and should return True if the ratio is valid:
hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

...

@view
def is_ratio_valid(self, qty_a: int, qty_b: int) -> bool:
"""Check if the swap quantities are valid."""
return (self.multiplier_a * qty_a == self.multiplier_b * qty_b)

Step 5: review the blueprint module

With this, we have completed the code for our blueprint. Let's review the complete code of the module we just implemented before moving on to the testing phase:

hathor-core/hathor_tests/nanocontracts/test_blueprints/swap_demo.py
from hathor import Blueprint, Context, NCDepositAction, NCFail, NCWithdrawalAction, TokenUid, export, public, view


@export
class SwapDemo(Blueprint):
"""Blueprint to execute swaps between tokens.
This blueprint is here just as a reference for blueprint developers, not for real use.
"""

# TokenA identifier and quantity multiplier.
token_a: TokenUid
multiplier_a: int

# TokenB identifier and quantity multiplier.
token_b: TokenUid
multiplier_b: int

# Count number of swaps executed.
swaps_counter: int

@public(allow_deposit=True)
def initialize(
self,
ctx: Context,
token_a: TokenUid,
token_b: TokenUid,
multiplier_a: int,
multiplier_b: int
) -> None:
"""Initialize the contract."""

if token_a == token_b:
raise NCFail

if set(ctx.actions.keys()) != {token_a, token_b}:
raise InvalidTokens

self.token_a = token_a
self.token_b = token_b
self.multiplier_a = multiplier_a
self.multiplier_b = multiplier_b
self.swaps_counter = 0

@public(allow_deposit=True, allow_withdrawal=True)
def swap(self, ctx: Context) -> None:
"""Execute a token swap."""

if set(ctx.actions.keys()) != {self.token_a, self.token_b}:
raise InvalidTokens

action_a = ctx.get_single_action(self.token_a)
action_b = ctx.get_single_action(self.token_b)

if not (
(isinstance(action_a, NCDepositAction) and isinstance(action_b, NCWithdrawalAction))
or (isinstance(action_a, NCWithdrawalAction) and isinstance(action_b, NCDepositAction))
):
raise InvalidActions

if not self.is_ratio_valid(action_a.amount, action_b.amount):
raise InvalidRatio

# All good! Let's accept the transaction.
self.swaps_counter += 1

@view
def is_ratio_valid(self, qty_a: int, qty_b: int) -> bool:
"""Check if the swap quantities are valid."""
return (self.multiplier_a * qty_a == self.multiplier_b * qty_b)


class InvalidTokens(NCFail):
pass


class InvalidActions(NCFail):
pass


class InvalidRatio(NCFail):
pass

Step 6: create the unit test module

With our blueprint code ready, it's time to verify that it behaves as intended. The blueprint SDK provides a framework based on the Python package pytest to facilitate the execution of automated unit tests. Let's use it to code our test suite.

As we did previously with the blueprint's code, we suggest that you expand the following pane and perform an inspectional reading of the entire unit test module that we will develop. This will provide the necessary context to understand the steps we will undertake from this point forward:

hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
from hathor.nanocontracts.nc_types import make_nc_type_for_arg_type as make_nc_type
from hathor.nanocontracts.storage.contract_storage import Balance
from hathor.nanocontracts.types import NCDepositAction, NCWithdrawalAction, TokenUid
from hathor_tests.nanocontracts.blueprints.unittest import BlueprintTestCase
from hathor_tests.nanocontracts.test_blueprints.swap_demo import InvalidActions, InvalidRatio, InvalidTokens, SwapDemo

SWAP_NC_TYPE = make_nc_type(int)


class SwapDemoTestCase(BlueprintTestCase):
def setUp(self):
super().setUp()

self.blueprint_id = self.gen_random_blueprint_id()
self.contract_id = self.gen_random_contract_id()

self.nc_catalog.blueprints[self.blueprint_id] = SwapDemo

# Test doubles:
self.token_a = self.gen_random_token_uid()
self.token_b = self.gen_random_token_uid()
self.token_c = self.gen_random_token_uid()
self.address = self.gen_random_address()
self.tx = self.get_genesis_tx()

def _initialize(
self,
init_token_a: tuple[TokenUid, int, int],
init_token_b: tuple[TokenUid, int, int]
) -> None:
# Arrange:
token_a, multiplier_a, amount_a = init_token_a
token_b, multiplier_b, amount_b = init_token_b
deposit_a = NCDepositAction(token_uid=token_a, amount=amount_a)
deposit_b = NCDepositAction(token_uid=token_b, amount=amount_b)
context = self.create_context(
actions=[deposit_a, deposit_b],
vertex=self.tx,
caller_id=self.address,
timestamp=self.now
)

# Act:
self.runner.create_contract(
self.contract_id,
self.blueprint_id,
context,
token_a,
token_b,
multiplier_a,
multiplier_b,
)
self.nc_storage = self.runner.get_storage(self.contract_id)

def _swap(
self,
amount_a: tuple[int, TokenUid],
amount_b: tuple[int, TokenUid]
) -> None:
# Arrange:
value_a, token_a = amount_a
value_b, token_b = amount_b
action_a_type = self.get_action_type(value_a)
action_b_type = self.get_action_type(value_b)
swap_a = action_a_type(token_uid=token_a, amount=abs(value_a))
swap_b = action_b_type(token_uid=token_b, amount=abs(value_b))
context = self.create_context(
actions=[swap_a, swap_b],
vertex=self.tx,
caller_id=self.address,
timestamp=self.now
)

# Act:
self.runner.call_public_method(self.contract_id, 'swap', context)

def test_lifecycle(self) -> None:
# Create a contract.
# Arrange and act within:
self._initialize((self.token_a, 1, 100_00), (self.token_b, 1, 100_00))

# Assert:
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(0, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make a valid swap.
# Arrange and act within:
self._swap((20_00, self.token_a), (-20_00, self.token_b))
# Assert:
self.assertEqual(
Balance(value=120_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=80_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(1, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make multiple invalid swaps raising all possible exceptions.
with self.assertRaises(InvalidTokens):
self._swap((-20_00, self.token_a), (20_00, self.token_c))
with self.assertRaises(InvalidActions):
self._swap((20_00, self.token_a), (40_00, self.token_b))
with self.assertRaises(InvalidRatio):
self._swap((20_00, self.token_a), (-40_00, self.token_b))

def get_action_type(self, amount: int) -> type[NCDepositAction] | type[NCWithdrawalAction]:
if amount >= 0:
return NCDepositAction
else:
return NCWithdrawalAction

In the project directory tree, navigate to /hathor_tests/nanocontracts/blueprints/. The unit test suite for each blueprint is implemented as a single module.

  1. Let’s create the test_swap_demo.py file, and add the standard imports that are necessary for most blueprints unit test suites:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
from hathor.nanocontracts.nc_types import make_nc_type_for_arg_type as make_nc_type
from hathor.nanocontracts.storage.contract_storage import Balance
from hathor.nanocontracts.types import NCDepositAction, NCWithdrawalAction, TokenUid
from hathor_tests.nanocontracts.blueprints.unittest import BlueprintTestCase
from hathor_tests.nanocontracts.test_blueprints.swap_demo import InvalidActions, InvalidRatio, InvalidTokens, SwapDemo

To test our blueprint, we need the public method calls to be made by emulating the conditions under which they are invoked by the Hathor engine. Fortunately, the blueprint SDK handles this for us through the use of the superclass BlueprintTestCase and the runner object. The superclass BlueprintTestCase creates a fake blockchain system, and the runner object is able to perform the role it normally does in the Hathor Core, of invoking methods from a blueprint.

  1. We define the SwapDemoTestCase class that inherits from BlueprintTestCase:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
from hathor.nanocontracts.nc_types import make_nc_type_for_arg_type as make_nc_type
from hathor.nanocontracts.storage.contract_storage import Balance
from hathor.nanocontracts.types import NCDepositAction, NCWithdrawalAction, TokenUid
from hathor_tests.nanocontracts.blueprints.unittest import BlueprintTestCase
from hathor_tests.nanocontracts.test_blueprints.swap_demo import InvalidActions, InvalidRatio, InvalidTokens, SwapDemo

class SwapDemoTestCase(BlueprintTestCase):

With this in mind, we can think about what needs to be tested in our blueprint. We need to test the following blueprint interactions:

  • Contract creation
  • Contract execution

In other words, we need to test the public methods of the blueprint. We need to ensure that the two public methods initialize and swap execute successfully when given appropriate inputs and fail as expected when given inappropriate inputs.

Let's then follow this sequence in our test case: (1) create a valid contract; (2) execute a valid swap; (3) execute all the swaps that should fail, one for each exception we defined.

  1. For this, we will implement a single test method test_lifecycle that performs all these tests, simulating the usual lifecycle of a contract on the blockchain:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

def test_lifecycle(self) -> None:
# Create a contract.
# TO DO
# Make a valid swap.
# TO DO
# Make multiple invalid swaps raising all possible exceptions.
# TO DO
  1. To make it possible to create as many new test flows as we want beyond test_lifecycle, and to avoid code repetition in multiple calls to the public methods initialize and swap, we will create the helper methods _initialize and _swap. These should prepare the necessary objects to be sent to the blueprint (arrange) and call the respective method using the runner (act):
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):
def _initialize(self, TO DO) -> None:
# TO DO

def _swap(self, TO DO) -> None:
# TO DO

def test_lifecycle(self) -> None:
# Create a contract.
# TO DO
# Make a valid swap.
# TO DO
# Make multiple invalid swaps raising all possible exceptions.
# TO DO
  1. Now, we just need to add the setUp method to our class, standard to every test case using pytest:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):
def setUp(self):
super().setUp()
# TO DO

def _initialize(self, TO DO) -> None:
# TO DO

def _swap(self, TO DO) -> None:
# TO DO

def test_lifecycle(self) -> None:
# Create a contract.
# TO DO
# Make a valid swap.
# TO DO
# Make multiple invalid swaps raising all possible exceptions.
# TO DO

After finishing the coding of the four methods we defined, we will complete the implementation of our class, as well as the entire test module. Let's code them in the following sequence: (1) first the test_lifecycle method, defining the test flow; (2) then the _initialize method; and (3) finally the _swap method. (4) The setUp method will be used to create all necessary test doubles, and therefore will be written ad hoc throughout the process.

Our test execution will start with unittest invoking the setUp method for the necessary preliminary preparation for the tests. The statement super().setUp() carries out the standard preparation required for testing any blueprint.

  1. Add to the setUp method the creation of a dummy contract and its registration, along with the blueprint, on chain:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

def setUp(self):
super().setUp()
self.blueprint_id = self.gen_random_blueprint_id()
self.contract_id = self.gen_random_contract_id()
self.nc_catalog.blueprints[self.blueprint_id] = SwapDemo
# TO DO

...

Step 7: write the test_lifecycle method

Our flow starts with the creation of a swap contract. To create a swap contract, a user makes a call to the initialize method of our blueprint. Like any call, it can have multiple actions, and for our blueprint, it must have four mandatory arguments: (1) the two distinct tokens with which the contract will operate; and (2) the respective multipliers for each token.

  1. The creation call of a swap contract requires sending two valid tokens on the blockchain as arguments. Let's add the creation of two dummy tokens to setUp, using gen_random_token_uid() already implemented in the superclass:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

def setUp(self):
super().setUp()
self.blueprint_id = self.gen_random_blueprint_id()
self.contract_id = self.gen_random_contract_id()

self.nc_catalog.blueprints[self.blueprint_id] = SwapDemo

# Test doubles:
self.token_a = self.gen_random_token_uid()
self.token_b = self.gen_random_token_uid()
# TO DO

...
  1. All other arguments for creating a contract can be arbitrary literals, and with that, we can already create a valid contract. Note that this defines the signature of the _initialize method:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def test_lifecycle(self) -> None:
# Create a contract.
# Arrange and act within:
self._initialize((self.token_a, 1, 100_00), (self.token_b, 1, 100_00))

# Make a valid swap.
# TO DO
# Make multiple invalid swaps raising all possible exceptions.
# TO DO
  1. Our _initialize method receives two identical tuples: (token, token's multiplicative factor, and token amount to be deposited). Let's check if the contract's state was correctly initialized:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def test_lifecycle(self) -> None:
# Create a contract.
# Arrange and act within:
self._initialize((self.token_a, 1, 100_00), (self.token_b, 1, 100_00))

# Assert:
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(0, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make a valid swap.
# TO DO
# Make multiple invalid swaps raising all possible exceptions.
# TO DO

Note that assertEqual and nc_storage are implemented in the superclass of our test class.

With our swap contract created, let's test a valid swap case.

  1. To execute the contract, i.e., to perform a swap, the user should make a call to the swap method without passing any additional arguments. In this case, we only need to test the actions. In our contract, there must be exactly two actions, one deposit of token A and one withdrawal of token B, in amounts determined by the ratio:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def test_lifecycle(self) -> None:
# Create a contract.
# Arrange and act within:
self._initialize((self.token_a, 1, 100_00), (self.token_b, 1, 100_00))

# Assert:
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(0, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make a valid swap.
# Arrange and act within:
self._swap((20_00, self.token_a), (-20_00, self.token_b))

# Make multiple invalid swaps raising all possible exceptions.
# TO DO

Note that we defined the signature of the _swap method with two tuples as parameters, one for the action of each token. The _swap helper uses a test-only convention to make the test calls easier to read: positive values create deposit actions, and negative values create withdrawal actions. The helper converts those signed values into NCDepositAction or NCWithdrawalAction objects before creating the context.

  1. We now need to ensure that the state of the contract — multi-token balance and attribute values — was updated correctly after contract execution:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def test_lifecycle(self) -> None:
# Create a contract.
# Arrange and act within:
self._initialize((self.token_a, 1, 100_00), (self.token_b, 1, 100_00))

# Assert:
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(0, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make a valid swap.
# Arrange and act within:
self._swap((20_00, self.token_a), (-20_00, self.token_b))
# Assert:
self.assertEqual(
Balance(value=120_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=80_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(1, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make multiple invalid swaps raising all possible exceptions.
# TO DO

Now let's make swap attempts that should fail. Recall that during the implementation of our blueprint, we created classes specifying the three exceptions that can make a contract execution fail. This facilitates checking the failure cases.

  1. The first case to check is requests for swaps of tokens with which the contract does not operate. Recall that before invoking a contract's execution, the Hathor engine verifies if the tokens from deposit and withdrawal actions exist on blockchain. Therefore, let's create a dummy token C, which exists on blockchain but is not operated by the contract:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

def setUp(self):
super().setUp()
self.blueprint_id = self.gen_random_blueprint_id()
self.contract_id = self.gen_random_contract_id()

self.nc_catalog.blueprints[self.blueprint_id] = SwapDemo

# Test doubles:
self.token_a = self.gen_random_token_uid()
self.token_b = self.gen_random_token_uid()
self.token_c = self.gen_random_token_uid()
# TO DO

...
  1. Check whether the expected execution failure occurs when a user requests a swap of at least one token that the contract does not operate with. It should raise the InvalidTokens exception:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def test_lifecycle(self) -> None:
# Create a contract.
# Arrange and act within:
self._initialize((self.token_a, 1, 100_00), (self.token_b, 1, 100_00))

# Assert:
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(0, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make a valid swap.
# Arrange and act within:
self._swap((20_00, self.token_a), (-20_00, self.token_b))
# Assert:
self.assertEqual(
Balance(value=120_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=80_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(1, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make multiple invalid swaps raising all possible exceptions.
with self.assertRaises(InvalidTokens):
self._swap((-20_00, self.token_a), (20_00, self.token_c))
  1. Now, check whether the user sent actions related to the two tokens with which the contract operates with, but they were not one deposit and one withdrawal. It should raise the InvalidActions exception:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def test_lifecycle(self) -> None:
# Create a contract.
# Arrange and act within:
self._initialize((self.token_a, 1, 100_00), (self.token_b, 1, 100_00))

# Assert:
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(0, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make a valid swap.
# Arrange and act within:
self._swap((20_00, self.token_a), (-20_00, self.token_b))
# Assert:
self.assertEqual(
Balance(value=120_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=80_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(1, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make multiple invalid swaps raising all possible exceptions.
with self.assertRaises(InvalidTokens):
self._swap((-20_00, self.token_a), (20_00, self.token_c))
with self.assertRaises(InvalidActions):
self._swap((20_00, self.token_a), (40_00, self.token_b))
  1. Finally, check whether the tokens and actions are correct, but the deposit and withdrawal quantities do not correspond to the contract's conversion ratio. It should raise the InvalidRatio exception:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def test_lifecycle(self) -> None:
# Create a contract.
# Arrange and act within:
self._initialize((self.token_a, 1, 100_00), (self.token_b, 1, 100_00))

# Assert:
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(0, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make a valid swap.
# Arrange and act within:
self._swap((20_00, self.token_a), (-20_00, self.token_b))
# Assert:
self.assertEqual(
Balance(value=120_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=80_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(1, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make multiple invalid swaps raising all possible exceptions.
with self.assertRaises(InvalidTokens):
self._swap((-20_00, self.token_a), (20_00, self.token_c))
with self.assertRaises(InvalidActions):
self._swap((20_00, self.token_a), (40_00, self.token_b))
with self.assertRaises(InvalidRatio):
self._swap((20_00, self.token_a), (-40_00, self.token_b))

Step 8: write the _initialize method

Our helper method _initialize will prepare all objects that our blueprint's initialize needs to receive (arrange); then, it will use the runner to call initialize from SwapDemo (act).

  1. _initialize already receives within its parameters the four arguments that must be passed to initialize:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def _initialize(
self,
init_token_a: tuple[TokenUid, int, int],
init_token_b: tuple[TokenUid, int, int]
) -> None:
  1. Unpack the received parameters into local variables:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def _initialize(
self,
init_token_a: tuple[TokenUid, int, int],
init_token_b: tuple[TokenUid, int, int]
) -> None:
# Arrange:
token_a, multiplier_a, amount_a = init_token_a
token_b, multiplier_b, amount_b = init_token_b
  1. Create the two actions needed to form the context object:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def _initialize(
self,
init_token_a: tuple[TokenUid, int, int],
init_token_b: tuple[TokenUid, int, int]
) -> None:
# Arrange:
token_a, multiplier_a, amount_a = init_token_a
token_b, multiplier_b, amount_b = init_token_b
deposit_a = NCDepositAction(token_uid=token_a, amount=amount_a)
deposit_b = NCDepositAction(token_uid=token_b, amount=amount_b)
  1. And then, create the context object:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def _initialize(self, init_token_a: tuple[TokenUid, int, int], init_token_b: tuple[TokenUid, int, int]) -> None:
# Arrange:
token_a, multiplier_a, amount_a = init_token_a
token_b, multiplier_b, amount_b = init_token_b
deposit_a = NCDepositAction(token_uid=token_a, amount=amount_a)
deposit_b = NCDepositAction(token_uid=token_b, amount=amount_b)
context = self.create_context(
actions=[deposit_a, deposit_b],
vertex=self.tx,
caller_id=self.address,
timestamp=self.now
)

Note that now is implemented in the superclass to generate an int type timestamp.

  1. Finally, it's time to use the runner to call the method under test, as it's done in the Hathor Core:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def _initialize(self, init_token_a: tuple[TokenUid, int, int], init_token_b: tuple[TokenUid, int, int]) -> None:
# Arrange:
token_a, multiplier_a, amount_a = init_token_a
token_b, multiplier_b, amount_b = init_token_b
deposit_a = NCDepositAction(token_uid=token_a, amount=amount_a)
deposit_b = NCDepositAction(token_uid=token_b, amount=amount_b)
context = self.create_context(
actions=[deposit_a, deposit_b],
vertex=self.tx,
caller_id=self.address,
timestamp=self.now
)

# Act:
self.runner.create_contract(
self.contract_id,
self.blueprint_id,
context,
token_a,
token_b,
multiplier_a,
multiplier_b,
)
self.nc_storage = self.runner.get_storage(self.contract_id)

Step 9: write the _swap method

Our helper method _swap will function similarly to _initialize, but it will handle the execution of contracts by calling the swap method.

  1. In test_lifecycle, we already defined the signature for _swap as having two tuples as parameters. Each tuple contains a value (amount plus type of action) and the token involved in the conversion:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def _swap(
self,
amount_a: tuple[int, TokenUid],
amount_b: tuple[int, TokenUid]
) -> None:
  1. Unpack the received parameters into local variables:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def _swap(
self,
amount_a: tuple[int, TokenUid],
amount_b: tuple[int, TokenUid]
) -> None:
# Arrange:
value_a, token_a = amount_a
value_b, token_b = amount_b
  1. Now we need to create the context object containing the two actions that make up a swap. Remember that the type of action is encapsulated in the sign of the amount passed as a parameter. We will also write the _get_action_type helper method to define the type of action:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def get_action_type(self, amount: int) -> type[NCDepositAction] | type[NCWithdrawalAction]:
if amount >= 0:
return NCDepositAction
else:
return NCWithdrawalAction

...

def _swap(
self,
amount_a: tuple[int, TokenUid],
amount_b: tuple[int, TokenUid]
) -> None:
# Arrange:
value_a, token_a = amount_a
value_b, token_b = amount_b
action_a_type = self.get_action_type(value_a)
action_b_type = self.get_action_type(value_b)
swap_a = action_a_type(token_uid=token_a, amount=abs(value_a))
swap_b = action_b_type(token_uid=token_b, amount=abs(value_b))
  1. And then, create the context object:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def _swap(
self,
amount_a: tuple[int, TokenUid],
amount_b: tuple[int, TokenUid]
) -> None:
# Arrange:
value_a, token_a = amount_a
value_b, token_b = amount_b
action_a_type = self.get_action_type(value_a)
action_b_type = self.get_action_type(value_b)
swap_a = action_a_type(token_uid=token_a, amount=abs(value_a))
swap_b = action_b_type(token_uid=token_b, amount=abs(value_b))
context = self.create_context(
actions=[swap_a, swap_b],
vertex=self.tx,
caller_id=self.address,
timestamp=self.now
)
  1. Finally, it's time to use the runner to call the method under test, as it's done in the Hathor Core:
hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
...

class SwapDemoTestCase(BlueprintTestCase):

...

def _swap(
self,
amount_a: tuple[int, TokenUid],
amount_b: tuple[int, TokenUid]
) -> None:
# Arrange:
value_a, token_a = amount_a
value_b, token_b = amount_b
action_a_type = self.get_action_type(value_a)
action_b_type = self.get_action_type(value_b)
swap_a = action_a_type(token_uid=token_a, amount=abs(value_a))
swap_b = action_b_type(token_uid=token_b, amount=abs(value_b))
context = self.create_context(
actions=[swap_a, swap_b],
vertex=self.tx,
caller_id=self.address,
timestamp=self.now
)

# Act:
self.runner.call_public_method(self.contract_id, 'swap', context)

Step 10: review the blueprint unit test module

With this, we have completed the code for our test suite. Let's review the complete code of the module we just implemented before starting running the tests:

hathor-core/hathor_tests/nanocontracts/blueprints/test_swap_demo.py
from hathor.nanocontracts.nc_types import make_nc_type_for_arg_type as make_nc_type
from hathor.nanocontracts.storage.contract_storage import Balance
from hathor.nanocontracts.types import NCDepositAction, NCWithdrawalAction, TokenUid
from hathor_tests.nanocontracts.blueprints.unittest import BlueprintTestCase
from hathor_tests.nanocontracts.test_blueprints.swap_demo import InvalidActions, InvalidRatio, InvalidTokens, SwapDemo

SWAP_NC_TYPE = make_nc_type(int)


class SwapDemoTestCase(BlueprintTestCase):
def setUp(self):
super().setUp()

self.blueprint_id = self.gen_random_blueprint_id()
self.contract_id = self.gen_random_contract_id()

self.nc_catalog.blueprints[self.blueprint_id] = SwapDemo

# Test doubles:
self.token_a = self.gen_random_token_uid()
self.token_b = self.gen_random_token_uid()
self.token_c = self.gen_random_token_uid()
self.address = self.gen_random_address()
self.tx = self.get_genesis_tx()

def _initialize(
self,
init_token_a: tuple[TokenUid, int, int],
init_token_b: tuple[TokenUid, int, int]
) -> None:
# Arrange:
token_a, multiplier_a, amount_a = init_token_a
token_b, multiplier_b, amount_b = init_token_b
deposit_a = NCDepositAction(token_uid=token_a, amount=amount_a)
deposit_b = NCDepositAction(token_uid=token_b, amount=amount_b)
context = self.create_context(
actions=[deposit_a, deposit_b],
vertex=self.tx,
caller_id=self.address,
timestamp=self.now
)

# Act:
self.runner.create_contract(
self.contract_id,
self.blueprint_id,
context,
token_a,
token_b,
multiplier_a,
multiplier_b,
)
self.nc_storage = self.runner.get_storage(self.contract_id)

def _swap(
self,
amount_a: tuple[int, TokenUid],
amount_b: tuple[int, TokenUid]
) -> None:
# Arrange:
value_a, token_a = amount_a
value_b, token_b = amount_b
action_a_type = self.get_action_type(value_a)
action_b_type = self.get_action_type(value_b)
swap_a = action_a_type(token_uid=token_a, amount=abs(value_a))
swap_b = action_b_type(token_uid=token_b, amount=abs(value_b))
context = self.create_context(
actions=[swap_a, swap_b],
vertex=self.tx,
caller_id=self.address,
timestamp=self.now
)

# Act:
self.runner.call_public_method(self.contract_id, 'swap', context)

def test_lifecycle(self) -> None:
# Create a contract.
# Arrange and act within:
self._initialize((self.token_a, 1, 100_00), (self.token_b, 1, 100_00))

# Assert:
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=100_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(0, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make a valid swap.
# Arrange and act within:
self._swap((20_00, self.token_a), (-20_00, self.token_b))
# Assert:
self.assertEqual(
Balance(value=120_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_a)
)
self.assertEqual(
Balance(value=80_00, can_mint=False, can_melt=False), self.nc_storage.get_balance(self.token_b)
)
self.assertEqual(1, self.nc_storage.get_obj(b'swaps_counter', SWAP_NC_TYPE))

# Make multiple invalid swaps raising all possible exceptions.
with self.assertRaises(InvalidTokens):
self._swap((-20_00, self.token_a), (20_00, self.token_c))
with self.assertRaises(InvalidActions):
self._swap((20_00, self.token_a), (40_00, self.token_b))
with self.assertRaises(InvalidRatio):
self._swap((20_00, self.token_a), (-40_00, self.token_b))

def get_action_type(self, amount: int) -> type[NCDepositAction] | type[NCWithdrawalAction]:
if amount >= 0:
return NCDepositAction
else:
return NCWithdrawalAction

Task completed

At this point, you have completed the source code for your first blueprint using the Blueprint SDK. Now run the automated unit tests.

From the root of the hathor-core project, execute:

~/hathor-core
poetry run pytest -v -n0 hathor_tests/nanocontracts/blueprints/test_swap_demo.py \
-W ignore::DeprecationWarning \
-W ignore::PendingDeprecationWarning \
-W ignore::FutureWarning

The expected result is that the test_lifecycle test passes. A passing test confirms that the blueprint can:

  • Create a contract with initial liquidity for both supported tokens.
  • Execute a valid swap.
  • Reject unsupported tokens.
  • Reject invalid action combinations.
  • Reject invalid conversion ratios.

Key takeaways

Automated unit testing is the fastest way to validate blueprint logic while you are developing. In this tutorial, the tests focus on the blueprint's public methods, state changes, balance changes, and expected failure cases.

After unit testing, consider integration testing when you need to validate the blueprint in a fuller blockchain test environment and instantiate nano contracts on chain from the developed blueprint.

Next steps

  • Review the Blueprint SDK guidelines for the complete rules on imports, type annotations, allowed value types, decorators, and method behavior.
  • Extend the test suite with additional edge cases, such as invalid initialization inputs or different conversion ratios.
  • Continue to integration testing before considering any production use.