Go to course navigation

4 - Solidity I.

Tutorial objectives

We will learn Solidity and EVM fundamentals. Additionally, we will implement a simple smart contract representing an ERC-20 token.

Tutorial pre-requisites

High-level Solidity overview

  • statically typed, contract-oriented, high-level language for implementing smart contracts on the EVM,
  • compiled to EVM bytecode,
  • Turing-complete, but not really general purpose, it is a domain-specific language for EVM smart contracts.

EVM intermezzo

Ethereum can be interpreted as a transaction-based virtual machine. Valid transactions produce state transitions. For example, if Alice sends 1 ETH to Bob, the state transition is that Alice has 1 ETH less and Bob has 1 ETH more. The given transaction has to be valid, ie. Alice has to have enough ETH, the transaction has to be signed by Alice, the transaction nonce has to be correct, etc.

Another type of transaction is a message call that targets an address with an associated code, ie. a smart contract. If the target is a smart contract then its code gets executed. The code execution is defined by the execution model of the EVM:

The execution model specifies how the system state is altered given a series of bytecode instructions and a small tuple of environmental data.

  • The bytecode is the code of the smart contract. The environmental data is data like the sender address, message value, gas, input data etc.

The EVM is a simple stack-based architecture. The word size of the machine (and thus the size of stack items) is 256-bit. The memory model is a word-addressed byte array. The stack has a maximum size of 1024. The machine also has an independent storage model, which is a word-addressable word array. Unlike memory, which is volatile, storage is non-volatile and is maintained as part of the system state.

  • The EVM has 3 main memory components: stack, memory and storage. Stack is used to facilitate the execution of the bytecode. Bytecode consists of instructions (opcodes), those instructions execute data from the stack and output data to the stack. For example, an ADD instruction would pop 2 items from the stack and push the result of the addition of those 2 items. Memory is volatile and thus can’t be used to store the state of the smart contract, it can be interpreted as RAM. After the execution, it is erased. Storage is used to store the state of the smart contract, it can be interpreted as a hard drive. The state of the smart contract can be anything, it can, for example, be a mapping of addresses to balances (ie Alice has 1 token, Bob has 20 tokens..), etc.

All locations in both storage and memory are zero-initialized. The machine does not follow the standard von Neumann architecture. Rather than storing program code in generally-accessible memory or storage, it is stored separately in a virtual ROM interactable only through specialized instruction. The machine can have exceptional execution for several reasons, including stack underflows and invalid instructions.

  • If an exceptional state is reached then the execution immediately halts and the transaction is abandoned. No changes to the Ethereum state are applied, except for the sender’s nonce being incremented and their ether balance going down to pay the block’s beneficiary for the resources used to execute the code to the halting point.

The EVM iteratively processes the instructions and updates the machine state. The machine state is defined as the tuple (g, pc, m, i, s) which are the gas available, the program counter, the memory contents, the active number of words in memory (counting continuously from position 0), and the stack contents. If the machine successfully reaches a halting state, the execution is finished and the state of Ethereum can transition to a new valid state.

Solidity basics

Solidity provides good language documentation at: https://docs.soliditylang.org/en/latest/. Documentation should be your first stop when you are looking for something. The following is a quick overview of the language.

Contracts

Solidity is a contract-oriented language. A contract is a collection of code (its functions) and data (its state) that resides at a specific address on the Ethereum blockchain. The contract can be created using the contract keyword. The following example creates a contract named SimpleStorage:

    contract SimpleStorage {
        uint storedData;
    }

State variables

State variables are variables whose values are permanently stored in contract storage. Variables declared inside the contract’s body (but outside any function) are state variables. They are expensive to modify and internal by default (bare in mind that internal still means that it is stored on a public blockchain). The variables storedData and balances are state variables of the SimpleStorage contract.

    contract SimpleStorage {
        uint storedData;
        mapping (address => uint) balances;
        function foo() public {
            balances[msg.sender] = 100;
            uint x = balances[msg.sender];
        }
    }

Data types

Solidity has several data types, they are divided into value types and reference types. Value types are copied when they are used as function arguments or returned from functions. Reference types can be modified through multiple different names (references). The reference types have to be declared with the data area where the type will be stored (memory, storage, calldata).

  • Value types
* bool
* uint(8-256), int(8-256)
* address
* enum
* bytes(1-32)
  • Reference types
* fixed-size array
* dynamic-size array
* byte array
* string
* struct
* mapping

Data location

Data location is a property of a variable that defines where the data is stored. There are 3 data locations: memory, storage and calldata. Memory is volatile, it is erased after the execution. Storage is non-volatile, it is stored on the blockchain. Calldata is used for function call parameters, it is read-only and volatile.

Most important reference types

The most important reference types are arrays, structs and mappings.

Arrays can be both static and dynamic. They can be nested - uint[][5] - is an array of 5 dynamic arrays of uints. Further information in: https://docs.soliditylang.org/en/latest/types.html#arrays.

Structs allow users to define new types. They can contain both value and reference types. Structs can be passed to functions, arrays, mapping etc.

Mappings are key-value stores, similar to the hash maps in other languages. They can be only declared in storage and they aren’t iterable. Further information in: https://docs.soliditylang.org/en/latest/types.html#mapping-types.

Functions

Functions are the executable units of code and are usually defined inside a contract. Functions can be invoked internally (by moving the program counter) or externally (through a message call). They can be both overloaded and overridden. They can be extended using modifiers, which amend the function’s semantics. Functions can have varying levels of visibility - external, public, internal and private.

Function modifiers

Modifiers are pieces of code that amend the semantics of the function that they modify. They are inspired by the decorators from Python. For example, the following modifier checks if the caller is the owner of the contract:

    contract SimpleStorage {
        address owner;
        modifier onlyOwner {
            require(msg.sender == owner);
            _;
        }
        function foo() public onlyOwner {
            //the logic of foo will be executed only if the caller is the owner
            //if the caller is not the owner, the execution will be reverted
        }
    }

Events

Events are an abstraction atop EVM’s logging functionality. Smart contracts should emit events when they make important state changes. Applications outside EVM can subscribe to the events and react to them, eg. a frontend application can display changes of the state to a user. Events are declared using the event keyword. The following example declares an event named Transfer:

    contract SimpleStorage {
        event Transfer(address indexed from, address indexed to, uint amount;)
    }

Errors and revert statements

Errors are a way to signal that something went wrong in a function. They are declared using the error keyword. The following example declares an error named InsufficientBalance:

 contract SimpleStorage {
    error InsufficientBalance(uint requested, uint available);
    function foo() public {
        if (balances[msg.sender] < 100)
            revert InsufficientBalance(100, balances[msg.sender]);
    }

Revert statements

The statement require(condition, "description"); would be equivalent to if (!condition) revert Error("description"). They are a convenient way to check conditions and revert the execution if the condition is not met. Throwing errors using revert is a bit more gas-efficient than throwing errors using require.

Creating a smart contract: ERC-20 token

Ethereum has its native token - Ether. Ether is used to pay for the execution of smart contracts. Ether is also used to pay for the storage of data on the Ethereum blockchain. It is directly incorporated into the protocol and the balances are directly stored in the Account state.

However, we can create smart contracts and thus we can develop a new token atop Ethereum. The smart contract will provide the state for the token (eg the balances of the token holders) and the logic for the token (eg the transfer function).

We will create a simple ERC-20 token. ERC-20 is a standard for tokens on Ethereum. It defines a set of functions that a token contract has to implement. The standard is defined at https://eips.ethereum.org/EIPS/eip-20. Because the tokens have their standard they can be easily integrated into other smart contracts. For example, a decentralized exchange can integrate a token and then simply call the transfer function of the token contract.

Create a new contract

// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
contract HelloToken {
}

Variables

Name of your token.

string private _name;

Symbol of your token.

string private _symbol;

Number of decimals. Decimals are usually set to 18 to mimic the properties of Ether. This means that 1 token is 1 * 10^18.

uint8 private _decimals;

Total supply of your token.

uint256 private _totalSupply;

Mapping, which holds the account balances, eg. _balances[alice] = 10.

mapping (address => uint256) private _balances;

Mapping, which holds transfer allowances from a sender to a recipient. The recipient can spend the allowance on behalf of the sender.

mapping (address => mapping (address => uint256)) private _allowances;

Events

Define events for logging the state transitions.

event Transfer(address indexed _from, address indexed _to, uint256 _value);
event Approval(address indexed _owner, address indexed _spender, uint256 _value);

Constructor

Is called during the contract deployment. It initializes the state of the contract and produces the final bytecode that gets deployed to the network.

constructor () public {
    _name = "HelloToken";
    _symbol = "HELLO";
    _decimals = 18;
}

Getter functions

function name() public view returns (string memory) {
    return _name;
}
function symbol() public view returns (string memory) {
    return _symbol;
}
function decimals() public view returns (uint8) {
    return _decimals;
}
function totalSupply() public view returns (uint256) {
    return _totalSupply;
}

Token balance of the specific address.

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

Supply functions

Mints new tokens specified by the amount to the specified address. Increases the total supply.

function _mint(address account, uint256 amount) internal {
    _totalSupply += amount;
    _balances[account] += amount;
    emit Transfer(address(0), account, amount);
}

Burns amount of the token from the specified address, decreases the total supply.

function _burn(address account, uint256 amount) internal {
    require(_balances[account] >= amount, "ERC20: burn amount exceeds balance");
    _balances[account] -= amount;
    _totalSupply -= amount;
    emit Transfer(account, address(0), amount);
}

Now you can add this line to your constructor. It will mint fixed amount of the token during the deployment to the deployer’s address.

_mint(msg.sender, 1000 * 10 ** _decimals);

Allowance functions

Get the allowance amount from the owner to a spender.

function allowance(address owner, address spender) public view returns (uint256) {
    return _allowances[owner][spender];
}

Set the allowance amount to the spender.

function approve(address spender, uint256 amount) public returns (bool) {
    _allowances[msg.sender][spender] = amount;
    emit Approval(msg.sender, spender, amount);
    return true;
}

Transfer functions

Transfers the specified amount from the owner to the recipient.

function transferFrom(address owner, address recipient, uint256 amount) public returns (bool) {
    require(allowance(owner, msg.sender) >= amount, "ERC20: transfer amount exceeds allowance");
    require(_balances[owner] >= amount, "ERC20: transfer amount exceeds balance");
    _balances[owner] -= amount;
    _balances[recipient] += amount;
    _allowances[owner][msg.sender] -= amount;
    emit Transfer(owner, recipient, amount);
    return true;
}

Transfers the specified amount to the recipient.

function transfer(address recipient, uint256 amount) public returns (bool) {
    return transferFrom(msg.sender, recipient, amount);
}

Compile, deploy & interact

Compile your contract, deploy it locally to a Javascript VM or the Ganache (Web 3 Provider in the Remix IDE). Transfer some tokens from address A to address B.

Optional homework

Implement NFT (Non-Fungible Token) using ERC-721 standard. (https://ethereum.org/en/developers/docs/standards/tokens/erc-721/)

The following resources were used for creating this tutorial. The italicized text is slightly modified text from those sources. They are recommended for further reading.