DEV Community

Cover image for Writing smart contracts in ink!
Bekka
Bekka

Posted on • Updated on

Writing smart contracts in ink!

If you are looking for a soft landing into polkadot and you don't want to bother about writing pallets yet while still enjoying solidity's style of writing smart contract. Then you have come to the right place.

In this article, you will learn how to write smart contracts in polkadot and deploy the smart contract on a Testnet.

Prerequisites

You need to know the following to be able to follow this tutorial.

  • Some programming knowledge working with structures.

  • Experience working with solidity but not compulsory.

  • Some programming knowledge on rust.

  • A polkadot account, if you don't have one you can follow this guide to create one here

With that out of the way, let's start coding.

We are going to build a simplified burger shop using ink smart contracts.

What is Ink! programming language?

Ink! is a safe, secure, and efficient language for writing smart contracts. It is based on the Rust programming language, which is known for its safety and security features.

Ink! smart contracts are also portable, which means that they can be deployed on any blockchain that supports WebAssembly.

Setting up

To run contracts, instantiate contracts and more, we need to download the Ink! CLI. Open your terminal and run rustup component add rust-src.
After the process finishes, run cargo install --force --locked cargo-contract.
To verify the package is installed in your pc, run cargo contract -V or cargo contract --version. You also run cargo contract --help to see other available commands.

Next, we install substrate-contracts-node. This is a simple substrate blockchain configured for smart contract functionality using contracts-pallets. You can use this instead of a Testnet, we will cover this in another article.
You could install the binary which is faster. This is supported for linux and Mac systems.

You could also install it using cargo instead, by running cargo install contracts-node --git https://github.com/paritytech/substrate-contracts-node.git --tag v0.23.0 --force --locked.
To verify that it is downloaded, run substrate-contracts-node -V also to check for other commands run substrate-contracts-node --help.

What are pallets?

Substrate framework is the framework used in building blockchains in polkadot which comprises of different modules. These modules make up different parts of a blockchain from the FRAME, Consensus Engines, Primitives, Libraries, etc.

FRAME stands for "Framework for Runtime Aggregation of Modularized Entities." It is designed to enable flexibility and composability in blockchain development, allowing developers to pick and choose different modules to include in their custom blockchain's runtime logic. Borrowing some of the artistic term of pallets used for drawing, I assume you know about colour pallets used in drawing. You can think of substrate pallets as different tools available for you to build the blockchain you want. For example, the substrate-contracts-node comprises of pallets configured for smart contracts. You could build a blockchain tailored for a social media platform or a staking platform.

Building the project

Ink! smart contracts relies on the use of macros which are code that generate code to achieve some function. We will use macros while working on this project, don't worry. You won't need to write a custom macro.

Let's create our project, we'll call it "burger_shop", in your project's directory, run cargo contract new burger_shop.
This should create a project and generate an ink! smart contract template. I encourage to play around or read the code first, to try to understand what's going on. You'll notice there are contract calls, tests and integration tests. We'll write our own contract calls and tests for our smart contract. To keep this tutorial short, tests will be covered in another article.

Next, let's update our .toml file with the necessary deps, copy this code.

[package]
name = "burger_shop"
version = "0.1.0"
authors = ["Ayomide Bajo <oluwashinabajo@gmail.com>"] ## replace with your name and email
edition = "2021"

[dependencies]
ink = { version = "4.0.1", default-features = false }

scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["derive"] }
scale-info = { version = "2.3", default-features = false, features = ["derive"], optional = true }

[dev-dependencies]
ink_e2e = "4.0.1"

[lib]
path = "lib.rs"

[features]
default = ["std"]
std = [
    "ink/std",
    "scale/std",
    "scale-info/std",
]
ink-as-dependency = []
e2e-tests = []
Enter fullscreen mode Exit fullscreen mode

Setting up the storage

Our smart contract needs a storage to keep all the data coming from the shop, this storage will contain orders from customers.

Firstly, let's do some imports, we'll need them, so it's better to get that out of the way.

Delete everything, with mod braces, (trust me, I didn't waste your time, lol). Your file should look like this.
empty file
Add the following code within mod braces.


    use ink::prelude::vec::Vec;
    use ink::prelude::format;
    use ink::storage::Mapping;
    use scale::{Decode, Encode};
Enter fullscreen mode Exit fullscreen mode

We imported from the prelude module, this includes the types, we usually use with std that are also supported for no_std compilations. We also imported from the storage module which is the storage type for storing our data. Also the scale crate for serializations properties and conversions.

Now we can setup our first storage.
Copy this code block.


   #[ink(storage)]
    pub struct BurgerShop {
        orders: Vec<(u32, Order)>,
        orders_mapping: Mapping<u32, Order>,
    }
Enter fullscreen mode Exit fullscreen mode

If you notice, there is an attribute #[ink(storage)], this tells the compiler that the data structure is going to be entering the ink! storage. It also contains fields, I assume you know Vec already. There's an interesting type Mapping. This type is for storing key-value pairs, like hashmaps and it's quite different from hashmaps, you can read more here

Next we are going to create the types that will be used in interacting with the storage in our smart contract.

If, you're using rust-analyzer on your Vscode. You might be seeing some errors, saying something about a missing constructor, we will fix that soon! I recommend deactivating it for now so that it won't distract you.

Copy this code block.

// The order type
  #[derive(Encode, Decode, Debug, Clone)]
    #[cfg_attr(
        feature = "std",
        derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout)
    )]
    pub struct Order {
        list_of_items: Vec<FoodItem>,
        customer: AccountId,
        total_price: Balance,
        paid: bool,
        order_id: u32,
    }

  // Food Item type, basically for each food item
    #[derive(Encode, Decode, Debug, Clone)]
    #[cfg_attr(
        feature = "std",
        derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout)
    )]
    pub struct FoodItem {
        burger_menu: BurgerMenu,
        amount: u32,
    }

// Burger Type
    #[derive(Encode, Decode, Debug, Clone)]
    #[cfg_attr(
        feature = "std",
        derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout)
    )]
    pub enum BurgerMenu {
        CheeseBurger,
        ChickenBurger,
        VeggieBurger,
    }
Enter fullscreen mode Exit fullscreen mode

There are a bunch of attributes derived for the types we've created, #[derive(Encode, Decode, Debug, Clone)] (skipping the other traits in rust). This attribute derives the following traits, Encode and Decode. Encode makes the type encodable (i.e can be hashed) and Decode makes type decodable (i.e can be decoded for interaction in the frontend and other operations). The cfg_attr also does some derivations, in here, scale_info::TypeInfo derives the TypeInfo trait that does some serializations like serde, this helps to compile the struct Order(for example) into a Portable form. The ink::storage::traits::StorageLayout type implements StorageLayout trait, which will allow the compiler to add the type(s) into storage.

Also, notice we have types Balance and AccountId. These are special types indicating the value in tokens to be sent and the address/account of the caller, respectively. These are the expected types for our operations. You can read more about them here

We created the following types for the following reasons:

  • Order: This type is for each order a customer creates to buy something from the shop(which is burger in the case), it also contains the total_price for the order and other fields.

  • FoodItem: This type is for each food item stored in each order, it also contains the amount of food items.

  • BurgerMenu: This is for each type of burger available in our shop.

We are going to implement some methods for our types, as you have noticed our smart contract follows rust's standard way of writing structures and code, plus you still get to enjoy solidity's style of writing smart contract, isn't that wonderful?😀
Copy the next code blocks and put them in their appropriate positions like this.

// derive attributes skipped for brevity sake
pub struct Order {
list_of_items: Vec<FoodItem>,
// other code here...
}

impl Order {
        fn new(list_of_items: Vec<FoodItem>, customer: AccountId, id: u32) -> Self {
            let total_price = Order::total_price(&list_of_items);
            Self {
                list_of_items,
                customer,
                total_price,
                paid: false,
                order_id: id, // Default is "getting ingredients" in this case
            }
        }

        fn total_price(list_of_items: &Vec<FoodItem>) -> Balance {
            let mut total = 0;
            for item in list_of_items {
                total += item.price()
            }
            total
        }
    }
Enter fullscreen mode Exit fullscreen mode
// derive attributes skipped for brevity sake
pub struct FoodItem {
burger_menu: BurgerMenu,
// other code here...
}

 impl FoodItem {
        fn price(&self) -> Balance {
            match self.burger_menu {
                BurgerMenu::CheeseBurger => BurgerMenu::CheeseBurger.price() * self.amount as u128,
                BurgerMenu::ChickenBurger => {
                    BurgerMenu::ChickenBurger.price() * self.amount as u128
                }
                BurgerMenu::VeggieBurger => BurgerMenu::VeggieBurger.price() * self.amount as u128,
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

//you already guessed it, attributes skipped here😀
 pub enum BurgerMenu {
 CheeseBurger,
// Yup! other code here...
}

 impl BurgerMenu {
        fn price(&self) -> Balance {
            match self {
                Self::CheeseBurger => 12,
                Self::VeggieBurger => 10,
                Self::ChickenBurger => 15,
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Here, we set the necessary methods, for tracking prices for each burger on the menu and for food item(s) when we are adding them into storage.

Setting up events, creating error types and result type.

Events are very important in your code, especially when you want to query informations about the contract's executions and current state. We will setup different event types for our smart contract. We will also use Ink! attributes for setting up our event types. Copy the following code.


  /// Event emitted when a token transfer occurs.
    #[ink(event)]
    pub struct Transfer {
        #[ink(topic)]
        from: Option<AccountId>,
        #[ink(topic)]
        to: Option<AccountId>,
        value: Balance,
    }

    /// Event when shop owner get all orders in storage
    #[ink(event)]
    pub struct GetAllOrders {
        #[ink(topic)]
        orders: Vec<(u32, Order)>,
    }

    /// Event when shop owner gets a single order
    #[ink(event)]
    pub struct GetSingleOrder {
        #[ink(topic)]
        single_order: Order,
    }

    /// Event when the shop_owner creates his shop
    #[ink(event)]
    pub struct CreatedShopAndStorage {
        #[ink(topic)]
        orders: Vec<(u32, Order)>, // this only contains a vector because `Mapping` doesn't implement "encode" trait, this means you can't encode or decode it for operational purposes, it also means you can't return `Mapping` as a result for your contract calls
    }
Enter fullscreen mode Exit fullscreen mode

We also want to handle errors by returning custom error types.


// For catching errors that happens during shop operations
    #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
    #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
    pub enum BurgerShopError {
        /// Errors types for different errors.
        PaymentError,
        OrderNotCompleted,
    }
Enter fullscreen mode Exit fullscreen mode

Now we can finally create our result type.

 // result type
    pub type Result<T> = core::result::Result<T, BurgerShopError>;
Enter fullscreen mode Exit fullscreen mode

Writing smart contract call functions

We've set up the necessary types that we will need, now we can start writing functions that will interact with our smart contract.

Let's implement the function that instantiates the smart contract.


 impl BurgerShop {
        #[ink(constructor)]
       pub fn new() -> Self {
            let order_storage_vector: Vec<(u32, Order)> = Vec::new();
            let order_storage_mapping = Mapping::new();

            Self {
                orders: order_storage_vector,
                orders_mapping: order_storage_mapping,
            }
        }
}
Enter fullscreen mode Exit fullscreen mode

If you noticed, there's a new attribute, this attribute basically flags the function as a special function for instantiating the smart contract, a smart contract must have at least one constructor. You can also have multiple constructors. There are also list of other attributes, we will go through the ones we use for this project, I recommend you check the others too!

Next, let's implement the function that takes in an order and also accepts payment.


   /// takes the order and makes the payment, we aren't implementing cart feature here for simplicity purposes, ideally the cart feature should be implemented in the frontend
        #[ink(message, payable)]
        pub fn take_order_and_payment(&mut self, list_of_items: Vec<FoodItem>) -> Result<Order> {
            // Gets the caller account id
            let caller = Self::env().caller();

            // this is assertion is opinionated, if you don't want to limit the shop owner from creating an order, you can remove this line
            assert!(
                caller != self.env().account_id(),
                "You are not the customer!"
            );

            // assert the order contains at least 1 item
            for item in &list_of_items {
                assert!(item.amount > 0, "Can't take an empty order")
            }

            // our own local id, you can change this to a hash if you want, but remember to make the neccessary type changes too!
            let id = self.orders.len() as u32;

            // Calculate and set order price
            let total_price = Order::total_price(&list_of_items);
            let mut order = Order::new(list_of_items, caller, id);
            order.total_price = total_price;

            assert!(
                order.paid == false,
                "Can't pay for an order that is paid for already"
            );

            let multiply: Balance = 1_000_000_000_000; // this equals to 1 Azero, so we doing some conversion
            let transfered_val = self.env().transferred_value();

            // assert the value sent == total price
            assert!(
                transfered_val
                    == order
                        .total_price
                        .checked_mul(multiply)
                        .expect("Overflow!!!"),
                "{}", format!("Please pay complete amount which is {}", order.total_price)
            );

            ink::env::debug_println!(
                "Expected value: {}",
                order.total_price
            );
            ink::env::debug_println!(
                "Expected received payment without conversion: {}",
                transfered_val
            );  // we are printing the expected value as is

            // make payment
            match self
                .env()
                .transfer(self.env().account_id(), order.total_price)
            {
                Ok(_) => {
                    // get current length of the list orders in storage, this will act as our unique id
                    let id = self.orders.len() as u32;
                    // mark order as paid
                    order.paid = true;

                    // Emit event
                    self.env().emit_event(Transfer {
                        from: Some(order.customer),
                        to: Some(self.env().account_id()),
                        value: order.total_price,
                    });

                    // Push to storage
                    self.orders_mapping.insert(id, &order);
                    self.orders.push((id, order.clone()));
                    Ok(order)
                }
                Err(_) => Err(BurgerShopError::PaymentError),
            }
        }
Enter fullscreen mode Exit fullscreen mode

We see different attributes message and payable. The message attribute indicates that the function can be called publicly, notice pub keyword too. Any function(contract call) that implements a message attribute, implements a special functionality that allows them to be called by users.

While payable means the contract call will receive some value as part of the call, you can also verify the value you're expecting, we did this here in this code block.


let multiply: Balance = 1_000_000_000_000;
let transfered_val = self.env().transferred_value();
  assert!(
                transfered_val
                    == order
                        .total_price
                        .checked_mul(multiply)
                        .expect("Overflow!!!"),
                "{}", format!("Please pay complete amount which is {}", order.total_price)
            );
Enter fullscreen mode Exit fullscreen mode

The value that will be sent will come from the frontend or the contracts UI. In this article, we will interact with the UI to test our smart contract.

We used self a lot here. This refers to the smart contract itself, it contains a lot of rich methods and types including the ones we created in our storage struct.

The env method is one of the most used methods, it's basically the smart contract environment, you have access to things like account_id of the smart contract, balance which means you can transfer native token to your smart contract. Also, the caller returns the address of who is actually calling the contract, in our case, whenever a customer makes an order, we have access to the customer's address.

Whoo! that was a lot! If you've come this far, Congratulations🎉 you're half way, already!

Next up, we are adding the remaining functions for our burger shop.


  #[ink(message)]
        /// gets a single order from storage
        pub fn get_single_order(&self, id: u32) -> Order {
            // get single order
            let single_order = self.orders_mapping.get(id).expect("Oh no, Order not found");

            single_order
        }

        #[ink(message)]
        /// gets the orders in storage
        pub fn get_orders(&self) -> Option<Vec<(u32, Order)>> {
            // Get all orders
            let get_all_orders = &self.orders;

            if get_all_orders.len() > 0 {
                Some(get_all_orders.to_vec()) // converts ref to an owned/new vector
            } else {
                None
            }
        }
Enter fullscreen mode Exit fullscreen mode

Now, we are done with code. Go ahead, format with this command cargo fmt, compile with this command cargo contract build.

Check your target folder, you should see this. You can check the source code here

ink! target folder

Deploying our smart contract and interaction

Ink! compiles down to WASM, which means it can be deployed on any platform that supports WASM, it also means you can do some embedded software development😉 that could call smart contract at the end of the program. So many possibilities here!😃

To test our smart contract, we will be using the contracts ui. We will be using the .contract file, the contract ui supports this file type, it contains all our compiled code.

For you to deploy you contract, you need to use faucet tokens. In this tutorial, we'll be using Aleph zero tokens, they also have a really reliable testnet🤗. You can head to their website to get some faucet tokens. Since you're using polkadot address, you can use aleph zero and yes, you can see your tokens here.

Now, let's deploy our smart contract like this. Notice the drop down at the top left corner of your screen, it contains different test nets available in polkadot, in this case we are using aleph zero testnet, so choose that like this screenshot here.

deploying smart contract on aleph zero testnet

Then upload your .contract file into the uploader.

Click on Next

Image description

Notice the page contains all the functions we talked about, especially the public ones.

Upload and instantiate your contract

upload

instantiate

It should ask for your password, it interacts with your polkadot wallet (assuming you have the extension installed on your browser)

Interacting with our smart contract

Let's create a new order. I love chicken, so I'm gonna order 1 chicken burger, without adding any amount.

contracts ui

Notice the error on the right, polkadot blockchains also have the power to check if the outcome of a transaction is going to successful without signing the transaction, It's called "DryRun". You can see our assertion fails and it tells you what line this is located. Let's fix this by adding an amount.

Now we have a different error.

contracts ui

Our assertion fails here. Remember I talked about how to assert the price of the burger is actually inputed in the UI. This is where you input the price you're supposed to pay for a transaction. You can see that if the value isn't up to the total_price, it returns an error.

Let's fix this by inputing the amount that is expected, the error also tells us the expected value.

contract ui

We get a successful outcome, you can go ahead and click thecall contract button below. You'll also be asked to input your password for the wallet address that's calling the contract. You'll also get flash messages on the UI indicating the events that took place.

Notice the new output under the dry run outcome.

events

You guessed it, these are events emitted. It contains the function that emitted the event, the actual event parameters which is Transfer in this case and also the order in which the tokens where transferred. Tbh, I personally love the events output, so satisfying.

Now, you can call the other functions to get all the orders or a single order.

Congratulations!🎉 You've just written your smart contract and deployed it, You're now on your way to becoming a world class smart contract developer in polkadot!

Ink! smart contracts offer a lot more features, this tutorial introduced you to the fundamentals you need to know when writing smart contracts. You can add more features to your smart contract. We will cover writing unit tests in another article. I hope you found this tutorial helpful.

Top comments (2)

Collapse
 
kubasiemion profile image
kubasiemion

Nice, but would not the self.env().account_id() resolve to the contract deployment address rather than the creator's address (on a real chain)?

Collapse
 
ayomide_bajo profile image
Bekka

Yes, It would be the contract address. This is a better option because it's more secure. You can also withdraw from the contract address, you can write the logic for this. I hope this helps.