SmartPy Tutorial
SmartPy is a language designed for writing smart contracts on the Tezos blockchain. If you're not entirely sure what a smart contract is, don't worry. This tutorial is the ideal place to learn about them. It will guide you through the development of smart contracts using SmartPy's intuitive Python-like syntax. You'll be quickly on your way to testing and deploying your first smart contract on the Tezos blockchain!
There are no strict prerequisites for this tutorial, because it begins from the very basics. However, basic familiarity with programming, preferably in Python, would be beneficial.
First steps
The anatomy of a smart contract
Let's start by examining a simple example of a SmartPy contract that stores a number and allows users to add to it:
class Adder(sp.Contract):
def __init__(self):
self.data.sum = 0
@sp.entrypoint
def add(self, x):
self.data.sum += x
To start with, we can see that a smart contract is implemented in SmartPy as a Python class inheriting from sp.Contract
. Furthermore, the class has an __init__
method, and another method called add
.
The __init__
method initialises the field self.data.sum
to 0
. This is the initial value that the field will hold when the contract is deployed to the Tezos blockchain. A smart contract can store any number of values, but they must all be initialised in the __init__
method.
The add
method, marked as an entrypoint using the decorator sp.entrypoint
, can be invoked from outside the contract. This interaction is typically initiated by a human interacting with the blockchain (e.g. through a "wallet") or by another contract. In our example, the entry point merely adds its parameter x
to the sum maintained in self.data.sum
.
Only the smart contract itself can change its storage; users and other contracts cannot access our contract's storage directly. Therefore, after deployment, the only way to change the number in the storage is to call the add
entrypoint.
Simulating contracts in SmartPy
Before deploying our contract to the Tezos blockchain, let's test it. This step is crucial in smart contract development because the code of a contract becomes unchangeable when it's deployed.
Here is the previous example included in a SmartPy module, along with a test:
import smartpy as sp
@sp.module
def main():
class Adder(sp.Contract):
def __init__(self):
self.data.sum = 0
@sp.entrypoint
def add(self, x):
self.data.sum += x
@sp.add_test()
def test():
s = sp.test_scenario("my first test", main)
a = main.Adder()
s += a
a.add(2)
a.add(3)
s.verify(a.data.sum == 5)
Let's dissect this example step by step:
Firstly, we import the
smartpy
library, which is conventionally abbreviated as sp.A SmartPy module,
main
, is then defined using the decoratorsp.module
. This indicates that the following code is written in SmartPy, as opposed to ordinary Python. Among other things, this means that the code contained in this block will be type-checked. More on this later.A module can contain one or several definitions. In this instance, the sole definition is that of the contract class
Adder
, which we've already discussed in the previous section.Next, we add a test, designated as such by the decorator
sp.add_test
. Inside this test,s = sp.test_scenario(main)
creates a test scenario, which simulates the Tezos environment for testing purposes. Thena = main.Adder()
instantiates the contract, ands += a
adds it to the scenario. Note that we writemain.Adder
becauseAdder
has been defined inside themain
module. We also have to give main as an argument to sp.test_scenario.Then, we interact with the adder contract: firstly, we call the entrypoint add with the argument 2, then a second time with the argument 3.
Finally, we test the state of the contract's storage by verifying that it is 5.
Simulation results
SmartPy provides a convenient online IDE (integrated development environment) that enables you to write smart contracts and test them directly in your browser. It presents the simulation results in a user-friendly manner.
🚀 Try it out!
Head over to smartpy.io/ide and paste the above code snippet into the code editor. Then click the button. The result should resemble something like this:
The simulation results show the following elements:
The first one corresponds to the line
s += a
and looks like this: This simply records the instantiation of a contract.Next, the first entrypoint call
a.add(2)
yields: It shows that the argument was2
and that the new contract storage now contains a new value forsum
(this corresponds toself.data.sum
in the source code), namely2
.The second call
a.add(3)
then results in: As expected,sum
now is5
.
INFO
SmartPy compiles to Michelson, the native, low-level language of Tezos. To view the compiled code, click on Deploy Michelson Contract and then Code on the right-hand side of the browser window. You should see something like this:
Knowledge of Michelson is not necessary to work with SmartPy, but be aware that SmartPy does not always behave the same as Python because of the limitations of Michelson.
Summary
Before moving on to the next part, let's summarise what we've learned so far:
In SmartPy, a contract is represented as a Python class inheriting from
sp.Contract
.A contract can have one or several entrypoints, each marked as
sp.entrypoint
. After a contract has been deployed, its entrypoints cannot be altered. The blockchain ensures that the contract evolves only according to the rules specified in its code.A smart contract's storage is contained in fields of
self.data
, such asself.data.sum
. Contracts can have any amount of data in their storage, consisting of many different primitive or complex data types. After a contract has been deployed, only its own code can change its storage.
A simple token
Now that you are familiar with the basics, let's write a slightly more interesting contract. In this part, we're going to make a new currency, which we'll call the "Ducat".
WARNING
The smart contracts presented in this section are meant to illustrate basic concepts and we do not recommend using them in production. For real-world applications, please have a look at FA2 lib.
The contract
A common way to implement a new currency is by using a smart contract that remembers the number of tokens (here Ducats) owned by each participant. This is akin to a bank maintaining balances (number of Ducats) for current accounts.
To start, we'll create a module with a contract and its constructor:
import smartpy as sp
@sp.module
def main():
class Ducat(sp.Contract):
def __init__(self, admin):
self.data.balances = {}
self.data.admin = admin
Here balances
is a map, which associates a balance to each address. Initially it is empty, indicating that nobody owns any Ducats. Then there is an admin
address that has special powers, namely it is allowed to make new Ducats, i.e. mint them.
Before we write the transfer
and mint
entrypoints, we need two auxiliary methods. The first one increases the balance in a given account by a specified amount:
@sp.private(with_storage="read-write")
def increase_balance(self, account, amount):
if self.data.balances.contains(account):
self.data.balances[account] += amount
else:
self.data.balances[account] = amount
The code first checks whether the account has an entry in the ledger. If so, the amount is added to the balance in the entry. If no entry exists yet, a new one is added and initialised to the amount.
The method has the with_storage="read-write"
decorator, which enables it to read and write data in the contract's storage.
The second auxiliary method decreases the balance by the specified amount:
@sp.private(with_storage="read-write")
def decrease_balance(self, account, amount):
b = self.data.balances[account] - amount
assert b >= 0
if b == 0:
del self.data.balances[account]
else:
self.data.balances[account] = b
Here two points are noteworthy:
If the balance in the account is insufficient the
assert b >= 0
statement throws an error and cancels the transaction.To save storage space, the method removes the account from the ledger if its balance reaches 0.
We can now write the entrypoints of our Ducat contract. First, there is a transfer
entrypoint, which transfers Ducats from one account to another:
@sp.entrypoint
def transfer(self, params):
assert params.amount >= 0
self.decrease_balance(sp.record(account=sp.sender, amount=params.amount))
self.increase_balance(sp.record(account=params.dest, amount=params.amount))
This entrypoint uses the two previously defined auxiliary methods. A few things are noteworthy here:
To get the address of the sender, it uses the built-in variable
sp.sender
, which is the account that called the entrypoint.If the sending account has an insufficient balance,
decreaseBalance
fails. On Tezos if any part of an operation fails, the entire transaction is aborted and the contract is rolled back to its state before the transaction started. This means that even callingincrease_balance
beforedecrease_balance
would not yield inconsistent results in this case.We use record syntax to pass multiple parameters to the auxiliary functions.
Finally, as mentioned previously, the administrator is allowed to mint new coins. This is implemented by the following entrypoint:
@sp.entrypoint
def mint(self, params):
assert sp.sender == self.data.admin
self.increase_balance(sp.record(x=params.dest, amount=params.amount))
The assert
statement ensures that the operation fails if anyone other than the administrator attempts to call this entrypoint. The newly minted amount is assigned to the account given as a parameter.
The test scenario
Now that we have written the smart contract for Ducats, let's add a test scenario. The test scenario is important not just for testing but to compile the Michelson output.
To begin with, we define three accounts admin
, alice
, and bob
:
@sp.add_test()
def test():
admin = sp.test_account("Admin").address
alice = sp.test_account("Alice").address
bob = sp.test_account("Bob").address
Then, as previously, we define a test scenario and instantiate our contract:
s = sp.test_scenario("Ducat test", main)
c = main.Ducat(admin)
s += c
To start off, we have the administrator mint 5 ducats each for Alice and Bob.
c.mint(dest=alice, amount=5, _sender=admin)
c.mint(dest=bob , amount=5, _sender=admin)
Adding _sender=admin
to an entrypoint call informs SmartPy that the transaction came from the admin account.
For testing purposes, we verify that the ledger indeed contains these sums:
s.verify(c.data.balances[alice] == 5)
s.verify(c.data.balances[bob] == 5)
Next, Bob transfers 3 tokens to Alice. Again, we verify the resulting balances:
c.transfer(dest=alice, amount=3, _sender=bob)
s.verify(c.data.balances[alice] == 8)
s.verify(c.data.balances[bob] == 2)
Finally, let's see what happens if we do something unauthorised. Here Bob is trying to send 3 ducats to Alice, even though there are only 2 ducats in his account. To indicate that the operation is expected to fail we supply _valid=False
to the entrypoint call:
c.transfer(dest=alice, amount=3, _sender=bob, _valid=False)
To test another unauthorised transaction, let's verify that Bob can't mint new ducats:
c.mint(dest=bob, amount=100, _sender=bob, _valid=False)
For reference, here is the complete code of the Ducat smart contract and the test scenario:
import smartpy as sp
@sp.module
def main():
class Ducat(sp.Contract):
def __init__(self, admin):
self.data.balances = {}
self.data.admin = admin
@sp.private(with_storage="read-write")
def increase_balance(self, account, amount):
if self.data.balances.contains(account):
self.data.balances[account] += amount
else:
self.data.balances[account] = amount
@sp.private(with_storage="read-write")
def decrease_balance(self, account, amount):
b = self.data.balances[account] - amount
assert b >= 0
if b == 0:
del self.data.balances[account]
else:
self.data.balances[account] = b
@sp.entrypoint
def transfer(self, params):
self.decrease_balance(sp.record(account=sp.sender, amount=params.amount))
self.increase_balance(sp.record(account=params.dest, amount=params.amount))
@sp.entrypoint
def mint(self, params):
assert sp.sender == self.data.admin
self.increase_balance(sp.record(account=params.dest, amount=params.amount))
@sp.add_test()
def test():
admin = sp.test_account("Admin").address
alice = sp.test_account("Alice").address
bob = sp.test_account("Bob").address
s = sp.test_scenario("Ducat test", main)
c = main.Ducat(admin)
s += c
c.mint(dest=alice, amount=5, _sender=admin)
c.mint(dest=bob , amount=5, _sender=admin)
s.verify(c.data.balances[alice] == 5)
s.verify(c.data.balances[bob] == 5)
c.transfer(dest=alice, amount=3, _sender=bob)
s.verify(c.data.balances[alice] == 8)
s.verify(c.data.balances[bob] == 2)
c.transfer(dest=alice, amount=3, _sender=bob, _valid=False)
c.mint(dest=bob, amount=100, _sender=bob, _valid=False)
🚀 Try it out!
As before, you can go to smartpy.io/ide and paste the above code snippet into the code editor. Then click the button and examine the results.
You can also remove the valid=False
from the last two entrypoints and see what happens.
Further resources
Congratulations on concluding this tutorial! As you have seen, SmartPy makes writing and testing smart contracts a breeze. However, we've barely scratched the surface of what is possible. Here are some further resources to accompany you on your SmartPy journey:
Latest news and updates on Twitter: @smartpy_io
The SmartPy forum: a friendly place to ask questions and get support
The SmartPy manual