Create a smart contract in ink! 🦑
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 :
Subscribe to my newsletter
Read articles from Tomas Rawski directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Tomas Rawski
Tomas Rawski
code, arts and earth keeper