Blueprint SDK — guidelines
Introduction
This article presents guidelines for designing and implementing blueprints in Python v3.11.
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 v3.11 code as usual.
Module
A blueprint must be implemented as a single Python module — namely, a single file my_blueprint.py
.
Imports
You must use import statements with the syntax from x import y
; import x
is not allowed. For example:
# Allowed syntax:
from hathor.nanocontracts.context import Context
# Not allowed syntax:
import hathor.nanocontracts.context
Furthermore, 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, Union
from collections OrderedDict
# 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 should 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, add the statement__blueprint__ = MyBlueprint
somewhere in your module after definingMyBlueprint
.
The following snippet presents an example of on-chain blueprint module that complies 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_id: 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)
Standard library types:
- NamedTuple
- Optional[T]
- OrderedDict[K, T]
- frozenset[T]
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 containers:
...
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]
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 to fully parameterize.
more_nested_and_correct: dict[tuple[Address, str], Amount]
# This is also valid, to understand it 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 keywords
The following snippet provides an exhaustive list of keywords that are not allowed for use:
"""
Forbiden keywords List:
- exec
- try
- except
- finally
- async
- await
"""
try-except
blocks are not supported. In the future, there should be limited support for them.
Asynchronous operations are not allowed.
Forbidden names
Special method names — i.e., any name that begins and ends with double underscores (e.g., __init__
, __str__
, __dict__
) — are not allowed. Furthermore, the following snippet provides an exhaustive list of all other forbidden names:
"""
Forbiden names List:
- compile
- delattr
- dir
- eval
- getattr
- globals
- hasattr
- input
- locals
- open
- setattr
- vars
"""
Reserved names
The following snippet provides an exhaustive list of names reserved by the SDK within your blueprint class. 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 attribute assignments and method declarations. For example:
syscall = 123 # NOT ALLOWED
def syscall(): pass # NOT ALLOWED
self.syscall = 123 # NOT ALLOWED
However, you may (and should) still use (access) these names via 'dot notation'. For example:
# Your blueprint class:
class MyBlueprint(Blueprint):
...
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 should 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
- bool
- bytes
- list[T]
- set[T]
- dict[K, T]
- tuple[T, K, V, ...] (variable length)
Standard library types:
- NamedTuple *For how to use it, see next section.
- Optional[T]
- OrderedDict[K, T]
- frozenset[T]
hathor.nanocontracts package types:
- Address
- Amount
- BlueprintId
- ContractId
- TxOutputScript
- 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.
Class instances
If you want to define a class and use it as a data type for attributes, you must use NamedTuple
. For example:
class MyClass(NamedTuple):
a: str
b: int
class MyBlueprint(Blueprint):
attr1: MyClass # CORRECT
You cannot use regular class instances as attribute types without subclassing NamedTuple
. For example:
class MyClass:
a: str
b: int
class MyBlueprint(Blueprint):
attr1: MyClass # INCORRECT
Finnaly, you cannot directly use NamedTuple
. For example:
class MyBlueprint(Blueprint):
attr1: NamedTuple # INCORRECT
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 and fallback
, 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, internally by any other method, and by any method from other contracts. 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. Additionally, they can call public methods but if and only if they were called by a public method in first place — e.g., public_method > internal_method > public_method
.
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 fail.
The following snippet presents an example of valid calls between methods in a blueprint:
class FooBar(Blueprint):
...
@public
def initialize(self, ctx: Context, <other_params>) -> None:
...
# Some attribute of this blueprint
self.dummy = 0
...
@public
def foobar(self, ctx: Context, <other_params>) -> 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. For the exhaustive list of the allowed types, see Allowed value types. - Public and view methods cannot have parameters
*args
and**kwargs
.
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
Fallback method must always have the following signature:
fallback(self, ctx: Context, method_name: str, nc_args: NCArgs) -> Any
You cannot change its parameters; you can only define its return.
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. For the exhaustive list of the allowed types, see Allowed value types.
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 blueprint ID, attributes, and 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 interaction.
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 raise 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 raising an exception. Otherwise, it proceeds to the end.
Then, Hathor engine processes the method’s return. If it raises 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 to 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.
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 .
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 a 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.