Solidity 101: Know your code

Solidity 101: Know your code
Article by
Date
December 20, 2022
Category
Blog

Solidity 101: Know your code

The chances are you carefully cross-checked the URL that was entered into your browser against a known good source, arrived at the landing page for the dApp you wanted to use, clicked connect and now you are reassured to see your wallet address on display in the top right corner?

This pattern is frequently seen for web3 interactions and good operational security (OpSec) habits are lessons we all must learn as we dive deeper into the rapidly developing world of crypto assets. Connecting your wallet to the dApp exposes information about you - how much is a topic for a separate discussion - however until you interact with the dApp nobody can access the assets you hold. Before you sign any transaction it is worth your time to review the code you are about to interact with before you inadvertently agree to something you did not intend!

It can be daunting the first time you review the code however this series will aim to help you harden your due diligence steps. If you can manage formulae in Excel then you can absolutely manage a basic code review. If at any point something looks out of place, disconnect your wallet and avoid interacting with the dApp.

What is Solidity?

Solidity is a programming language that runs on the Ethereum Virtual Machine (EVM). Solidity is the most well-known language for Smart Contracts and makes dApps on Ethereum possible. Solidity proliferates on the many EVM-compatible chains such as Polygon, Cosmos, Hedera, and Avalanche. Knowing enough solidity to dissect a Smart Contract will serve you well as you descend deeper into web3.

The EVM is essentially an ephemeral computer in the sky that is capable of running code - Smart Contracts written in Solidity - and storing the state for as long as the network is alive. Solidity is a Turing-complete programming language in that you can implement any algorithm to solve a computational problem given the appropriate resources. The EVM solves the challenge of variable demand on the computation resources of the network by varying the cost of those resources as represented by the 'gas' paid when interacting with a dApp. That you spend ETH as gas to power the contract is one reason why people call ETH a utility token.

Smart Contract Risk

When you sign an interaction with a Smart Contract you are in effect delegating your authority to that contract to execute on your behalf. Remember that Solidity is a Turing-complete language that can make anything possible so before interacting pause, take a deep breath, and be sure you know what you believe the contract will be doing on your behalf.

Putting aside deliberately malicious code for a moment there is always a risk as any code of sufficient complexity has a chance of unintended bugs in the code. This has been seen many times as the root cause of a 'hack' where exploiters took advantage of code errors that occurred due to the developers over-optimizing the code to reduce the resource requirements of the Smart Contract given the perceived high costs of gas - clearly a false economy when it goes wrong.

Decentralization is a spectrum and it is an individual decision of how you allocate your trust. Some will rely on verifying the address of the Smart Contract with a secondary source and stop there. Others will read through the code line-by-line. A final group will leave it to hope and hope is not an investment strategy. A healthy hybrid model of 'trust but verify' is typically a good balance where you only interact with contracts known to you and verified by a secondary source as well as scanning the code for any unhealthy practices to judge the risk being undertaken.

Code is Law

As cliché as it may be, the mantra of 'not your keys, not your coins', holds true more than ever when interacting with a Smart Contract. It is important to be armed and ready when you are about to interact with a Smart Contract. Everyone should be able to read and understand what is happening at a high level.

One of the attractions of web3 and DeFi is the sovereignty and finality of transactions. This is of course a double-edged sword when you inadvertently interact with malicious code or accidentally send your digital assets to the wrong place. There is - generally - no higher authority to directly appeal to in order to have a contract reverted so it pays to be on high alert.

Whether code is truly law or whether this is merely a social convention will depend on your views regarding jurisdiction which is far past the scope of this article however it is fair to say it is better not to be in a situation where you require a third-party to intervene and a tool in your arsenal to achieve that is to be able to read the code.

Reading Solidity

In part 1 of this series, we will jump into some clean solidity code to help build familiarity. In future instalments, we will dissect code out in the wild.

A good starting place to learn is to review the code libraries of a best-in-class code auditor for Smart Contracts. Open Zeppelin (OZ) is a well-respected entity that falls into that classification that makes various libraries available to Solidity developers. Seeing a Smart Contract out in the wild that makes use of battle-tested code libraries from OZ rather than reinventing the wheel is typically a good coincident indicator although not sufficient to engender trust on its own.

Below is the source code for OZ implementation of a Vesting Wallet, a regular design pattern seen in the digital asset space. It is designed to enshrine in code a vesting schedule for one or more ERC20 tokens and/or some amount of ETH to a single beneficiary according to the defined schedule in a trustless and decentralized manner. Remember all Solidity files end in ".sol" and with that, roll up your sleeves and we will jump in...

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/finance/Vesting Wallet.sol

// SPDX-License-Identifier: MIT

// OpenZeppelin Contracts (last updated v4.7.0) (finance/VestingWallet.sol) pragma solidity ^0.8.0;

import "../token/ERC20/utils/SafeERC20.sol";

import "../utils/Address.sol";

import "../utils/Context.sol";

contract VestingWallet is Context {

   event EtherReleased(uint256 amount);

   event ERC20Released(address indexed token, uint256 amount);

   uint256 private _released;

   mapping(address => uint256) private _erc20Released;

   address private immutable _beneficiary;

   uint64 private immutable _start;

   uint64 private immutable _duration;

   constructor(

       address beneficiaryAddress,

       uint64 startTimestamp,

       uint64 durationSeconds

   ) payable {

       require(beneficiaryAddress != address(0), "VestingWallet: beneficiary is zero address");

       _beneficiary = beneficiaryAddress;

       _start = startTimestamp;

       _duration = durationSeconds;

   }

   receive() external payable virtual {}

   function beneficiary() public view virtual returns (address) {

       return _beneficiary;

   }

   function start() public view virtual returns (uint256) {

       return _start;

   }

   function duration() public view virtual returns (uint256) {

       return _duration;

   }

   function released() public view virtual returns (uint256) {

       return _released;

   }

   function released(address token) public view virtual returns (uint256) {

       return _erc20Released[token];

   }

   function releasable() public view virtual returns (uint256) {

       return vestedAmount(uint64(block.timestamp)) - released();

   }

   function releasable(address token) public view virtual returns (uint256) {

       return vestedAmount(token, uint64(block.timestamp)) - released(token);

   }

   function release() public virtual {

       uint256 amount = releasable();

       _released += amount;

       emit EtherReleased(amount);

       Address.sendValue(payable(beneficiary()), amount);

   }

   function release(address token) public virtual {

       uint256 amount = releasable(token);

       _erc20Released[token] += amount;

       emit ERC20Released(token, amount);

       SafeERC20.safeTransfer(IERC20(token), beneficiary(), amount);

   }

   function vestedAmount(uint64 timestamp) public view virtual returns (uint256) {

       return _vestingSchedule(address(this).balance + released(), timestamp);

   }

   function vestedAmount(address token, uint64 timestamp) public view virtual returns (uint256) {

       return _vestingSchedule(IERC20(token).balanceOf(address(this)) + released(token), timestamp);

   }

   function _vestingSchedule(uint256 totalAllocation, uint64 timestamp) internal view virtual returns (uint256) {

       if (timestamp < start()) {

           return 0;

       } else if (timestamp > start() + duration()) {

           return totalAllocation;

       } else {

           return (totalAllocation * (timestamp - start())) / duration();

       }

   }

}

Version & Imports

pragma solidity ^0.8.0;

import "../token/ERC20/utils/SafeERC20.sol";

import "../utils/Address.sol";

import "../utils/Context.sol";

Staring at the top you will find familiar code in every Solidity contract. The first line tells the compiler what version is appropriate to build the machine-readable code from this human readable format - in this case, a compiler of version 0.8.X is required. You may see this written as >= 0.8.0 <0.9.0 instead as these statements are equivalent.

Next, we have a series of import statements which are pulling in other contracts that we will rely on. The "../" syntax means the other Solidity files are found relative to this file so are also OZ implementations for example https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Address.sol

Basic Shell

contract VestingWallet is Context {

   event EtherReleased(uint256 amount);

   event ERC20Released(address indexed token, uint256 amount);

   uint256 private _released;

   mapping(address => uint256) private _erc20Released;

   address private immutable _beneficiary;

   uint64 private immutable _start;

   uint64 private immutable _duration;

   constructor(...) payable {

       ....

   }

}

Solidity is a curly brace {} language so you can tell where a contract or method starts and ends by mapping the braces. Most editors - e.g. Remix or Visual Studio Code - will highlight these for you to help you keep track. Each file will contain one or more contract definitions. Here we see a contract of type VestingWallet is declared with the keyword is. The is keyword represents an inheritance pattern so in this case, the VestingWallet contract is inheriting Context - another OZ utility library - meaning all functionality from the Context.sol contract is also available within the VestingWallet contract. Always check what is being inherited in case the code of concern lurks in that contract instead of the one you are reading.

Next, we see two event declarations. An event is simply a way of broadcasting to the world that something has happened with information that may be useful to other parties that are subscribed to these messages for example the front-end webpage of a dApp. Each event is defined with arguments of the data types that will be provided. A unit256 is an unsigned integer - which simply means positive values only - ranging from 0 to 2^256-1 (an exceptionally big number) and an address is a unique EVM-compatible address representing a token/wallet/contract. Both of these data items are called primitives meaning they are native building blocks for the EVM.

After this, we have our state variables which represent data storage for the contract. This data storage persists whilst the contract remains deployed and can be updated as the contract is interacted with. Each variable is marked with a modifier for visibility, in this case, all are private which means you can only access the data through methods specified by the contract which may have further access control restrictions. By contrast, a variable marked public will be visible - although not updateable - to anyone through an automatically generated method to get the data (imaginatively known as  a getter). The one new variable type to mention is mapping which - like a phonebook - allows the tying of a list of addresses to a numerical value representing how many tokens have been released to each user. One more keyword to notice is immutable which makes the state of the variable read-only once it has been set inside the constructor. We can be confident nothing will be able to alter the beneficiary, start or duration controls for a specific deployment of this contract once they have been configured.

Finally, we have the constructor method. This is a special method that is called when the instance of a contract is deployed. This method will perform the initial scaffolding to prime the contract for usage.

Constructor

constructor(

   address beneficiaryAddress,

   uint64 startTimestamp,

   uint64 durationSeconds

) payable {

   require(beneficiaryAddress != address(0), "VestingWallet: beneficiary is zero address");

   _beneficiary = beneficiaryAddress;

   _start = startTimestamp;

   _duration = durationSeconds;

}

The constructor method can only be called a single time upon deployment of the contract and is configured to require a set of arguments. In this case a self-explanatory set of data that is required to define a vesting agreement. The constructor is marked as payable which means the wallet interacting with the method is able to send ETH to this contract at the same time as deploying it.

We see the start of the method where the { opens and meet the require function. Require is an efficient way to run precondition checks and fail fast if they are not met; it has the benefit of returning any unspent gas minimising costs to the user. Specifically, this is checking the beneficiary is not the null/zero address - important as we remember this variable is immutable. From there the method sets the state variables to be equal to the arguments supplied.

Basic Methods

receive() external payable virtual {}

function beneficiary() public view virtual returns (address) {

   return _beneficiary;

}

function start() public view virtual returns (uint256) {

   return _start;

}

function duration() public view virtual returns (uint256) {

   return _duration;

}

function released() public view virtual returns (uint256) {

   return _released;

}

function released(address token) public view virtual returns (uint256) {

   return _erc20Released[token];

}

function releasable() public view virtual returns (uint256) {

   return vestedAmount(uint64(block.timestamp)) - released();

}

function releasable(address token) public view virtual returns (uint256) {

   return vestedAmount(token, uint64(block.timestamp)) - released(token);

}

These methods introduce a few more keywords that are important to be aware of. The receive method is a default method of a contract hence there is no function keyword yet it is still a method; this method allows the contract to receive value in the form of ETH sent to the contract which will then be released to the stated beneficiary according to the vesting schedule. It is marked as external which means it can only be called from outside the contract i.e. no other method can call it within the code only an external address. It is also marked as virtual which means it can be overridden - replaced with alternative code - in another contract, if it is inheriting from this one -> contract CustomVestingWallet is VestingWallet

The rest of the methods are getter methods used to return data to the caller and defined using the function keyword. They are marked as public which means they are callable from outside the contract and from inside the contract - in contrast to the external scope mentioned before. The methods are marked with the view modifier flagging to the compiler that the method will not change the state of the deployed contract. Finally, we see the returns keyword which defines the data type that is returned when you call the function. The function name, the arguments and the return types form a type of signature which helps standardise how we interact with contracts - this is known as the contract Application Binary Interface (ABI).

Diving a little deeper into the content, released retrieves how many tokens have been released and comes in two forms - one with no argument that is used when the VestingWallet is handling ETH and a second where the ERC20 token address is passed when the VestingWallet is handling other ERC20 tokens. The expression, block.timestamp is a numerical representation of time on the blockchain and when you see a function name followed by () - released() or released(token) for example - is a function calling another function during the evaluation. One more thought that is convention rather than a requirement is that internal state variables or methods start with '_' allowing you to detect whether to expect the code to be accessing the contract state or internal only method vs. a local variable, an argument passed to a method or a call to a function that may be also accessible externally. Again, good for readability so is to be encouraged even if it is not a strict necessity.

State Modifying Methods

function release() public virtual {

   uint256 amount = releasable();

   _released += amount;

   emit EtherReleased(amount);

   Address.sendValue(payable(beneficiary()), amount);

}

function release(address token) public virtual {

   uint256 amount = releasable(token);

   _erc20Released[token] += amount;

   emit ERC20Released(token, amount);

   SafeERC20.safeTransfer(IERC20(token), beneficiary(), amount);

}

These two methods do not have the view modifier so we know they are changing the state of the contract. The release() method calls the internal method to find how many tokens are eligible to be released, adds it to the internal state variable _released, emits an event message of the amount released for observers outside the blockchain and finally sends the amount of released ETH tokens to the beneficiary that is immutably encoded to the contract deployment. There are several different methods to transfer ETH value in Solidity and the OZ implementation Address.sendValue() is considered a robust method. Re-entrance note: the adjustment to the state value _released occurs before the token is sent to protect against a reentrancy hack where contracts are attacked in a recursive loop to drain funds; look for this pattern or a rentrancyGuard modifier to the method to prevent such attacks.

The second method is a variant of the first accomplishing the same goal with the key distinction that it handles ERC20 tokens in place of ETH. The first is called without an argument - for ETH vesting - whilst the second is called passing in a specific token address for ERC20 token vesting. The method then stores the amount of the specific ERC20 token that is about to be released in the _erc20Released[] mapping indexed by the address of the ERC20 token specified before again emitting a message and transferring the tokens out in a pattern designed to remove reentrancy risk. The mapping is dynamic in size so the same deployment of the VestingWallet contract could be used to control the vesting of multiple ERC20 tokens as well as ETH based on what has been sent to the deployed contract.

Vesting Schedule

function vestedAmount(address token, uint64 timestamp) public view virtual returns (uint256) {

   return _vestingSchedule(IERC20(token).balanceOf(address(this)) + released(token), timestamp);

}

function vestedAmount(uint64 timestamp) public view virtual returns (uint256) {

   return _vestingSchedule(address(this).balance + released(), timestamp);

}

function _vestingSchedule(uint256 totalAllocation, uint64 timestamp) internal view virtual returns (uint256) {

   if (timestamp < start()) {

       return 0;

   } else if (timestamp > start() + duration()) {

       return totalAllocation;

   } else {

       return (totalAllocation * (timestamp - start())) / duration();

   }

}

The most interesting piece of the puzzle is saved for last. The vestedAmount methods are accessible both internally and externally thanks to the public keyword and provide access to internal state variables to assess the amount of ETH or specified ERC20 token that is currently eligible to vest based on the call to the internal method _vestingSchedule; note the _ in the method name signifying it is should be marked to have internal visibility however do always check the developer followed through on this intent. All three methods are again marked as virtual to allow them to be overridden in a child class that inherits (is) VestingWallet should you wish to implement a more sophisticated vesting schedule in place of the simple straight-line linear implementation that is present by default.

The _vestingSchedule method takes in the totalAllocation of the specified token - defined as the current balance owned by the VestingWallet contract plus the amount previously emitted tracked by the relevant released() method - and the current timestamp of the blockchain. There is then a simple if() logic statement common to most programming languages:

  • If the current time is before the start() time then nothing is released
  • If the current time is after the full vesting period has been completed - defined as the start time plus the encoded duration() - then release totalAllocation.
  • Else a proportion of the totalAllocation based on time elapsed since the start time divided by the total duration of the vesting period

Closing Thoughts

Deep breath.

We made it to the end.

Hopefully, this article has shown you that reading through Solidity code need not be too daunting. Once you get the hang of it and are aware of the keywords the implementation is rather elegant. The ability to hold an asset in escrow on a vesting schedule for a known address in a trustless and decentralized manner with relatively little code when you harness the flexibility of the blockchain is quite remarkable.

Next time we will leave the classroom and migrate from undeployed code to a contract out in the wild. Homework ahead of time - read the next smart contract you are about to interact with!