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, as 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:
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.
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
.
Simulating contracts in SmartPy
Before deploying our contract to the Tezos blockchain, let's test it. In general, this step is crucial in smart contract development, as the code of a contract becomes unchangeable once 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)
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 scenario. 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.Finally, we interact with the adder contract: firstly, we call the entrypoint add with the argument 2, then a second time with the argument 3.
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.
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
. Once 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
.
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 is for increasing the balance in a given account x
by amount
:
@sp.private(with_storage="read-write")
def increase_balance(self, x, amount):
if self.data.balances.contains(x):
self.data.balances[x] += amount
else:
self.data.balances[x] = amount
The code first checks whether x
already has an entry in the ledger. If so, amount
is simply added to the balance in the entry. If no entry exists yet, a new one is added and initialised to amount
.
The second auxiliary function decreases the balance:
@sp.private(with_storage="read-write")
def decrease_balance(self, x, amount):
b = self.data.balances[x] - amount
assert b >= 0
if b == 0:
del self.data.balances[x]
else:
self.data.balances[x] = b
Here two points are noteworthy:
If the balance in the account is insufficient the
assert b >= 0
statement will throw an error.Special care is taken to remove the account from the ledger if its balance reaches
0
.
We can now get to the entrypoints of our Ducat contract. First, there is a transfer
entrypoint, which allows transferring Ducats from one account to another:
@sp.entrypoint
def transfer(self, params):
assert params.amount >= 0
self.decrease_balance(sp.record(x=sp.sender, amount=params.amount))
self.increase_balance(sp.record(x=params.dest, amount=params.amount))
This uses the two previously defined auxiliary functions. A few things are noteworthy here:
The sender here is taken from
sp.sender
, which is defined as the account calling 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. This means that even callingincrease_balance
beforedecrease_balance
would not yield inconsistent results in this case.We use record syntax to call auxiliary functions with several parameters.
Finally, as mentioned previously, the admin
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 admin
attempts to call this entrypoint. The newly minted amount is assigned to the account given as a parameter.
A test scenario
Now that we have written the smart contract for Ducats, let us add a test scenario. To begin with, we simply 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 admin
mint 5 ducats for each of 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 call should be executed 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, we let Bob transfer 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)
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, x, amount):
if self.data.balances.contains(x):
self.data.balances[x] += amount
else:
self.data.balances[x] = amount
@sp.private(with_storage="read-write")
def decrease_balance(self, x, amount):
b = self.data.balances[x] - amount
assert b >= 0
if b == 0:
del self.data.balances[x]
else:
self.data.balances[x] = b
@sp.entrypoint
def transfer(self, params):
self.decrease_balance(sp.record(x=sp.sender, amount=params.amount))
self.increase_balance(sp.record(x=params.dest, amount=params.amount))
@sp.entrypoint
def mint(self, params):
assert sp.sender == self.data.admin
self.increase_balance(sp.record(x=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