Google Analytics

How to write Ethereum smart contracts?

Getting started Ethereum smart contracts

3 min read
Dec. 09, 2021
Krzysztof Fonał
Krzysztof Fonał
×
Krzysztof Fonał
Krzysztof Fonał
Developer
3 min read
Dec. 09, 2021

Have you ever wondered how to implement applications for Ethereum? How do people create new cryptocurrencies based on the Ethereum blockchain? Or all those fancy NFTs? In this post, I’ll introduce you to the basics of smart-contracts for Ethereum, in which you will implement your first token.

Smart contract is the common term for a small program running on a blockchain. On Ethereum, they’re usually written in the Solidity language. In this short tutorial, you’ll implement a contract conforming to the ERC-20 standard to create a new coin in Solidity (we’ll call it Code Poets coin - CP coin).

What do you need:

  • Some programming experience (Solidity syntax is quite similar to languages like c++, Python, javascript)
  • Remix IDE (online IDE but there is also desktop version, or you can use a plugin to your favourite IDE like inteliJ, however in this tutorial, online Remix will be used)

Let’s go:

Go to https://remix.ethereum.org/ and create a new workspace.

MyWorkspace.png


You can call it “MyWorkspace”.

After you create a new workspace, Remix automatically fills it with some directories and example files. To avoid distraction, let’s remove all files and just keep empty directories.

Remix files.png

In Ethereum, a lot of things are already standardized in a place called Ethereum Improvements Proposals (EIP). Interfaces which describe new tokens are also standardized. The standard I’m talking about is ERC-20 -> https://eips.ethereum.org/EIPS/eip-20. So the first file we will create is IERC20.sol (let’s do it in ‘contracts’ directory). In this file, we will put content directly from the standard (if you are wondering what is ERC, here you can read more about EIP and their types: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1.md#eip-types).

Our IERC20.sol content at the end should look like this:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

interface IERC20 {
    function name() external view returns (string memory);

    function symbol() external view returns (string memory);

    function decimals() external view returns (uint8);

    function totalSupply() external view returns (uint256);

    function balanceOf(address account) external view returns (uint256);

    function transfer(address recipient, uint256 amount) external returns (bool);

    function allowance(address owner, address spender) external view returns (uint256);

    function approve(address spender, uint256 amount) external returns (bool);

    function transferFrom(
        address sender,
        address recipient,
        uint256 amount
    ) external returns (bool);

    event Transfer(address indexed from, address indexed to, uint256 value);

    event Approval(address indexed owner, address indexed spender, uint256 value);
}

For those with programming experience, there is not much to explain. But it’s worth pointing out that Solidity is an object-oriented language and above we defined an interface, and all objects inheriting from this interface have to implement the methods listed in it (note: we have implemented the interface as a good practise, but it’s not required. To match the standard, it is enough to have methods signatures match).

‘pragma solidity ^0.8.0;’ is the required line telling which solidity version is supported (and should be used to compile the code). You can treat this line like ` #!/bin/bash` in bash scripts. First line with the comment contains information about the license - you can skip it, but then you will be warned by the compiler about the lack of this.

Now that we’ve defined the interface for ERC-20, let’s create another file CPCoin.sol, where the implementation of our new coin will be placed.

First, we have to import our interface, so our new file will start with:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

import "./IERC20.sol";

Contract objects start from the contract keyword and the interfaces they implement are listed after the is keyword. Is keyword is used for inheritance (it is worth mentioning here that contract can be inherited too), so to create a new contract that implements previously defined interface, we put:

contract CPCoin is IERC20 {
    mapping(address => uint256) private _balances;

    uint256 private _totalSupply;

    address private _owner;

    constructor(address owner) {
        _owner = owner;
    }
}

After contract definition, we have defined a few storage variables which will be needed later in some functions. They are:

  • _balances - is a map where keys are addresses and values are the number of coins per address (which the address owns). It is worth mentioning here that address is one of the primitive types in Solidity.
  • _totalSupply - total number of minted coins
  • _owner - the owner of the contract, only the owner can mint and burn tokens.

Another distinct element in the above code is constructor. Much like in other languages, it is a special function triggered while an object is created. But in smart contract terminology, we say that an object (contract) is created when we deploy a contract. So in constructor, we can initialize some variables - in our example, we want to save the owner address of the contract to allow only him for minting and burning.

Next, we will implement a couple of simple getter functions, which don’t require more comments than the code says:

function name() public view override returns (string memory) {
     return "CPCoin";
}

function symbol() public view override returns (string memory) {
    return "CPC";
}

function decimals() public view override returns (uint8) {
    return 18;
}

function totalSupply() public view override returns (uint256) {
    return _totalSupply;
}

function balanceOf(address account) public view override returns (uint256) {
    return _balances[account];
}

We will leave empty implementation for the following functions because for this tutorial we focus on main token features like transfer. Implementation of those functions we will leave as homework for the reader ;). The idea of the functions is to allow non-owner to spend tokens on behalf of owner at some constraint limit, so we define who can spend tokens from owner account (allowance), what is the limit (approve) and transferFrom allows to transfer found from account not pointed by msg.sender (it will be explained later).

function allowance(address owner, address spender) public view override returns (uint256) {
    return 0;
}

function approve(address spender, uint256 amount) public override returns (bool) {
    return false;
}

function transferFrom(
    address sender,
    address recipient,
    uint256 amount
) public override returns (bool) {
    return false;
}

We’re ready to implement our main function - transfer. The logic for this is not sophisticated. Most importantly, the caller (the one who calls the transfer function from the contract and pays the fee) needs to have enough balance, so we have to modify _balances map properly. Additionally, we will make sure that none of the addresses is a 0 address. At the end, we emit a Transfer event to let web3 listeners (to learn more about web3 events, you can read this: https://dappsdev.org/hands-on/web3/listen-events/) know about this fact. The body of transfer function is following:

function transfer(address recipient, uint256 amount) public override returns (bool) {
    address sender = msg.sender;
    require(sender != address(0), "Cannot transfer from the zero address");
    require(recipient != address(0), "Cannot transfer to the zero address");

    uint256 senderBalance = _balances[sender];
    require(senderBalance >= amount, "Transfer amount exceeds balance");
    unchecked {
        _balances[sender] = senderBalance - amount;
    }
    _balances[recipient] += amount;

    emit Transfer(sender, recipient, amount);

    return true;
}

The parts worth a comment are the require function and the unchecked block. require is used for input validation. It is an elegant way to express some conditions (requirements) to process further, and if requirements are not fulfilled, EVM (Ethereum Virtual Machine) will stop further processing and the transaction will revert with the message we defined (fee will be charged according to what was charged to this moment of crash and remaining gas will be saved). Interestingly, Solidity has an assert function too :) But there is a significant difference between them and use cases for them. For more details, read the documentation: https://docs.soliditylang.org/en/latest/control-structures.html#error-handling-assert-require-revert-and-exceptions

Another crucial construct is the unchecked block. It was introduced in Solidity 0.8 to disable another new 0.8 feature which automatically checks for over/under flow for math operations. You can find more about it in the documentation:

https://docs.soliditylang.org/en/latest/control-structures.html#checked-or-unchecked-arithmetic

In the part of code inside the unchecked block, we are sure we cannot have the under/over-flow so we can save gas when we skip the auto checks.

At this point, we have implemented the whole ERC20 standard. We could test it now, BUT we still have a missing part that is not included in the standard. No CPCoins exist yet to do any transfer. So let’s implement popular features of ERC20 tokens like mint and burn methods. Such methods are not part of the standard (ERC20 interface) but they are usually implemented if supply is dynamic. For those methods, we have introduced _owner field. Only the owner (admin) of the contract will be able to mint and burn CPCoin tokens.

function mint(address account, uint256 amount) public {
    require(msg.sender == _owner, "Only owner can mint");
    require(account != address(0), "Cannot mint to the zero address");

    _totalSupply += amount;
    _balances[account] += amount;
    emit Transfer(address(0), account, amount);
}

function burn(address account, uint256 amount) public {
    require(msg.sender == _owner, "Only owner can burn");
    require(account != address(0), "Cannot burn from the zero address");

    uint256 accountBalance = _balances[account];
    require(accountBalance >= amount, "Burn amount exceeds balance");
    unchecked {
        _balances[account] = accountBalance - amount;
    }
    _totalSupply -= amount;

    emit Transfer(account, address(0), amount);
}

For mint, we increase the total supply and balance for a specific address (we issue coins for a given address). For burn, we decrease the amount from the balance of the given address. Both functions emit Transfer events.

The completed content of CPCoin.sol file looks like following:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

import "./IERC20.sol";


contract CPCoin is IERC20 {
    mapping(address => uint256) private _balances;

    uint256 private _totalSupply;

    address private _owner;

    constructor(address owner) {
        _owner = owner;
    }

    function name() public pure override returns (string memory) {
        return "CPCoin";
    }

    function symbol() public pure override returns (string memory) {
        return "CPC";
    }

    function decimals() public pure override returns (uint8) {
        return 18;
    }

    function totalSupply() public view override returns (uint256) {
        return _totalSupply;
    }

    function balanceOf(address account) public view override returns (uint256) {
        return _balances[account];
    }

    function allowance(address owner, address spender) public view override returns (uint256) {
        return 0;
    }

    function approve(address spender, uint256 amount) public override returns (bool) {
        return false;
    }

    function transferFrom(
        address sender,
        address recipient,
        uint256 amount
    ) public override returns (bool) {
        return false;
    }

    function transfer(address recipient, uint256 amount) public override returns (bool) {
        address sender = msg.sender;
        require(sender != address(0), "Cannot transfer from the zero address");
        require(recipient != address(0), "Cannot transfer to the zero address");

        uint256 senderBalance = _balances[sender];
        require(senderBalance >= amount, "Cannot transfer amount exceeds balance");
        unchecked {
            _balances[sender] = senderBalance - amount;
        }
        _balances[recipient] += amount;

        emit Transfer(sender, recipient, amount);

        return true;
    }

    function mint(address account, uint256 amount) public {
        require(msg.sender == _owner, "Only owner can mint");
        require(account != address(0), "Cannot mint to the zero address");

        _totalSupply += amount;
        _balances[account] += amount;
        emit Transfer(address(0), account, amount);
    }

    function burn(address account, uint256 amount) public {
        require(msg.sender == _owner, "Only owner can burn");
        require(account != address(0), "Cannot burn from the zero address");

        uint256 accountBalance = _balances[account];
        require(accountBalance >= amount, "Cannot burn amount exceeds balance");
        unchecked {
            _balances[account] = accountBalance - amount;
        }
        _totalSupply -= amount;

        emit Transfer(account, address(0), amount);
    }
}

Now, as we have the code completed, we can deploy our smart-contract. But before deployment, we have to compile it first (if you don’t have auto-compile enabled). To do it, click ‘Solidity compiler’ tab, choose CPCoin.sol from the list, and click ‘compile’.

Solidity compiler.png


Once the contract is compiled, we can continue with the deployment process. We won’t deploy on testnet or mainnet now - we’ll leave it for the next article. For now, we will use the VM provided by Remix for quick testing. Let’s click on the “deploy & run transactions” tab. Then pick-up JavascriptVM (any) from the “Environment” field. On the bottom you have a button “deploy”, but as our constructor requires one argument, we have to provide it. As you remember, it is the address of the owner. Below “Environment” you can see “Account” with a list of predefined accounts already top-up’ed with some ethers (100). Let’s copy any of them and paste in an argument beside the “Deploy” button and click “Deploy”. You should see in the console a successful transaction.

Deploy.png



Now, as the contract is deployed, you can unroll its methods:

your-contract.png


Bingo! Now, you can play with your contract. Try to mint some tokens, transfer to another account, check balances, etc. Keep in mind that orange methods change a state on a blockchain, so they require transactions on Eth and some gas fee. Blue methods are read-only methods that don't require fees and transaction if they are called outside contract (if they are called internally, they still use gas). They can be called for instance from web3js API (https://web3js.readthedocs.io/en/v1.5.2/).

Final code you can see on IPFS:

  • https://ipfs.io/ipfs/QmUBzhPQ7oitVEsRWu5qN6yevBTgeYdCUzdfUaYRq6s8jL
  • https://ipfs.io/ipfs/QmWE4ddmwgKXsss29vfjXfbRBpyBF3SRjPukWQPeoVrZFB

If you don’t know what IPFS is - I suggest you look at this fascinating project quite often used with blockchains (especially in NFT space). You can read more here: https://ipfs.io.

To sum up, now you know how to implement, compile and deploy a smart contract in a VM environment. The next steps to learn are unit testing the contracts, deployment on testnet and mainnet and, of course, deeper dive into Solidity. In the end, it is important to mention that the implementation of ERC-20 token from scratch was only for learning purposes. In the real world, you would rather just extend OppenZeppelin libraries: https://github.com/OpenZeppelin/openzeppelin-contracts/tree/master/contracts/token/ERC20.

Have a look at their repo as they already have a lot of baselines for many typical smart contracts implemented.