Solidity is the most popular smart contract development language at the time of writing.
I felt there was still a gap to be filled with regards to a comprehensive Solidity book, that starts from scratch and introduces beginners to the latest tech from the very beginning.
This mdbook will start from a barebones hello world contract, and will increase in complexity, as is the case with other books that teach a programming language.
Foundry is a powerful framework that allows us to build, test and deploy smart contracts to the blockchain straight out of the command line. This book will be using Foundry's tooling to write and test our smart contracts.
Most, if not all chapters in this book will contain at least two sections:
- A section explaining the actual smart contract, and the required concepts.
- Another one dedicated to writing a corresponding test, no matter how simple, using Foundry's toolkit.
There are two main ways of deriving value from this book:
- For beginners who just want to learn Solidity but don't want to pick up Foundry for now, you can just read the first section of each chapter, and try to replicate it in the Remix IDE. In case you find the Remix IDE intimidating, you can go through this lesson from Patrick Collins' 32-hour course.
- For folks interested in getting started with Foundry, I recommend you follow all sections of each chapter.
Here are some resources that this book is inspired by:
TL;DR: This project is an inspired twist on other sources of learning Solidity. I will be starting from scratch assuming no experience with Solidity. I do however expect a basic understanding of how blockchains work.
β οΈ Full disclosure
I am not in any way associated with the core Foundry team, and don't wanna be seen as piggybacking off of them. This mdbook is a personal labour of love, with the final goal being for this to one day serve as a free, comprehensive and open-sourced resource for wanabee Solidity devs.
Getting Started
To follow along, I highly recommend that you install Foundry on your system. The exact installation instructions might vary depending on your system, but the Foundry book is quite comprehensive.
Also, feel free to use Remix for an interactive UI to interact with your smart contracts, although I'll be using Foundry throughout. This will still be a useful resource for you if you decide to stick with Remix, which is more beginner friendly.
Once you have all the CLI tools part of Foundry installed in your system, you can get started by creating a new directory and running:
$ forge init
π Note: Foundry is a modular toolchain that consists currently of 4 different CLI tools. We will primarily be using Forge throughout, but you can read more about each of these tools in the Foundry book.
You will notice a bunch of new files and directories. Here are the ones you primarily need to worry about for now:
-
lib: This directory is where all your dependencies are stored. You will find yourself relying on Solidity code written by others much too often. This directory is where all of that will be stored.
-
src: The src directory is where you typically store all your smart contract code.
-
test: The test directory is where you typically store all your test files.
-
script: This is where you typically write scripts that deploy new smart contracts to the blockchain, and interact with existing ones.
-
foundry.toml: We can use the toml file to customise virtually all aspects of Foundry's behaviour.
For now, to keep things simple, we will only be working with the src directory. All our code and tests will go inside this directory.
To test if your Foundry project was properly initialized, run:
$ forge build
This command compiles all the Solidity files within the parent directory, not just the src directory. For now, Forge will simply compile the Solidity code that ships along with the default initialization of a Foundry project.
Hello World
Before getting started with our first contract, let us set up the diectory structure I will be following throughout the book. As I said before, we will be storing all our code within the src directory.
- Create a directory called
SolidityBasics
insidesrc
. This directory will contain all the chapters that deal with basic Solidity Syntax. - Create another directory called
HelloWorld
insideSolidityBasics
. This directory will contain all the code related to the Hello World contract. Each chapter will have its own directory. - Create a file called
HelloWorld.sol
insideHelloWorld
.
Your directory structure should look something like this:
βββ src/
β βββ SolidityBasics/
β β βββ HelloWorld/
| | β βββ HelloWorld.sol
β β β βββ HelloWorld.t.sol
You are now ready to write your first smart contract in Solidity, and learn about what all goes inside a bare-bones smart contract.
HelloWorld.sol
The first line of almost all smart contracts starts something like this:
// SPDX-License-Identifier: MIT
-
The SPDX license does not have anything to do with the code itself. The Solidity Compiler (solc) encourages the use of a license at the top of every Solidity file to clearly define the copyright legalities of the code.
-
I will be using the MIT license throughout the book, one of the most permissive licenses out there. Any code written in this book is freely available to anyone and everyone for any use. A full list of all SPDX licenses can be found on their website.
Paste this below the License identifier:
pragma solidity ^0.8.19;
- Every Solidity codebase must specify the versions of the Solidity Compiler(solc) it is compatible with.
- Long story short, code written for older versions of the solc might not be compatible with the newer ones.
- It is important therefore, to make sure that the code you are writing is compiled with the correct version of the solc.
There are 3 main ways to specify the solc version:
-
pragma solidity ^0.8.0;
This will ensure that your contract is compiled with a solc version equal to or greater than 0.8.0, but less than 0.9.0. The idea here is to that your contract won't be compiled with a solc version that will break your code. The solc will introduce breaking changes only with major versions. These versions have the form 0.x.0 or x.0.0. -
pragma solidity >=0.8.0 <0.8.14;
This is a way to specify the exact range of the solc you want to use in your contract. -
pragma solidity 0.8.0
This way you can make sure your contract only compiles with a specific compiler version.
Now we are ready to initialize a new smart contract using the contract
keyword:
contract HelloWorld { }
All the code you write goes within these curly brackets.
π Note: A single Solidity file may contain multiple smart contracts within it. Pragma defines the Solidity version for the entire file, not a single contract.
Our contract consists of a single string that returns the string "Hello world". Paste this within the curly brackets:
string public greeting = "Hello World";
And that's it. You created a Hello World smart contract. Your code should look something like this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract HelloWorld {
string public greeting = "Hello World";
}
If you are working with Remix, you can compile your contract by clicking Ctrl + S
.
If you are working with Foundry, make sure you have a terminal open in the parent directory, the same directory where you initalized the project. Then run the following command:
forge build
This command will compile all the Solidity files that exist within the parent directory.
If you've reached this far, and want to write a corresponding test for this contract, create a new file named HelloWorld.t.sol
on the same level as this file.
HelloWorld.t.sol
Foundry allows us to write tests using Solidity, allowing Solidity developers the comfort of not having to use a language they are not comfortable with.
Each 'test' is a single function within a smart contract. These typically have a condition that has to be satisfied. If the condition is true, the test passes; otherwise it fails.
Since HelloWorld.t.sol
is basically another Solidity file from Forge's perspective, it will begin like other Solidity files do:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
Next, we import two smart contracts into this file:
import "forge-std/Test.sol";
import "./HelloWorld.sol";
- The first import is the Forge test library, which contains the
Test
contract. Foundry lets us use its' testing suite by exposing functions from within the test contract to us. - The second import is the smart contract we are testing.
π Note: Please note that Solidity as a language supports inheritance, but not all smart contract languages do so. Do not worry too much about how inheritance works, we will look into it in more detail later on.
Next, we initialize the HelloWorld_test
contract like this:
contract HelloWorld_test is Test {
}
This is the contract that will contain all of our tests. We inherit from the Test
contract, which allows us to use the testing suite.
Next, we initialize a test function within the contract like this:
function testgreeting() public {
}
Within the function, create a new instance of the HelloWorld contract like this:
HelloWorld helloWorld = new HelloWorld();
We can now use the helloWorld
variable to access the functions within the HelloWorld
contract.
Lastly we use assertEq
to assert equality between two values. If the values are equal, the test passes; otherwise it fails.
This is what the test function should look like:
function testgreeting() public {
HelloWorld helloWorld = new HelloWorld();
assertEq(helloWorld.greeting(), "Hello World");
}
Now understand this carefully. We use Forge to compile and test our smart contracts. Forge compiles all smart contracts in a codebase indiscriminately. This means that it will compile all smart contracts, including the test contracts.
Forge distingueshes the indiviual test functions by looking at function names. Any function beginning with the string "test" is executed as a test function. This means that "testgreeting" is a valid name for a test function, but "greetingtest" is not.
Make sure to save your files and compile them using the build command. We can now execute the test function by running the following command:
forge test --match-path src/SolidityBasics/HelloWorld/HelloWorld.t.sol
We can use the match path flag to specify which test file we want to run. This is useful when we have multiple test files in our codebase, and don't want to run all of them.
This is what your terminal should look like right now:
And that's it!
This is all there is to writing a basic Solidity test in Foundry.
Now there's a variety of different ways to write more complex and comprehensive tests, but we will look into those later on, when we deal with more complex smart contracts.
Types in Solidity
Solidity is a statically typed language, which means that the type of every variable needs to be defined at compile time. Solidity has many different types for us to use. They can be broadly classified into two categories:
1. Value Types
Variables of value types store their data in a piece of memory they own. These variables are passed by value when assigned to new variables or passed as function arguments. Changing the value of the second variable won't alter the value of the original. Solidity consists of the following value types:
- Booleans (
bool
) - Unsigned integers (
uint
) - Signed integers (
int
) - Addresses (
address
) - Enums (
enum
) - Bytes (
bytes
)
2. Reference Types
Variables of reference types store a reference to the data in a piece of memory they don't own. That piece of memory could be used by other reference type variables as well. These variables are passed by reference when assigned to new variables or passed as function arguments. A change in the value of the second variable can alter the value of the original. Solidity consists of the following reference types:
- Arrays (
[]
) - Structs (
struct
) - Mappings (
mapping
)
Consider this bit of Solidity code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract ValueType_vs_ReferenceType {
//Value Type
uint public valueVar1 = 10;
// Reference Type: array of unsigned integers
uint[] public referenceVar1 = [1, 2, 3];
function modifyValueType(uint newValue) public view {
// New Variable assigned the value of valueVar1
uint valueVar2 = valueVar1;
//New variable passed
valueVar2 = newValue;
}
function modifyReferenceType(uint index, uint newValue) public {
// New variable, referenceVar2, refers to the same storage location as referenceVar1
uint[] storage referenceVar2 = referenceVar1;
// Modifying the localReference will modify referenceVar
referenceVar2[index] = newValue;
}
}
This contract consists of two state variables, and two public functions:
-
modifyValueType()
creates a new value type in memory, and assigns it an initial value from the original value type, before finally assigning it the value passed as the function argument. This won't change the value ofvalueVar1
, since only a temporary copy of the variable was used inside the function. -
modifyReferenceType()
creates a new reference type in memory that points to the same storage location asreferenceVar1
. Any change of value inreferenceVar2
will also be reflected inreferenceVar1
.
We can write a small test contract for the snippet as follows:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console2} from "forge-std/Test.sol";
import "./Types.sol";
contract Types_test is Test {
ValueType_vs_ReferenceType types;
function setUp() public {
types = new ValueType_vs_ReferenceType();
}
function test_modifyValueType() public {
types.modifyValueType(1234);
// Check that the value of valueVar1 is still 10
assertEq(types.valueVar1(), 10);
}
function test_modifyReferenceType() public {
console2.log("The original value of referenceVar1[0] is", types.referenceVar1(0));
types.modifyReferenceType(0, 1234);
// Check that the value of referenceVar1[0] is now 1234
assertEq(types.referenceVar1(0), 1234);
}
}
π Note: The
Console2
library is used to log values to the console. It is imported from theforge-std
package, just like the mainTest
library.
Value Types
Boolean
A bool
variable can have two values: true
or false
.
Solidity supports the following operations on booleans:
==
(equality)!=
(inequality)!
(logical negation)||
(logical disjunction, βORβ)&&
(logical conjunction, βANDβ)
Integers
Solidity supports signed and unsigned integers of various sizes. They are represented using the int
and uint
keywords respectively, followed by the number of bits they occupy.
For example, int256
is a signed integer occupying 256 bits, and uint8
is an unsigned integer occupying 8 bits.
Solidity supports integers of sizes 8 bits to 256 bits, in steps of 8.
Integers can be initialized as int
or uint
without specifying the number of bits they occupy. In this case, they occupy 256 bits.
π Note: All integers in Solidity are limitied to a certain range. For example,
uint256
can store a value between 0 and 2256-1. Sinceint256
is a signed integer, it can store a value between -2255 and 2255-1.
Addresses
An address
variable stores a 20-byte/160-bits value (size of an Ethereum address).
π Note: EVM addresses are 40 characters long, however they are often represented as hexadecimal strings with a
0x
prefix. But strictly speaking, the address itself is 40 characters.
Solidity allows us to initialize a variable of type address
in two ways:
address
: A simple 20-byte value that represents an EVM address. We can query the balance of an address variable using thebalance()
method.address payable
: Any address variable initialzed with thepayable
keyword comes with two additional functions,transfer()
andsend()
, that allow us to send ETH to the address.
Any integer can be typecasted into an address like this:
address(1) == address(0x1) == 0x0000000000000000000000000000000000000001
In this case, the integer 1
will be treated as a uint160
, which can be implicitly converted into an address type.
Enums
Enums are a user-defined type that can have upto 256 members. They are declared using the enum
keyword.
Each member of an enum corresponds to an integer value, starting from 0.
However, each member can be referenced directly by using its' explicit name.
Consider this example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract TestEnum {
// Define an enum named Directions
enum Directions { Center, Up, Down, Left, Right }
// Declare a state variable of type Directions with default value (Center, the first enum member)
Directions public defaultDirection;
// Declare and initialize another state variable
Directions public setDirection = Directions.Right;
// Change the direction
function changeDirection(Directions newDirection) public {
setDirection = newDirection;
}
// Get the maximum value of the Directions enum (i.e., Right in this case)
function getMaxEnumValue() public pure returns (Directions) {
return type(Directions).max;
}
}
Fixed-size byte arrays
The bytes
type is used to store raw byte data. Even though bytes are always stored as an array of characters, fixed-size byte arrays are a value type, while dynamic-size byte arrays are reference type.
A fixed size byte array can be anywhere between 1 and 32 bytes in size.
They are declared as:
bytes1
, bytes2
, bytes3
, .............. bytes32
.
Each byte can store 2 characters. Therefore, a bytes20
variable can store upto 40 characters, enough for an Ethereum address.
π Note: All byte variables come with a
length
property that can be used to get the length of the bytes array.
Here is a code snippet that demonstrates the use of fixed-size byte arrays:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract ByteContract {
// Declare a bytes20 variable to store data
bytes20 public data;
// Function to set data
function setData(bytes20 _data) public {
data = _data;
}
// Function to get the length of the bytes variable
function getFirstByte() public view returns (bytes1) {
return data[0];
}
function getLength() public view returns (uint){
return data.length;
}
}
Reference Types
To be Written soon