Go to course navigation

5 - Solidity II.

Tutorial objectives

We will learn about more advanced aspects of the Solidity language, such as inheritance, libraries, assembly, upgradeability, and more.

Tutorial pre-requisites

Contract Application Binary Interface (ABI)

ABI is the standard way to interact with contracts in the Ethereum ecosystem, both from outside the blockchain and for contract-to-contract interaction. Data is encoded according to its type, as described by the ABI specification. The encoding is not self-describing and thus requires a schema in order to decode.

  • A smart contract has a corresponding bytecode which is stored as part of the Ethereum blockchain. A bytecode is a series of instructions. When a transaction is made that targets some smart contract the bytecode is loaded by EVM and executed as described by the Execution model. The execution environment apart from other data contains the execution input data. The execution input data is the data provided by the transaction (message call) that originated the execution. The given smart contract takes the input as it is and processes it as specified by the bytecode. The data is processed on the binary level.
  • To provide a common way for interacting with smart contracts (and among smart contracts) an encoding of the data is needed (that matches the way the data is processed). Without the encoding, the ecosystem would suffer in terms of interoperability, because the contracts wouldn’t be able to interpret and process the input and output data.
  • This is where ABI comes in. It defines the standard way to interact with (and amongst) smart contracts.

How does the encoding look like?

The first four bytes of the input data for a function call (that is originated by a transaction or a message call) specify the function to be called (or the fallback function can be targetted and in such a case the selector won’t match). It is the first four bytes of the Keccak (SHA-3) hash of the signature of the function. The rest of the bytes are encoded arguments. The arguments are encoded according to the specification in: https://docs.soliditylang.org/en/v0.8.22/abi-spec.html#contract-abi-specification.

Example of the first four bytes of the Keccak (SHA-3) hash of the signature (without return types) of the function: keccak256(“foo(uint32,bool)”)[0:4].

Of course, the contracts have to be compiled in such a way that they will be able to decode the input data and process it correctly. This is, of course, done by the Solidity compiler.

Special functions

Receive

The receive function is used for receiving Ether, without any data. A contract can have at most one receive function.

It is declared without the function keyword: receive() external payable { …​ }. This function cannot have arguments, cannot return anything and must have external visibility and payable state mutability.

It is invoked on plain Ether transfers, i.e. using transfer or send. Thus if a contract defines a receive function and someone sends Ether to it, the receive function will be invoked.

contract Receiver {
    receive () external payable {
        //this function is called when the contract receives ether
    }
}
contract Sender {
    function send(address payable _receiver) public payable {
        //the _receiver address represent the address of the deployed Receiver contract
        _receiver.transfer(msg.value);
    }
}

Fallback

The fallback function is a special kind of function that is executed on a call to a contract if none of the other functions match the given function identifier. That is if Alice sends a transaction to contract A targeting the function foo and contract A does not have a function foo, then the fallback function is executed.

It is declared using either fallback () external [payable] {..} or fallback (bytes calldata input) external [payable] returns (bytes memory output) {..}.

Transfering Ether

In the previous tutorial, we programmed a simple token contract. The contract allowed for transferring tokens between accounts. Here we will show how to transfer Ether, the native currency, between accounts.

We already covered the address type. The address also exists in the payable variant address payable that can receive Ether. The payable variant has two additional members transfer and send that can be used to transfer Ether to the address.

The two functions are similar, but transfer reverts on failure while send returns false on failure.

It is also possible to transfer Ether using a normal external call: address.func{value: msg.value}();. If the func is declared payable, the Ether will be transferred to the contract.

Inheritance

Solidity provides support for inheritance (and also multiple inheritance). Functions in child contracts can override functions from parent contracts (though this has to be explicitly allowed using virtual and override keywords).

In case of inheritance, only one contract is created on the blockchain.

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;


contract Owned {
    constructor() { owner = payable(msg.sender); }
    address payable owner;
}


// Use `is` to derive from another contract. Derived
// contracts can access all non-private members including
// internal functions and state variables. These cannot be
// accessed externally via `this`, though.
contract Destructible is Owned {
    // The keyword `virtual` means that the function can change
    // its behaviour in derived classes ("overriding").
    function destroy() virtual public {
        if (msg.sender == owner) selfdestruct(owner);
    }
}

Function overriding

When a function is marked as virtual, it can be overridden by a function with the same name and parameters in a derived contract. The overriding function must use the override keyword.

pragma solidity 0.8.17;

contract A {
    function foo() public virtual returns(uint256) {
        return 1;
    }
}

contract B is A {
    function foo() public override returns(uint256) {
        return 2;
    }
}

Multiple inheritance

Solidity allows for multiple inheritances, ie. a contract inheriting from multiple different contracts (eg A is B, C). This can be problematic when multiple contracts override the same method (which method should be called?). In such a case, an order (linearization) for the method resolution has to be established. To produce the linearization, Solidity uses an algorithm inspired by the C3 linearization algorithm (https://en.wikipedia.org/wiki/C3_linearization). The C3 algorithm basically creates a graph of the inheritance hierarchy and uses a topological sort to produce the linearization.

Especially, the order in which the base classes are given in the is directive is important: You have to list the direct base contracts in the order from “most base-like” to “most derived”.

  • The C3 algorithm preserves the order of the base classes in the is directive (preservation of local precedence order).

A simplifying way to explain this is that when a function is called that is defined multiple times in different contracts, the given bases are searched from right to left (ie from the most derived contract) in a depth-first manner, stopping at the first match.

pragma solidity 0.8.22;

contract A { }

contract B is A { }

contract C is A { }

contract D is B, C { }

Constructors

A constructor is an optional function declared with the constructor keyword which is executed upon contract creation, and where you can run contract initialisation code.

If no explicit constructor is provided, a default constructor is generated. The default constructor has no arguments and an empty body.

In the case of inheritance, one can pass arguments to the base contracts in the following way:

pragma solidity 0.8.22;

contract A {
    uint256 x;
    constructor(uint256 x_) { x = x_; }
}

contract B is A {
    constructor(uint256 y) A(y * y) {}
}

One area where inheritance linearization is especially important and perhaps not as clear is when there are multiple constructors in the inheritance hierarchy. The constructors will always be executed in the linearized order, regardless of the order in which their arguments are provided in the inheriting contract’s constructor.

Interfaces, Abstract contracts

Solidity also supports interfaces and abstract contracts. Interfaces specify the functions that a contract should implement, but do not provide an implementation of these functions. Abstract contracts are similar to interfaces, but they can also contain implemented functions (and declare variables). Both of those constructs are used extensively.

Abstract contracts are useful where common functionality is convenient, but the implementation of certain functions can only be done in child contracts. Abstract contracts enforce the implementation of those functions because they can’t be deployed. A good example of abstract contracts can be found in: https://github.com/LayerZero-Labs/solidity-examples/blob/3fae7e07c7687d2f526dcc38b28e952a159165a7/contracts/lzApp/NonblockingLzApp.sol#L40.

  • It is part of the LayerZero SDK, which is a framework for building LayerZero applications. It requires the programmer of an application that utilizes the protocol to implement his own receive function.
abstract contract A {
    function f() public virtual;

    function bar() public returns(bool){
        return false;
    }
 }

interface I {
    function f() external;
    function fooha() external;
}

contract Implementor is A, I {
    function f() public override {}
    function foo() public override {}
    function fooha() public override {}
}

Assembly

Languages like C or C++ allow to directly embed assembly code into the source code. Solidity also supports this feature. The assembly code is embedded in a function with the assembly keyword. The assembly code is written in the Yul language.

Yul is used as the intermediate language for the Solidity compiler. It is a low-level language that is designed to be a compilation target for higher-level languages. It still provides good human readability and is widely used in the smart contracts.

We will discuss the Yul as it is used in smart contracts. In contracts it is used for 2 reasons - to access the low-level features of the EVM (and thus provide better flexibility than Solidity) and to optimize the code (and thus create significant gas savings).

Yul shares many language features with high-level languages. It allows for declaring variables using the let keyword, loops, switch, if statements, etc. It also supports the concept of functions and function calls (compared to Solidity those functions take args from stack).

Apart from the mentioned high-level features, Yul also supports low-level opcodes. Some of them for example are:

mstore(p, v) // direct access to memory
sstore(p, v) // direct access to storage
calldataload(p) // direct access to calldata
call(g, a, v, in, insize, out, outsize) // call to another contract
returndatacopy(t, f, s) // copy bytes from return data
delegatecall(g, a, in, insize, out, outsize) // call to another contract and keep the current context

Upgrading smart contracts

The following example discusses the use of assembly in so-called proxies. A proxy is a contract that delegates all calls to another contract. Delegating a call means that the proxy will execute the code of the delegated contract in the context of the proxy, ie. the proxy will provide the state and the other contract the logic. It can be seen that if the other contract would be swapped for some new one then the logic will be changed and thus the proxy will be upgraded.

The main advantage of using a proxy is that the proxy can be upgraded without changing the address and modifying the proxy state.

The following example shows how the proxy delegates calls to the logic contract.

fallback() external payable {
        address logic = getAddress(LOGIC_CONTRACT_ADDRESS);

        // solhint-disable-next-line no-inline-assembly
        assembly {
            calldatacopy(0, 0, calldatasize())

            let result := delegatecall(gas(), logic, 0, calldatasize(), 0, 0)

            returndatacopy(0, 0, returndatasize())

            switch result
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }
  • The code excerpt is from the proxy contract.
  • The whole assembly block is contained in the fallback function. This is because all the functions are defined in the logic contract (and thus not present in the proxy). If Alice calls function foo on the proxy contract the fallback will be called. The fallback will delegate the call to foo to the logic contract. And the logic contract will be responsible for executing the desired foo function. After foo finishes executing, the fallback will return the result to Alice.
  • The first line of the fallback queries the address of the logic contract. The address is stored in the storage of the proxy contract. This is the address to which the proxy will delegate the call. If this address gets upgraded the proxy will be upgraded as well (because it will delegate to a new logic contract).
  • The line with delegatecall is responsible for the actual delegation. It will pass the data from calldata to the logic contract. Those data include the function and associated arguments that should be called on the logic contract (remember how the 4 bytes are the function selector and the rest are encoded arguments?). Because Alice called foo on the proxy the calldata will contain the function selector for foo and this is exactly what we want to pass to the logic contract.
  • Further discussion of details of the assembly block is beyond the scope of this introductory lesson. Anyone interested can discuss it with the lecturer. Further reading about the assembly block can be found in https://blog.openzeppelin.com/proxy-patterns/.

Libraries

Libraries are similar to contracts, but their purpose is that they are deployed only once at a specific address and their code is reused using the DELEGATECALL. Additionally, libraries can be linked directly to contracts and called internally, which again allows for code reuse.

Libraries can be seen as implicit base contracts of the contracts that use them. They will not be explicitly visible in the inheritance hierarchy, but calls to library functions look just like calls to functions of explicit base contracts (using qualified access like L.f())

  • Calls to internal libraries are realized via JUMP opcode. That means that the internal functions of a library are included in the bytecode of the calling contract. Calls to external libraries are realized via DELEGATECALL opcode.
  • When the using directive is used for the internal libraries the library code is copied into the bytecode of the contract. The given type that is being extended inherits the functions defined in the library. If a library function is then called on the given type, the value of the type is used as an implicit first argument of the library function.

Library vs contract

Libraries have multiple differences compared to contracts:

  • they cannot have state variables
  • they cannot inherit nor be inherited
  • they cannot receive Ether
  • they cannot be destroyed

Semestral work - implementation of D21 voting method

"Janečkova metoda D21" is a modern voting system, which allows more accurate voting. You can learn more about it here: https://www.ih21.org/o-metode. In our exercise, we want to achieve the following use cases:

  • UC1 - Everyone can register a subject (e.g. political party)
  • UC2 - Everyone can list registered subjects
  • UC3 - Everyone can see the subject’s result
  • UC4 - Only the owner can add eligible voters
  • UC5 - Only the owner can start the voting period
  • UC6 - Subjects can’t be registered during the voting period
  • UC7 - Every voter has 3 positive and 1 negative vote
  • UC8 - Voter can not give more than 1 vote to the same subject
  • UC9 - Negative vote can be used only after 2 positive votes
  • UC10 - Voting ends after 2 days from the voting start

Interface

This interface will help you with the contract implementation. It is necessary to strictly follow this interface and naming for the successful evaluation of the final exercise.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.21;

interface IVoteD21{

}

Subject structure

Define the structure inside the interface. The subject of voting can be for example a political party.

struct Subject{
    string name;
    int256 votes;
}

Interface functions

Add a new subject into the voting system using the name.

function addSubject(string memory name) external;

Add a new voter into the voting system.

function addVoter(address addr) external;

Get addresses of all registered subjects.

function getSubjects() external view returns(address[] memory);

Get the subject details.

function getSubject(address addr) external view returns(Subject memory);

Start the voting period.

function startVoting() external;

Vote positive for the subject.

function votePositive(address addr) external;

Vote negative for the subject.

function voteNegative(address addr) external;

Get the remaining time to the voting end in seconds.

function getRemainingTime() external view returns(uint256);

Get the voting results, sorted descending by votes.

function getResults() external view returns(Subject[] memory);

Create the contract

Now we create the contract, which implements the defined interface.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.21;

import "./IVoteD21.sol";

contract D21 is IVoteD21 {

}

Implement the logic

Implementation of the smart contract completely depends on you. There are many possible ways how to achieve it. The only important thing is, to follow the IVoteD21 interface. If you have any blocker or questions, don’t hesitate to ask.

Consultations

  1. Join our discord at https://discord.gg/x7qXXnGCsa
  2. Join channel cvut-fit-nie-blo-23

Classification criteria

  1. Implementation - 20 points (all the use cases must be implemented correctly, and all our tests must pass)
  2. Unit tests - 10 points (10 points for unit tests with full coverage)
  3. Gas optimizations - 10 points (make getResults function as efficient as possible)

Deadline

  1. December 1, 2023

Testing framework

The tests should be implemented in the Wake testing framework. Wake will be discussed in the upcoming tutorials.

Submission

  1. Fill the form: https://forms.gle/R3Zg1n5jUowuHUDZ7
  2. We will create a GitHub repo for you and send you an invitation.
  3. Push your sources until the deadline.
  4. Relax

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.