Blueprint development guidelines
Introduction
This article is the primary reference material to consult when developing a blueprint. It provides guidelines for its design and implementation, and describes the prescribed rules for blueprint code. Now, to understand the overall mechanics of blueprints, see Nano contracts: how it works.
This article refers to the following version of the Blueprint SDK: branch experimental/nano-testnet-v1.8.0
of hathor-core
repository at GitHub.
Note that the Blueprint SDK is an experimental phase and evolving rapidly. Newer versions may include features not yet covered here. Moreover, backward compatibility is not guaranteed, and they may change the behavior described in this article.
This article is in final review stage. While most of its content is accurate, some errors may still be present.
Python SDK
Hathor provides an SDK for blueprint development in Python 3. In the future, blueprint development may be expanded to other languages. For now, you need to develop your blueprint using Python 3.
Throughout this article, we will present the complete set of constraints that your blueprint code must follow. That is, your code must comply with all these rules to execute without errors on Hathor engine. Aside from these constraints, you are free to write Python 3 code as usual.
Module
A blueprint must be implemented as a single Python module — namely, a single file my_blueprint.py
.
Imports
You can only import names explicitly allowed by the blueprint SDK. The following snippet provides an exhaustive list of allowed imports:
# Standard and related third parties
from math import ceil, floor
from typing import Optional, NamedTuple, TypeAlias
# Hathor (local application/library)
from hathor.nanocontracts.blueprint import Blueprint
from hathor.nanocontracts.context import Context
from hathor.nanocontracts.exception import NCFail
from hathor.nanocontracts.types import (
Address,
Amount,
BlueprintId,
ContractId,
fallback,
NCAction,
NCActionType,
NCAcquireAuthorityAction,
NCDepositAction,
NCGrantAuthorityAction,
NCWithdrawalAction
public,
SignedData,
Timestamp,
TokenUid,
TxOutputScript,
VertexId,
view,
)
Any import outside this list is not allowed. For the hathor.nanocontracts
package interface documentation, see Blueprint SDK — API.
Classes
Regarding classes, you must comply with the following rules:
- Your module must have exactly one class that inherits from
Blueprint
. This is the primary class of your module, that models contracts and is used to instantiate them. - Like any regular Python module, your module may contain multiple other classes. However, these classes should only be used to instantiate temporary objects, such as error specifications.
- You must indicate to Hathor engine which is your blueprint class. Suppose
MyBlueprint
is that class:- For a built-in blueprint, add
MyBlueprint
to modulehathor-core/hathor/nanocontracts/blueprints/__init__.py
. - For an on-chain blueprint, add the statement
__blueprint__ = MyBlueprint
somewhere in your module after definingMyBlueprint
.
- For a built-in blueprint, add
The following snippet presents an example of on-chain blueprint module that comply with these rules:
# Start with the imports.
# Remember: only allowed imports.
from hathor.nanocontracts.blueprint import Blueprint
...
# Define any ancillary classes:
class InvalidToken(NCFail):
pass
...
# Define the blueprint class.
# This is the class that will be used to instantiate contracts.
# We suggest you to use the same name as the module:
class MyBlueprint(Blueprint):
...
# Finally, assign the primary class to Hathor protocol:
__blueprint__ = MyBlueprint
Type annotations
There are two rules you must comply with:
- Type annotations are mandatory for all contract attributes, and methods' parameters and return values. The only exception is the first parameter of each method, conventionally
self
. - When following rule (1), all generic types must be fully parameterized.
The following snippet provides examples of how to apply rule (1) in a simple case without generic types:
...
class MyBlueprint(Blueprint):
"""Docstring explaining the blueprint.
After this docstring, you must specify all contract attributes.
Do this using type annotations.
"""
# Maximum value for loan
max_loan_value: int
# Id of the borrower
borrower_id: str
# Token that your contract operate with
token_uid: TokenUid
# Amount to be borrowed
amount: Amount
# Then, start defining the methods
# Example of method signature:
# Note that all parameters (except self)
# and the return value all use type annotations.
@view
def check_credit(self, credit_id: int, borrower_name: str) -> bool:
...
...
The next subsection describes how to apply rule (2).
Generic types
First, let's look at an example that is valid in Python but not sufficient for blueprints. The following snippet presents examples of incorrect contract attribute specifications:
# Let's specify contract attributes:
incorrect_list: list
other_incorrect_list: list[list]
incorrect_dict: dict
# What is wrong here?
# Generic containers are not fully parameterized!
What all these examples have in common — and what makes them incorrect in blueprints — is that they are not fully parameterized, and therefore do not comply with rule (2). The following snippet presents the same examples, now modified to be correct in blueprints:
# Let's try to specify contract attributes again:
correct_list: list[str]
other_correct_list: list[list[int]]
correct_dict: dict[bytes, bool]
# Now all generic containers are fully parameterized!
# All good now!
The following snippet provides an exhaustive list of types that shall comply with rule (2):
"""Generic containers:
Built-in types:
- list[T]
- set[T]
- dict[K, T]
- tuple[T, K, ...] (variable length)
hathor.nanocontracts package types:
- SignedData[T]
"""
These are all generic containers, and whenever you use these them in attributes, parameters, or return values, you must fully parameterize them.
Finally, note that you can nest multiple generic containers, as long as each is fully parameterized. The following code snippet presents examples of both correct and incorrect contract attribute specifications using nested generic container:
...
class MyBlueprint(Blueprint):
"""Docstring explaining the blueprint.
After this docstring, you must specify all contract attributes.
Do this using type annotations.
"""
# this is correct
correct_attribute: list[str]
# this is NOT correct
incorrect_attribute: list
# this is a correct dict
another_correct: dict[Address, Amount]
# this is a correct tuple
also_another_correct: tuple[str, int]
# tuple can have variable size as long as all items are the same
tuple_with_variable_size: tuple[str, ...]
# You can nest containers
nested_attribute: list[set[str]]
# You can nest more; just make sure fully parameterize.
more_nested_and_correct: dict[tuple[Address, str], Amount]
# This is also valid, to understand see the blueprint SDK API reference
last_correct: SignedData[str]
# Remember, these are all NOT correct:
incorrect_dict: dict
incorrect_set: set
# Then, start defining the methods
...
Forbidden names
The following snippet provides an exhaustive list of names that are not allowed for use:
"""
Forbiden names List:
- __builtins__
- __import__
- compile
- delattr
- dir
- eval
- exec
- getattr
- globals
- hasattr
- input
- locals
- open
- setattr
- vars
"""
Reserved names
The following snippet provides an exhaustive list of names reserved by the SDK. You must not bind these names to any value or object:
"""
You cannot OVERRIDE these names:
- syscall
- log
"""
In other words, you cannot override the SDK reserved names through function, class, or variable definitions; attribute assignments; method declarations; parameter names; or import statements. For example:
syscal = 123 # NOT ALLOWED
def syscal(): pass # NOT ALLOWED
self.syscall = 123 # NOT ALLOWED
However, you may (and should) still use (access) these names via 'dot notation'. For example:
self.syscall # ALLOWED
self.log # ALLOWED
Attributes
In Python, data attributes are not declared prior to being assigned a value. Also, data attributes do not have types — only the values assigned to them do. However, the blueprint SDK mandates specifying each contract attribute along with its value type using type annotations. From the developer's perspective, it is as declaring variables in a statically‑typed language without type inference, such as C.
As a result, within your blueprint class, you must use type annotations to specify all contract attributes along with their respective value types. These type annotations shall appear before any method definitions. To know how to do this, see section Type annotations.
Allowed value types
Contract attributes must have values of types explicitly allowed by the blueprint SDK. The following snippet provides an exhaustive list of the allowed types:
"""Attribute data types you can use:
Built-in types:
- int
- str
- float
- bool
- bytes
- list[T]
- set[T]
- dict[K, T]
- tuple[T, K, V, ...] (variable length)
Standard library types:
- NamedTuple
hathor.nanocontracts package types:
- Address
- Amount
- BlueprintId
- ContractId
- Script
- SignedData[T]
- Timestamp
- TokenUid
- VertexId
"""
Any attribute value with a type not included in this list will not be allowed. For the data types provided by hathor.nanocontracts
, see Blueprint SDK — API.
Note that, for now, instances of classes defined within the blueprint module itself cannot be used as attribute values.
Class attributes
Blueprints do not support class attributes. In object-oriented programming, such as in Python, classes can have both class and instance attributes. Instance attributes hold individual values for each object instantiated from the class, whereas class attributes share values across all instances.
Although Python allows for class attributes, they are not allowed by the blueprint SDK. As a result, nano contracts instantiated from the same blueprint do not share any dynamic values; they only share static, hard-coded values from the blueprint's source code.
Methods
Types
Why use each type of method?
- Method
initialize
: you must define it. It will be used for contract creation — i.e., instantiate contracts from the blueprint. - Public methods: you should define them for contract execution — i.e., providing the contract's functionalities to callers.
- View methods: you can define them for logic that can be used both internally by the contract and externally by callers.
- Internal methods: you can define them for logic that shall be used only within the contract.
- Method
fallback
: you may define it. If present, it is (automatically) invoked by Hathor engine whenever a non-existent public method is called.
For more on method types, see Methods at Nano contracts: how it works.
Decorators
Decorators are used to define the type of a method. Additionally, in the case of public methods, the decorator specifies which types of actions the method accepts. Use decorators to mark your methods as follows:
@public
: for all public methods, includinginitialize
.@view
: for all view methods.@fallback
: use to mark the method namedfallback
.
For how to use these decorators, see Decorators at Blueprint SDK — API.
If a method does not have any decorator, it is considered internal. for example:
def _get_action(self, ctx: Context) -> NCAction:
"""Return the only action available; fails otherwise."""
...
The underscore at the beginning of the internal method name is optional and is used here as a good practice in Python programming to indicate internal methods.
Finally, do not mark a method with more than one decorator. A method can have only one decorator and, therefore, a single type.
To reiterate:
@public
: public method, includinginitialize
.@view
: view method.@fallback
: for the method namedfallback
.- No decorator: internal method.
- Method with more than one decorator: syntax error.
Calls
Public methods can be called externally by users via nano contracts transactions, and by other contracts executing; and internally by other public methods. Public methods can call any other method of the contract.
View methods can be called externally by users via full node API requests, and internally by any other method. View methods can call other view methods, cannot call public methods, and can call internal methods as long as these do not change the attributes of the contract.
Internal methods can only be called internally by any other method. They cannot be called externally by users. Internal methods can call other internal methods and view methods, and cannot call public methods.
Fallback method cannot be directly called, neither externally (by users via transactions or by other contracts in a call chain), nor internally by other methods within the same contract. Only the Hathor engine itself can invoke the fallback
method, and it does so when a caller attempts to invoke a non-existent public method.
Be careful while implementing state changes within internal methods. This will work fine as long as the method is not used, directly or indirectly, by a view method.
The golden rule is that the primary call dictates if the state of a contract can or cannot be changed. If the first method called in a contract is public, the internal methods subsequently called can alter the contract’s attributes. However, if the first method is a view, the internal methods called must not alter the state of the contract. If they do, the call will raise an exception.
The following snippet presents an example of valid calls between methods in a blueprint:
class FooBar(Blueprint):
...
@public
def initialize(self, ctx: Context, *args) -> None:
...
# Some attribute of this blueprint
self.dummy = 0
...
@public
def foobar(self, ctx: Context, *args) -> None:
...
# foobar is public and therefore can call any other method:
self.foo(ctx)
self.bar()
self._grok()
self._qux()
...
@public
def foo(self, ctx: Context) -> None:
...
@view
def bar(self) -> int:
...
# Can call the internal methods
# as long as they don't change attributes
self._qux()
# No decorator, it's an internal method
def _grok(self) -> str:
...
# Be careful!
# This method changes attributes;
# shall not be called by view methods
self.dummy += 1
...
# Another internal method
def _qux(self) -> bool:
...
# This method doesn't change attributes;
# can be safely called by view methods
...
In a nutshell, when the primary call is a view method, you need to ensure that no method in the call chain tries to alter the contract’s attributes. Typically, you will have public methods providing the contract’s functionalities, view methods for user queries, and internal methods as helpers.
Parameters
Regarding parameters, you must comply with the following rules:
- The first parameter of all methods must be the contract instance self-reference (conventionally
self
). - The second parameter of all public methods must be a
Context
object. - After the mandatory parameters defined in rules (1) and (2), methods can have any number of specific parameters.
- All parameters must follow the type annotation rules described in section Type annotations.
- The specific parameters of internal methods can have any valid type within the blueprint.
- However, the specific parameters of public, view, and
fallback
methods may only have explicitly allowed types. The following snippet provides an exhaustive list of these allowed types:
"""Allowed types for parameters:
Built-in types:
- int
- str
- float
- bool
- bytes
- list[T]
- dict[K, T]
- tuple[T, K, V, ...] (variable length)
Standard library types:
- NamedTuple
hathor.nanocontracts package types:
- Address
- Amount
- SignedData[T]
- Timestamp
- TokenUid
- VertexId
"""
To comply with rule (4), see section Type annotations. For the other rules, the next subsections explain how to apply them to each method type.
Public methods
Public methods always have at least two parameters. (1) Since this is Python, the first parameter must be a self-reference, namely the contract itself — conventionally referred to as self
. (2) The second parameter must always be a Context
object. (3) After that, you can define any number of additional parameters. For example:
@public(allow_actions=[NCActionType.DEPOSIT])
def bet(self, ctx: Context, address: Address, score: str) -> None:
"""Make a bet."""
...
The specific parameters of the method may only use types explicitly allowed, as listed in rule (6) in the previous subsection.
View methods
View methods must have self
as the first parameter and may have any number of additional parameters. For example:
@view
def get_max_withdrawal(self, address: Address) -> int:
"""Return the maximum amount available for withdrawal."""
...
Again, the specific parameters of the method may only use types explicitly allowed, as listed in rule (6) in the previous subsection.
Internal methods
Internal methods must have the first parameter self
and may have any number of additional parameters. However, unlike public and view methods, internal methods can have parameters of any valid type within the blueprint. For example:
def _assess_credit(self, address: Address) -> bool:
"""Return if credit should or not be provided to given address."""
...
Method fallback
Regarding parameters, method fallback
follows the same rules as public methods.
Return value
Regarding return values, you must comply with the following rules:
- All methods must include an explicit return type, following the type annotation rules described in section Type annotations.
- Internal methods may return any valid type within the blueprint.
- Public, view, and
fallback
methods may only return values with explicitly allowed types. The following snippet provides an exhaustive list of these allowed types:
"""Allowed types for view method's return values:
Built-in types:
- int
- str
- float
- bool
- bytes
- list[T]
- dict[K, T]
- tuple[T, K, V, ...] (variable length)
Standard library types:
- NamedTuple
hathor.nanocontracts package types:
- Address
- Amount
- SignedData[T]
- Timestamp
- TokenUid
- VertexId
"""
To comply with rule (1), see section Type annotations. For the other rules, the next subsections explain how to apply them to each method type.
Internal methods can return any valid type within the blueprint class. This includes, for example, other classes defined in the module. For example:
...
class Foo:
pass
...
class MyBlueprint(Blueprint):
...
def bar(self) -> Foo:
...
Note that this internal method returns an instance of a class defined within the blueprint module itself, which is not possible in other types of methods.
Balances
The state of a contract comprises its attributes and its balances. The latter is also referred to as its multi-token balance. Attributes are defined in the blueprint code and are directly controlled by the contract through the logic of its public methods. The multi-token balance is controlled by Hathor engine, similarly to how the Ether balance of a contract is controlled on Ethereum platform. However, whereas in Ethereum all other tokens are just regular attributes, in Hathor all tokens are controlled by Hathor engine.
Reading
For how to read its own balances, or the balances of another contract, see Blueprint.syscall
at Blueprint SDK — API.
Since it is not a contract attribute, the procedure for reading its own balance is the same as reading any global state variable from the ledger (blockchain), which requires an external interactions.
Note that Hathor engine is not capable of listing and informing the contract which tokens it has a non-zero balance of. Therefore, the contract needs to know in advance which token it wants to check the balance of. That is, it cannot discover at runtime which tokens are present in its multi-token balance. As a result, you should define attributes to store which tokens the contract can receive deposits from, and among those, which ones it has already received.
Updating
All updates to a contract's balances occur upon the successful execution of a public method. This happens as follows:
- A public method is called along with a batch of actions.
- If the execution of the public method is successful (i.e., does not return an exception), Hathor engine will execute the entire batch of actions.
For example, to make a deposit into a contract, a public method should be called with a deposit action. If the method executes successfully, then the Hathor engine updates the contract's balance.
Business logic
The logic of each public method (and method fallback
) may (and should) do the following:
- Update the contract's own data attributes.
- Read the global ledger state via external interaction with Hathor engine.
- Write to the global ledger state via external interaction with Hathor engine. This includes creating new tokens, managing and using token authorities, calling other contracts, creating other contracts, etc.
- Finally, use conditional logic to check the batch of actions received from the caller and decide whether to authorize the entire batch. If the method decides to reject the batch, it halts execution by returning an exception. Otherwise, it proceeds to the end.
Then, Hathor engine processes the method’s return. If it returns an exception, all changes made during execution are discarded. If it returns any other value, all changes are committed to the ledger.
It is not possible to have any statements anywhere in your blueprint that directly update the contract’s balance. All balance updates are handled by Hathor engine, once a method completes its execution successfully.
Furthermore, methods cannot decide fund transfers from their balance to an address at runtime. The logic of a public method should only update attributes and authorize or deny the entire batch of actions made by a caller.
Next subsection presents an use case example to illustrate all these rules you should take into account when designing the business logic of your methods.
Use case example
Suppose you want to create a blueprint that models the use case of "collectible trading cards" (e.g., Pokémon TCG, baseball cards, Magic the Gathering) sold in "blind boxes." Let's see the requirements for this type of use case:
- The use case comprises a collection of trading cards.
- These cards are sold in "blind boxes," each containing, for example, 5 cards.
- The contents of each blind box are hidden, random, and only revealed after being opened, containing any of the 5 cards in the collection.
- The trading card will be modeled as a collection of NFTs, where each card is an NFT.
- The contract will hold a supply of NFTs in its balance, to be used as a stock for the blind boxes.
- The user interacts with the contract to purchase a blind box.
- Each blind box costs, for example, 10 tokens A.
- The contract is responsible for generating and selling these blind boxes.
- When called to execute the sale of a blind box to a user, the contract should generate, randomly and at runtime, the user's blind box using the NFTs in its balance, which serves as its stock.
At first, you might think of modeling the sale of blind boxes in this blueprint as follows:
- A user (end user or another contract) calls the
buy_blind_box
method and sending only a deposit of 10 tokens A, with no arguments inargs
. buy_blind_box
verifies that the deposit equates to the purchase of exactly 1 blind box, priced at 10 tokens A.buy_blind_box
randomly selects 5 NFTs from those available in its balance.buy_blind_box
sends the 5 NFTs to the caller's address.
However, as we've seen, it is not possible for the contract to decide to send funds at runtime. All fund transfers occur through withdrawals and must be previously requested in the batch of actions by the caller. So how can this use case be modeled, given that the user cannot know in advance which NFTs can be withdrawn from the contract?
To model this type of use case, two contract executions will always be necessary:
- The first will request the purchase and generates the product.
- The second will request to collect the purchased product.
For our collectible trading cards blueprint, we could model it as follows:
- A public method
buy_blind_box
that receives purchase orders through a deposit, randomly generates the blind box, and then saves in the contract's state that the buyer's calling address is entitled to collect the 5 NFTs selected in the blind box. - A view method
reveal_blind_box
that the user will use to discover which NFTs they can withdraw. This would be the real life equivalent to opening the physical blind box package and looking at the cards. - A public method
get_blind_box
that the user will use to withdraw the 5 NFTs contained in the blind box they purchased from the contract.
The public method buy_blind_box
should:
- Verify that the batch of actions of the call contains only one deposit action with a value of 10 tokens A (the sale price of the blind box).
- Randomly select 5 of these NFTs from the universe of NFTs available in its balance.
- Finally, update its attributes to record that the buyer, represented by the calling address (in the call), is entitled to withdraw the 5 selected NFTs.
The same applies to the public method get_blind_box
:
- Verify the batch of actions contains exactly 5 actions, one to withdraw each NFT revealed in the blind box.
- Verify if the caller is entitled to withdraw the 5 requested NFTs.
- Update its attributes to record that the buyer has already collected their blind box.
- End its execution without exceptions, signaling to Hathor engine that authorizes the withdrawals.
When designing your blueprint, remember that there are two reasons why callers execute a contract:
The first and most common (1) is when the caller wants to use a functionality of the contract, which will most often result in a batch of actions that they perform with contract. For example, a bet blueprint has the public method bet
, which users use to place bets on a bet contract.
The second reason (2) is when an oracle wants to provide off-chain data that a contract needs to operate. Still using the bet blueprint as an example, it has the public method set_result
to be used by the oracle to report the result of a betting event (e.g., a football match, an MMA fight, etc.).
External interactions
External interactions refer to any interaction made by a contract with Hathor engine. That is, any statement in the blueprint code that is not self-contained within its own module. For example, manipulating its own attributes, instantiating variables, and calling its own methods are part of the contract's internal logic. On the other hand, read and write requests to the ledger (blockchain) and inter-contracts calls are all external interactions. In summary, external interactions in Hathor encompass the combination of environmental reads and external calls in the EVM model.
The Blueprint
class provides an object named syscall
for performing all possible external interactions. And since every blueprint inherits from the Blueprint
class, this object is part of the common behavior inherited by all blueprints.
Currently, the blueprint SDK allows the following external interactions:
- Reading the contract’s own ID, blueprint, and multi-token balance of any contract (its own or another).
- Manage and use its own token authorities.
- Calling public and view methods of other contracts.
- Creating tokens and contracts.
- Obtaining an RNG (random number generator).
For how to use these external interactions see Blueprint.syscall
at Blueprint SDK — API .
Oracles
Hathor provides built-in support to oracles. An oracle is identified by a TxOutputScript
, which is used to ensure that only the oracle can successfully execute a specific public method. To implement a public method for an oracle, use TxOutputScript
and SignedData
from the Blueprint SDK API. For example, the bet blueprint has the public method set_result
:
@public
def set_result(self, ctx: Context, result: SignedData[Result]) -> None:
"""Set final result. This method is called by the oracle."""
self.fail_if_result_is_available()
if not result.checksig(self.oracle_script):
raise InvalidOracleSignature
self.final_result = result.data
By comparing the data of the NC transaction that executed the contract (i.e., called the set_result
method) with the oracle_script
, the method ensures that final_result
can only be set by the oracle. In a nutshell, any user can call the method through a valid NC transaction, but the execution will only be successful if it the transaction was signed by the entity defined as the oracle.
This covers the blueprint side of the oracles support. As for the client part, the SignedData
object needs to be properly assembled. This can be done using the headless wallet. For how to do this, see the Nano contracts with headless tutorial.
Constraints
Some nano contract constraints you need to consider are not unique to Hathor. Rather, they are inherent to the nature of smart contracts in general and are shared across all smart contract platforms. In the next subsections, we’ll look at the most important ones.
Atomic behavior
When modeling your blueprint, remember that the execution of a nano contract has atomic behavior. If the execution fails, nothing happens. If successful, all changes to the contract’s attributes occur, and the entire batch of actions is executed, thus changing the contract’s balance.
For example, suppose a nano contract ABC
has a method abc
. During its execution, abc
changes the values of several of its attributes. If abc
executes to completion without exceptions, Hathor engine will execute all the deposits and withdrawals. Thus, the state of the ABC
contract will be updated.
On the other hand, if an non-handled exception occurs during the execution of abc
, all attribute changes are discarded, none of the actions are performed, and the contract's state does not change.
Passive behavior
Like conventional smart contracts, nano contracts are passive. They only execute in the context of a call chain triggered by a transaction validated on the blockchain. Event-driven logic cannot be implemented in blueprints and must instead be implemented off-chain, in components of integrated systems.
What's next?
- Blueprint SDK — API: to consult the API provided by the blueprint SDK for developers.
- Develop a blueprint — part 1: hands-on tutorial to assist developers to conceive and design a blueprint.
- Develop a blueprint — part 2: hands-on tutorial to assist developers to implement and test a blueprint.
- Set up a localnet: for integration testing of blueprints, nano contracts, and DApps.