Skip to content
On this page

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:

SmartPy
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:

python
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 decorator sp.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. Then a = main.Adder() instantiates the contract, and s += a adds it to the scenario. Note that we write main.Adder because Adder has been defined inside the main 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 was 2 and that the new contract storage now contains a new value for sum (this corresponds to self.data.sum in the source code), namely 2.

  • The second call a.add(3) then results in: As expected, sum now is 5.

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 as self.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:

python
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:

SmartPy
@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:

SmartPy
@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:

SmartPy
@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 calling increase_balance before decrease_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:

SmartPy
@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:

python
@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:

python
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.

python
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:

python
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:

python
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:

python
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:

python
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:

python
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: