A-Z of building dApps on ICE — Part 4: Ink! Smart contracts
In the previous parts of the series, we have discussed on writing and deploying Solidity smart contracts on the Arctic network (ICE/Snow testnet). However, the network is not just limited to Solidity smart contracts. It also supports writing smart contracts using Ink!.
Ink! is a Rust-based embedded domain-specific language (eDSL) designed exclusively for the purpose of writing WebAssembly (wasm) code, which is executed by the Contracts Pallet of the ICE blockchain.
What you will learn
- Write a simple ERC20 smart contract using Ink!,
- Deploy the smart contract to Arctic testnet using Polkadot.js app,
- Interact with smart contract using Polkadot.js app.
In this article, we will walk through the process of deploying an ERC20 contract on the ICE blockchain using Ink! smart contracts.
ERC20 is a set of rules that all fungible tokens on EVM based networks must follow. Thus, this token standard allows developers of all kinds to accurately predict how new tokens will work in the larger Ethereum system. As long as the tokens follow the rules, developers need not worry about redoing their work, every time a new token comes out
Installing Prerequisites
- Make sure you have installed Rust and configured your Rust environment.
- Add additional Rust configuration for Ink! smart contracts
rustup component add rust-src --toolchain nightly
rustup target add wasm32-unknown-unknown --toolchain nightly
- Install cargo-contract, a CLI tool for setting up and managing WebAssembly smart contracts written using Ink!
cargo install cargo-contract --vers ^0.17 --force --locked
- Install the binaryen package, which is used to optimize the WebAssembly bytecode of the contract
# For Ubuntu or Debian users
sudo apt install binaryen
# For MacOS users
brew install binaryen
NOTE: If you’re unable to find a version of binaryen compatible with your OS, you can download the binary directly. You can also install the package globally using NPM (npm i -g binaryen).
- Install Polkadot-js extension for deploying contracts and interacting with them.
ERC20 contract
We shall create a simple ERC20 contract, which allows users to mint some tokens initially, check their balances, and transfer tokens to other users.
Creating ERC20 contract template
In your working directory run:
cargo contract new erc20
This command will create a new project folder named erc20 with the following content:
erc20
└─ lib.rs ← Contract Source Code
└─ Cargo.toml ← Rust Dependencies and ink! Configuration
└─ .gitignore
Here, we shall replace the content of lib.rs with the smart contract code given below:
Note: This example is inspired by the erc20 contract from the Ink! smart contract documentation examples by paritytech.
The first line, #![cfg_attr(not(feature = "std"), no_std)],
is telling the Rust compiler which mode they are being compiled in. This also plays a significant role in how the compiled code is generated.
We have then declared a module, mod erc20
, which is the collection of functions and data (state) that resides at a specific address on the blockchain. A[ink::contract]
macro is added before mod erc20
, which represents the content of the contract will reside under this mod.
public struct erc20
defines a storage which consists of :
- name: name of the token
- symbol: symbol of the token
- total_supply: the total supply of tokens in our contract. The total amount of tokens circulating in the network cannot exceed this value.
- balances: individual balance of each account
- allowances: balance that are spendable by non-owners
Remember that contract calls cannot return a value to the outside world when you send a transaction. However, we often encounter a use-case where we want to notify the outer world that something interesting has occurred in the contract. We can achieve this by setting up an event on our contract.
For eg: Declaring an event:
#[ink(event)]
pub struct Transfer {
#[ink(topic)]
from: Option<AccountId>,
#[ink(topic)]
to: Option<AccountId>,
value: Balance,
}
Events should be declared using the #[ink(event)]
macro.
Here, in the pub struct Transfer event, there are three pieces of data: a value of type Balance, two Option-wrapped AccountId variables indicating the from and to accounts.
Let us look at how we can emit the events.
self.env()
.emit_event(
Transfer {
from: None,
to: Some(self.env().caller()),
value: 100,
}
);
We can emit events by calling self.env().emit_event() with a particular event as the sole argument to the method call.
Recall that since the from and the to fields are Option<AccountId>, we can’t just set them to particular values. Let us assume we want to set a value of 100 for the initial deployer. This value does not come from any other account, and so the from value should be None.
Now, let’s talk about another event public struct Approval
#[ink(event)]
pub struct Approval{
#[ink(topic)]
owner: AccountId,
#[ink(topic)]
spender: AccountId,
value: Balance,
}
This event is emitted when the approve() function is invoked where spender is allowed to withdraw up to the value amount of tokens from the owner’s wallet.
#[ink(message)]
pub fn approve(&mut self, spender: AccountId, value: Balance)
When you call the approve
function, update the allowances variable to indicate that the value amount of tokens from the owner’s account can be used by the spender . The owner
is always the self.env().caller()
, ensuring that the function call is always authorized.
When an account requests approval several times, the approved value simply overwrites any previously approved value. The authorized value between any two accounts is always 0, and a user may always call approve for 0 to cancel access to their funds in another account.
Now, let us look into the ERC-20 error types that we have defined in our contract:
pub enum Error { InsufficientBalance,
InsufficientAllowance,
}
We have defined two types of errors: InsufficientBalance and InsufficientAllowance. These errors are returned whenever the user doesn’t have insufficient balance to transfer, and isn’t allowed to spend the specified amount of tokens on behalf of another user respectively.
Now, we will discuss the implementation of the Erc20 module. The line beginning with impl Erc20
is where we define different methods for this contract. The constructor below this line is fired when the ERC-20 contract is deployed with the specified initial supply.
The most basic ERC20 token contract is a token with a specified total supply. All the tokens will be made available to the contract owner when the contract is set-up (inside #[ink(constructor)]
).
impl Erc20 {
/// Creates a new ERC-20 contract with the specified initial supply.
#[ink(constructor)]
pub fn new(initial_supply: Balance) -> Self {
// This call is required in order to correctly initialize
// the mappings of our contract.
ink_lang::utils::initialize_contract(|contract| {
Self::new_init(contract, initial_supply)
})
}
. . .
}
Now, it is up to the creator to transfer these tokens to other users when needed. The transfer can be done using transfer() or transfer_from_to() functions
transfer() function lets the user who calls the contract, transfer some funds they own, to another account.
You will notice, there is a public function transfer() and an internal function transfer_from_to() which will help us enable third party allowances and spending on behalf of another user.
Keep in mind that the transfer() function and other public functions return a bool to indicate the success or failure state of the operation. To avoid making any changes to the contract state, we will exit early if the from account doesn’t have enough tokens to make the transfer. transfer_from_to() will simply bubble up the success indicator up to the function that calls it.
Finally, the transfer() function calls the transfer_from_to() function with the from parameter already set to the self.env().caller. To ensure that the user who invoked the function can move their own funds, we do this “authorization check.”
There are some getter methods that will return the current state of the contract as mentioned below:
total_supply
: Returns total supply of tokenname
: Returns name of the tokensymbol
: Returns symbol of tokenbalance_of
: Returns the account balance for the specified `owner`allowance
: Returns the amount which `spender` is allowed to withdraw from `owner`.
Compiling the contract
Before you begin the compilation process, update your cargo.toml file by adding the following dependencies:
a. Under [dependencies] add the following
ink_prelude = { version = "3.0.0-rc8", default-features=false}
b. Under [features], in the existing std variable add the following
Std = ["ink_prelude/std",
]
This is because we have used ink_prelude::string::String
type to specify token name and symbol.
Inside the erc20 directory, run:
cargo +nightly contract build
After you run the above command, you should see three files inside the target/ink folder; a Wasm binary, a metadata file (which contains the contract’s ABI) and a .contract file which bundles both. This .contract file can be used to deploy your contract to the blockchain.
NOTE: If you get parity-scale-codec is ambiguous error while compiling the contract then update the following dependencies in your cargo.toml file.
scale-info = { version = "2", default-features = false, features = ["derive"], optional = true }scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["derive", "full"]}
Deploying the contract
Head over to the Polkadot.js app to deploy the compiled erc20 contract.
Uploading contract code
We will upload the contract code and make a copy of the contract on the blockchain.
- On the Navbar at the top, select Developer -> Contracts.
- Click on upload & deploy code button on contracts page.
- On the popup, select the wallet you want to deploy the contract from. Select erc20.contract file generated after compiling our erc20 contract, and set a suitable name for your contract
- Click on Next button, then enter the amount of initial total supply of token and finally click on the Deploy button.
- On the next popup click on sign and submit button. Then your polkadot.js extension will pop up, where you have to confirm the transaction.
After deployment is successful you can find the contract methods in the contracts section
You can find more about ink! contract deployment on this link.
Interacting with the deployed contract
After the contract has been deployed, you can call its read and write methods.
You can call the balanceOf() method to check the balance of a wallet address.
- Click on read button on the left side of balanceOf() function
- A popup will appear, enter the wallet address whose balance you want to check
- Finally Click on Read button inside the popup
Similarly, you can invoke write functions (state-changing functions) like transfer() on the deployed contract. The transfer() function transfers the specified amount of tokens from the caller’s wallet to the specified receiver’s wallet.
- Click on exec button at the left side of the transfer() function
- Enter receiver’s address & token amount to be transfered
- Click on Execute and confirm the transaction on the Polkadot.js wallet.
Hence we interacted with the smart contract using both read and write methods. you can try invoking other methods also.
Conclusion
In this Part 4 of A-Z of building dApps on ICE series, we have learned about setting up our environment for writing, testing and deploying ink! Contracts on the Arctic testnet. We should now be able to write, deploy and interact with smart contracts on the Arctic testnet network using Ink!.