Create a smart contract in ink! 🦑

Create a smart contract in ink! 🦑

Using Open Dream Colors as our project to analice in deep

You can read my previous tutorial to understand a little more about how to analyze, compile and deploy a smart contract developed in ink!

The idea of this tutorial is to follow the creation of a smart contract and understand the different parts of it. We are adding this tutorial as part of the art project "Open Dream Colors" which can be seen in https://opencolors.tomasrawski.com.ar/

Create the smart contract project

Run in your terminal cargo contract new openColors

Think and create the storage of the smart contract

As you may be planning to write your smart contract, it's a good practice to think about which should be the storage structure, the public functions that would change that storage, the functions that only would read from it and the private functions.

When you are starting it's a good practice to take in mind:

  • Use already-written tools or code where possible, like OpenBrush

  • Sometimes it's good to prioritize clarity and simplicity over performance.

  • Consider gas costs when you are thinking about a function, building or modifying the storage.

  • Be careful when making external contract calls.

  • Test the smart contract as much as you can. (see the next tutorial about testing)

  • If possible, have your smart contract audited by a 3rd-party company.

  • Stay informed about new vulnerabilities.

So we would add these items to our storage:

  • List of colors

  • The last color added

  • The number of colors that each wallet has

  • Total colors added

  • The owner of the contract

    // a Struct for the color with Red, Green and blue values: 0-255
    #[derive(scale::Encode, scale::Decode, Eq, PartialEq, Debug, Clone)]
    #[cfg_attr(feature = "std", derive(scale_info::TypeInfo, StorageLayout,))]
    pub struct Color {
        r: u8,
        g: u8,
        b: u8,
    }

    #[ink(storage)]
    pub struct OpenColors {
        // All the colors in order
        colors_list: Vec<Color>,
        // The last color added
        last_color: Option<Color>,
        // The amount of colors per Account
        colors_added_per_user: Mapping<AccountId, u32>,
        // the amount of colors added
        total_colors_added: u32,
        // the owner of the contract that can reset it
        owner: AccountId,
    }

More info about storage behavior in ink!: https://use.ink/basics/storing-values & https://use.ink/datastructures/mapping/

The struct Color is a custom structure that has to implement several traits, that the compiler would guide to or you can see the documentation: https://use.ink/datastructures/custom-datastructure.

This custom trait, Color has 3 properties to store the value of each amount of color and the OpenColors struct uses a Vec<Color> to maintain the list of colors in order. The last_color property has the Option<Color> structure to allow us to have a None value when no colors have been added yet to the contract.

The mapping structure in Ink! is very similar to the one in Solidity and I recommend reading the provided link above for a better understanding of the functionality. Unlike a Vec, accessing information from a mapping will only retrieve the specific piece of information requested, rather than the entire set of data.

Implement the functions that will interact with the struct storage

Inside impl OpenColors { ... } there are all the functions. First, here is the constructor:

        /// Constructor that initializes with the initial colors send in a vector
        #[ink(constructor)]
        pub fn new(initial_colors: Vec<Color>) -> Self {
            // instanciate the storage with the default values
            let mut instance = Self::default();

            // set the owner
            let user = Self::env().caller();
            instance.owner = user;

            if initial_colors.is_empty() {
                return instance;
            }

            // add the last color
            instance.last_color = initial_colors.last().cloned();
            // add the vector and the amount of colors to the diff. storage
            let colors_added =
                instance.colors_added_per_user.get(user).unwrap_or(0) + initial_colors.len() as u32;
            instance.colors_added_per_user.insert(user, &colors_added);
            instance.colors_list = initial_colors.clone();
            instance.total_colors_added = initial_colors.len() as u32;

            instance
        }

        /// Set Default values with no colors
        fn default() -> Self {
            Self {
                owner: Self::env().caller(),
                colors_list: Vec::new(),
                last_color: None,
                colors_added_per_user: Mapping::new(),
                total_colors_added: 0,
            }
        }

self.env().caller() -> Return the Account of the wallet that is calling the function.

A function that only return the list of colors doesn't spend any gas, because is only a reading function and we are not changing the state of the blockchain:

        /// Get the list of the colors 
        #[ink(message)]
        pub fn get_colors_list(&mut self) -> Vec<Color> {
            // simple return the vector that is in the storage
            self.colors_list.clone()
        }

The function has a public property (pub) and the attribute #[ink(message)], so everyone can call it.

        /// add a color at the end of the vector and update counters
        #[ink(message)]
        pub fn add_color(&mut self, color: Color) {
            let account = self.env().caller();
            // add to the list
            self.colors_list.push(color.clone());

            // increment the amount of colors per user
            let amount_of_color = self
                .colors_added_per_user
                .get(self.env().caller())
                .unwrap_or(0)
                + 1;
            // insert into the mapping
            self.colors_added_per_user.insert(account, &amount_of_color);
            self.last_color = Some(color.clone());
            self.total_colors_added += 1;

            // emit the event of the color added
            self.env().emit_event(ColorAdded {
                account_id: account,
                color,
            });
        }

The function add_color received a parameter with the color to add at the end of the list and updates all the count storages.

In the end, it calls the emit_event function to emit an event so that the Interfaces can listen to it and know how to behave in that case. It's very important for the protocols that are indexing the blockchain and can easily index the information by only reading the events.

So a good practice will be to raise an event every time there is a change in the state of the blockchain and try to add all the information to the event. In this case, we share the account_id which calls the function and the color that the user added.

Finally, we have a private function that we can be call when we need to ensure (inside a function) that the owner of the contract is calling that extrinsic:

    /// Ensure_owner ensures that the caller is the owner of the contract
    fn ensure_owner(&self) -> Result<(), Error> {
        let account = self.env().caller();
        // Only owners can call this function
        if self.owner != account {
            return Err(Error::NotOwner);
        }
        Ok(())
    }

We can add an Enum of errors with the macros that we see above and the struct for every event must have the attribute #[ink(event)]

    #[derive(scale::Encode, scale::Decode, Eq, PartialEq, Debug, Clone)]
    #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
    pub enum Error {
        // The caller is not the owner of the contract
        NotOwner,
    }

    /// called when someone add a color
    #[ink(event)]
    pub struct ColorAdded {
        #[ink(topic)]
        account_id: AccountId,
        color: Color,
    }

Full smart contract code: https://github.com/rtomas/openColors/blob/main/lib.rs

Reference:

Links :