Released: Friday March 25, 2022.
Due: Sunday April 24, 2022 at 11:59PM ET.
Welcome to the Solidity lab! This assignment will help you set up your development environment and get you up to speed with the language you'll use to develop smart contracts for blockchains using the Ethereum Virtual Machine (EVM). You'll also use Solidity for the last course project: Swap!
Solidity may look similar to languages you've used before, but there are several quirks and features that make it a bit tricky. If you have any questions about this language, please ask the TA staff on EdStem or through the #solidity-lab
Discord channel!
We know this handout is quite long, but we wanted to provide a one-stop guide to Solidity for all the smart contract development you'll do in CS1951L. Even if you're already proficient in the language, everybody benefits from a brief refresher; we recommend you at least skim through our guide before jumping into the assignment.
Before we can start writing smart contracts, we must first set up your development environment. After following the setup instructions below, clone the stencil from Github Classroom to get started on the assignment.
We will use a framework called Hardhat to help us build smart contracts for blockchains using the EVM. Hardhat will help us compile, deploy, test, and debug our smart contracts.
You must have Node installed before you can use Hardhat. You can check by running node -v
in the terminal. If you see v16.14.2
or greater, you're good to go!
If node -v
returns an error or a version lower than v16.14.2
, you must install Node from here. Download the "v16.14.2 LTS" version.
Hardhat projects typically use the following file structure:
contracts/
: Directory for your Solidity contractstest/
: Directory for test files for testing your contractshardhat.config.js
: Hardhat configuration fileWe will now go over a basic, high-level workflow for developing a new smart contract with Hardhat. For this assignment, we've already set up the project for you; don't worry about these steps.
npm init -y
.npm install --save-dev hardhat
.npx hardhat
Create a basic sample project
. You can choose the default options for all the prompts, especially the one asking to install npm packages.contracts/
directory: cd contracts
touch YourContractName.sol
.sol
file has just been created in the contracts/
directory. Open this in your favorite text editor and write the contract code.test/
directory: ../test
touch your-test-name.js
.js
file has just been created in the test/
directory. You can learn more about writing tests with Hardhat here.npx hardhat test
.There's much more to Hardhat than we covered here, so we highly recommend you look through the Hardhat Documentation to get a better idea of everything you can do. We promise it's worth a look!
Remix is an IDE that allows developing, debugging, testing, deploying, and administering smart contracts for EVM blockchains. It looks similar to VSCode (it uses Monoco, the same text editor as VSCode), but it runs in the browser and has tons of features geared towards smart contract development.
To work with local projects, we must first connect your file system to the Remix IDE. To do so, we will use the remixd
NPM package. Install it by running npm install -g @remix-project/remixd
in your terminal (again, you may need to use sudo
if you're on Mac).
After you install remixd
, you can now use it to create a bridge between your project and the Remix IDE. In your terminal, run the following command: remixd -s <absolute-path-to-the-shared-folder> --remix-ide https://remix.ethereum.org
, replacing the shared folder path with the absolute path to your Hardhat project (the repo you cloned).
Now, open the Remix IDE in your browser, expand the "Workspaces" dropdown under the File Explore sidebar, click "- connect to localhost -", and click "Connect". This will open your project directory in the Remix window. Yay!
You can learn all about Remix's various features here. They're super helpful for smart contract development within CS1951L and beyond!
More of a VSCode kind of person? We got you. If you would like to use VSCode to write Solidity, we recommend this Solidity extension, which provides syntax highlighting, autocomplete, and more!
contracts/Election.sol
. Make sure your error handling code reverts with the exact error messages used within the test suite or the autograder may deduct points.This section will give a (not so) brief tour of the essential Solidity language features you'll need to know. Please use this section as a reference throughout the course; we think it will be very helpful!
Solidity is a new, continuously evolving language that may result in syntax changes over time. Whenever new features or improvements are introduced, there is a new version. For this assignment, we will use version 0.8.13 (the latest version at the time of writing).
Contracts are the fundamental building block of Ethereum applications – they encapsulate all the variables and functions of a smart contract. They're analogous to classes in object-oriented languages.
Unlike classes, contracts can be created and deployed on the blockchain. Once a contract is deployed, any function marked public
will be available for any Ethereum user to interact with, and the blockchain will persist its state across transactions for the lifetime of the contract (which is forever 😲 until the contract is deleted). Blockchains are so cool!
Here we have a simple smart contract. At the top, we see pragma solidity ^0.8.3;
. The pragma
keyword specifies which compiler version to use and is typically the first line for any Solidity file. At the time of writing, the latest version of Solidity is v0.8.3
, so we tell the compiler that the file won't compile with a version earlier than version 0.8.3, and it will also not work on a compiler starting from version 0.9.0 (that's what the ^
is doing).
Next, we use the contract
keyword to declare a new contract called HelloWorld
. This contract contains one function named sayHello
, which returns the string "Hello, world!"
when called. Nice!
You may ask, "what does public
and pure
and memory
mean?" Don't worry, we'll get to that shortly!
Solidity is statically typed, which means that you must specify the variable's type during declaration. There is no concept of "undefined" or "null" in Solidity, which means you must also initialize all declared variables with a value based on its type. If you don't provide an initial value, variables will be assigned to the default value for their type.
State Variables are variables whose values are permanently stored in a contract's storage – a data area that persists between function calls and transactions. Data stored here is stored on the blockchain, kind of like writing to a database. These can be initialized within a constructor
, which is a function that's called once when the contract is first created on the blockchain. You can also assign an initial value during declaration.
These values persist data through the contract's lifetime and are modified by the contract's functions.
State variables can have three types of scopes: public
, internal
, or private
. The default visibility of state variables is internal
.
public
state variables can be accessed by internal contract code as well as externally via messages. Automatic getter functions are generated for public state variables.internal
state variables can be accessed internally from the current contract or child contracts (contracts that inherit the current contract), but not from external contracts or callers.private
state variables can be accessed internally from the contract they are defined in, but not from child or external contracts.Local Variables are variables that are only accessible within the scope of the function where they're defined. Parameters to a function are always local to that function.
There are special variables defined in the global namespace that provide information about the blockchain and properties about the transaction calling your contract.
Read more about all the special variables here.
Whenever someone calls your contract, they must pay a certain amount of gas: a fee required to successfully send a transaction or execute a contract on the Ethereum blockchain. The purpose of gas is to limit the amount of work needed to execute the transaction all while paying for the execution at the same time. While the transaction is executed in the EVM, the gas is depleted according to specifc rules (for example, certain operations cost more gas to execute than others).
The gas price is a value set by the creator of the transaction, who has to pay gas_price * gas
up front from the sending account. If some gas is left after the execution, it is refunded to the caller in the same way. Transactions with a higher gas price get higher priority to be included in a block.
If the gas is used up at any point (i.e. it would be negative) before the transaction completes, an out-of-gas exception is triggered, which reverts all modifications made to the state in the current call. The spent gas is not refunded to the caller.
There are two upper bounds to the maximum amount of gas you can spend:
gas limit
: the maximum amount of gas you're willing to spend on a transaction.block gas limit
: the maximum amount of gas allowed in a block. This is determined by the network.Math in Solidity is familiar. The following operations are the same as in many other programming languages:
x + y
x - y
x * y
x / y
x % y
Solidity also supports the **
exponential operator:
Sometimes you need a more complex data type. For this, Solidity provides structs:
Structs allow you to combine multiple pieces of data into a single data type. You can access an individual member with the dot (.
) syntax:
When you want a collection of data, use an array. There are two types of arrays in Solidity: fixed arrays and dynamic arrays. Fixed arrays can be declared as such:
Elements at a specific index can be accessed/modified as such:
Storage arrays with dynamic lengths can be declared as state variables. These have no fixed size and can be resized by using push()
and pop()
:
You can also create an array of structs. Using the Champion struct from before:
Remember how state variables are permanently stored on the blockchain? Creating a dynamic storage array of structs, like this example, can be useful for storing structured data in your contracts (kind of like a database).
Memory arrays with dynamic length can be created using the new
operator. Unlike fixed arrays, you don't need to know the array's size at compile time; the size can be specified at runtime. However, unlike dynamic storage arrays, dynamic memory arrays cannot be resized once created (e.g. the .push
member functions are not available and you cannot assign .length
). You must either calculate the required size in advance or copy every element into a new memory array:
In Solidity, you can store variables in two locations — in storage
and in memory
.
Storage refers to data that's stored permanently on the blockchain. Memory, on the other hand, refers to temporary variables that are erased between external function calls to your contract. It's similar to the difference between your computer's hard disk vs. RAM.
You don't need to use these keywords most of the time because Solidity handles them by default. State variables are always storage
and written permanently to the blockchain. Local variables are by default memory
and will disappear when the function call returns.
However, there are times when you do need to use these keywords to explicitly define the data location, namely when using structs and arrays within functions:
No worries if you don't fully understand when to use memory
versus storage
– throughout this lab, we'll tell you when to what, and the Solidity compiler will also give you warnings to let you know when you should be using one of these keywords.
Right now, it's enough to understand that there are cases (mainly arrays and structs within functions) where you'll need to declare storage
or memory
explicitly!
Good Solidity code consumes as little Gas as possible. Storing data in Memory consumes far less Gas compared to storing data in Storage (contract storage is very expensive to use). In the vast majority of cases, it's better to use Memory for intermediate calculations and store the final result in Storage.
In other languages, you might want to store the result of an expensive calculation for further use down the line. However, in Solidity, it's possible that rerunning an expensive computation might still be much less costly than using Storage.
As you've already seen in the previous examples, functions are defined with the function
keyword as such:
Here we have a function named eatCookie
that takes two parameters: a string
and a uint
. For now, the body of the function is empty.
Note that we're specifying the function visibility as public
: this means that this function is callable by any Ethereum user. In addition to the visibility levels we discussed earlier, functions can also be marked external
. Functions marked external
can ONLY be called outside the contract — they can't be called by other functions inside that contract.
In general, it's good practice to mark your functions as private
by default, and then only make public
the functions you want to expose to the world. Same as function parameters, it's convention to prefix the names of private functions with an underscore (_
).
We also instruct the compiler that the _name
variable should be stored in memory
. We must explicitly state the data location for all reference types such as arrays
, structs
, mappings
, and strings
.
To understand reference types, we must understand the two ways in which you can pass an argument to a Solidity function:
Because complex types do not always fit into 256 bits, we have to handle them more carefully than value-types (uint, bool, etc.). Since copying them can be quite expensive, we have to think about whether we want them to be stored in memory
(which is not persisting) or storage
(where the state variables are held).
Note: By convention, function parameter variable names are prefixed with an underscore (_
) to differentiate them from state and global variables. This is not required, but it's the convention we'll use throughout the course.
Functions are called as such:
Just like Go, functions in Solidity can return multiple values:
View functions are declared with the view
keyword and cannot modify the state of the contract; the compiler will throw an error if you try. Think of view functions as "read-only." More specifically, view functions cannot:
selfdestruct
function.view
or pure
.The getter functions automatically provided by public state variables are by default view functions.
Pure functions are even more restrictive than view functions; they cannot read or modify the state of the contract. Think of pure functions as static functions in other object-oriented languages. In addition to the restrictions on view functions, pure functions cannot:
address(this).balance
or <address>.balance
.block
, tx
, msg
(although msg.sig
and msg.data
can be read).Note: It may be hard to remember when to mark functions as pure
or view
. A general rule of thumb is to start at the most restrictive level (pure
) and only move up as needed. Lucky for us, the Solidity compiler is really good at issuing warnings that let us know when you should use one of these modifiers.
The Fallback function is a special type of function available to contracts. As the name suggests, a fallback function is a function that is called when an otherwise non-existent function is called on the contract.
Here are the rules when it comes to the fallback function:
external
.If plain ether is sent to a contract (that is, ether is sent without data), it will be directed to the fallback function. The fallback function must be marked payable
for the payment to be received; otherwise, an exception will be thrown, and the transaction reverted.
While writing smart contracts, you may find yourself repeating boilerplate code that does stuff like validating inputs or restricting access. Solidity offers a way to reduce boilerplate through Modifiers, which is a mechanism to modify the behavior of a function. Modifiers are code that can be run before and/or after a function call.
We can create a modifier with or without parameters using the modifer
keyword as such:
Notice the _
symbol. This tells Solidity to continue executing the rest of the code. In this case, the modified function's body will execute in place of the _
. Using the onlyOwner
modifier as an example, the modified function will be executed if and only if the require
statement passes; otherwise, an exception is thrown.
We can also use modifiers to guard against reentrancy attacks:
Ah, our old pal: the if-statement. An if-statement is written as follows:
There are for
, while
, and do... while
loops:
Mappings are key-value stores that can be declared as the type:
The type of the key can be any built-in type in addition to bytes
and string
. No reference type or complex objects are allowed. The type of the value can be any type.
Mappings can only be stored in storage
and are typically only used as state variables. They cannot be used as parameters or return parameters of contract functions that are publicly visible. These restrictions are also true for arrays and structs that contain mappings.
Public mappings, like all other public state variables, have getter functions that are automatically generated. The getter function takes in the key as a parameter and returns the value.
You may have thought to yourself while reading this guide: "where are the print statements?" Good question! Because smart contracts run on the blockchain, outputting information is a little more complicated than printing to stdout. Instead, we log information onto the blockchain using Events.
Events are an abstraction on top of the EVM's logging capabilities. They are a mechanism in which your contract can send information to the transaction's log – a special data structure stored on the blockchain.
Hardhat actually provides the ability to print logging messages and contract variables by calling console.log()
from your contracts and tests on Hardhat Network. See our Testing and Debugging section for more info.
One of the most powerful features of smart contracts is the ability to call other smart contracts. However, before our contract can talk to another contract on the blockchain that we don't own, we need to define an interface.
Say there was a contract on the blockchain that looked like this:
This simple contract allows anyone to store their favorite number and associate it with their Ethereum address. After, anyone could look up that person's favorite number using their address.
Now imagine we had another contract that wanted to read the data stored in this contract via getNum
.
To do so, we must first define an interface of the FavoriteNumber
contract:
This looks like defining a contract, but there are a few differences:
getNum
), and we don't mention any other functions or state variables.{}
), we simply end the function header with a semi-colon (;
).It looks kind of like the skeleton of FavoriteNumber
, which is how the compiler knows it's an interface.
By including this interface in our code, our contract knows what the external contract's functions look like, what they take in, and what they return. Now that we have defined our interface, we can use it as such:
Now your contract can interact with any other contract on the Ethereum blockchain, as long as the functions you are calling are marked public
or external
.
Various functions are provided by Solidity for error handling. Typically, when an error occurs, the state of the contract is reverted back to its original (that is, the state before the contract was called). However, we can also implement checks to prevent unauthorized code access, and to protect against certain conditions:
assert(bool cond)
: Asserts that a condition must be met. If not, this function causes any state changes to be reverted. This is used for internal errors.require(bool cond, [string memory message])
: Asserts that a condition must be met. If not, this function causes any state changes to be reverted. This method is to be used for errors in inputs or external components. The optional message argument, if provided, allows you to log a custom error message.revert([string memory reason])
: When called, this function aborts the contract execution and reverts any state changes. The optional message argument, if provided, allows you to provide a custom message.Solidity is infamous for it vulnerablities and eccentricities. In this section, you will learn some (of the many) Solidity pitfalls so you that you can hopefully avoid them in your own code!
This code (specifically returnKeys()
) won't work. Why is that? That is because .keys()
is not a function you can call on a mapping. In fact, in Solidity there is essentially no way to get the set/list of keys from a mapping. This is because every key-value pair is initialized by default for Solidity mappings (meaning you also can't get a total set of values).
This also means you will have to be careful of certain appealing mapping usages like -
This works, but keep in mind that any address that has not yet been assigned in the mapping will still return a value of 0, as all pairs are initialized with a default value and Solidity has no concept of "null". As such, there is no way to tell if a key has been added into a mapping yet.
This is a common trend in Solidity, and it is good to remember that any declared variable is auto-intialized as the all zeroes representation of that type. For example:
As previously discussed, getIndex(0)
through getIndex(4)
will return 0, while getIndex(5)
will error.
Strings in Solidity are quite featureless and are unindexible, not concatenate-able, and do not support native ways of finding length. This is partially because of how strings are stored in Solidity. In general, strings in Solidity are one of the most expensive data structures to use, and should be avoided if possible.
Similarly, looping in Solidity is often best avoided due this same potentiality of high gas usage (a category of vulnerability often called "gas griefing"). As such, solutions that involve arrays (also an expensive data structure) are in most cases best repurposed to instead use mappings.
We talked about reentrancy attacks briefly during the syntax section, but it's useful to see why reentrancy is such a vulnerability:
Here is a simple contract that allows users to store ether (via the deposit()
function) as well as withdraw their total balance (via the widthdraw()
function). While seemingly robust, we can construct an exploitative contract that takes advantage of a mixture of the fallback()
function as well as reentry to drain this contract of its funds.
To initiate this exploit, all that must be done is for attack()
to be called. This sends 1 ether to EtherStore
so as to give the sender some balance. At this point, it withdraws that 1 ether. However, on line 12 of the EtherStore
contract, the transfering back of the balance activates the Attack
contract's fallback()
function. This function calls withdraw()
again, and since EtherStore
has not yet reached line 14 which resets the sender's balance, the contract still believes the sender has balance and sends 1 more ether back, then again activating the fallback()
function. This will continue until the contract simply has no more ether to send.
While a simple example, we hope that this shows you how small insecurities in Solidity code can be exploited to drastic proportions, as well as demonstrate how important it is to understand the structure and purpose of your contract!
Bonus: How would you fix this EtherStore
contract to make it secure against this kind of attack in particular?
Now let's see what you've learned! For this assignment, you will design a voting smart contract.
Of course, the main problems of electronic voting are how to assign voting rights to voters and how to prevent manipulation, all while preserving the privacy of the voter. We won't solve these problems in this assignment, but we will show how voting can be done, so that vote counting is automated and transparent.
The high-level idea is this: create one instance of the contract per election, providing a name for each candidate. The creator of the contract, who serves as the election leader
, will grant voting rights individually to each voter (identified by their address) by calling registerVoter
. Voters can then cast their vote by calling castVote
.
When you're ready for the results of the election, you can call winningProposal
to determine which proposal got the most votes. If winningProposal
is called before any votes have been cast, you should throw an error and revert the transaction.
Task: In contracts/Election.sol
, finish implementing the system described above using the provided functions and data structures.
After cloning the stencil, make sure you run npm install
to install all of the required dependencies.
We have already provided the tests we will use to autograde your assignment in the tests/
directory. Look through the test suite to see how we tested the contract and what error messages you must return to pass the tests. The autograder expects specific error messages when handling certain situations. You can run the test suite locally by running npx hardhat test
.
To run your tests, go into your test/
directory. We set up election-test.js
for you, although you are free to write your own tests in the same file.
You can print logging messages and contract variables by calling console.log()
while running contracts and tests on Hardhat Network. To do so, you must first import hardhat/console.sol
from your contract code:
You can use console.log
in functions as if you were using it in JavaScript:
and logging output will show when you run your tests!
This part of the lab is completely optional, but we thought it would be fun to show you a use of smart contracts that you've almost certainly heard of: NFTs!
Non-Fungible Tokens (NFTs) are a way to represent anything unique as an Ethereum-based asset. They're powered by smart contracts, which means you already know how to make them!
An NFT on Ethereum is simply a smart contract that conforms to the ERC-721 standard. ERC-721 is a standard that defines an API for smart contracts that represent NFTs; it requires the smart contract programmer to implement functions for transferring tokens from one account to another, getting the current token balance of an account, getting the owner of a specific token, and more.
Basically, if a smart contract implements all of these functions and events, it conforms to ERC-721 standard and is considered an NFT contract.
A common place of confusion surrounding tokens is mixing up two separate concepts: token contracts and the tokens themselves.
Token contracts are simply Ethereum smart contracts. Whenever someone "sends" a token, they are really calling a function on a smart contract that's been deployed on the blockchain. In essence, a token contract is a mapping of addresses to balances, alongside some methods to manipulate those balances.
These balances are what represent the tokens themselves. Someone has a token when their balance in the token contract is not zero. That's all there is to it!
What these balances represent is up to the programmer; they could represent money, coins in a game, property, music rights, or voting rights (of course, each of these tokens would be stored in different token contracts).
cd contracts
) and create a new file called BoredBluenos.sol (touch BoredBluenos.sol
).npm install @openzeppelin/contracts
.BoredBluenos.sol
:So, what exactly are we doing here? Let's break it down:
@openzeppelin/contracts/token/ERC721/ERC721.sol
contains the implementation of ERC-721.@openzeppelin/contracts/utils/Counters.sol
provides counters than can only be incremented/decremented by one. We'll use this to keep track of the total number of tokens we've minted (which will also help us give each token a unique ID).@openzeppelin/contracts/access/Ownable.sol
provides access control to our contract. This ensures only the contract owner can mint tokens.mintNFT(address recipient, string memory tokenURI)
. This allows us to mint an NFT! This function takes in two parameters:
address recipient
is the address that will receive your newly minted NFT.string memory tokenURI
is a string that should resolve to a JSON document that describes the NFT's metadata. The NFT's metadata is crucial since it allows our tokens to have configurable properties like a name, description, image, and more! You can learn more about configuring the metadata here.mintNFT
then calls some functions from the inherited ERC-721 library and finally returns an integer that represents the ID of the freshly minted NFT.That's it! Obviously, we left out things like configuring the metadata and deploying the contract to the Ethereum mainnet, so we've provided some links that we think could be useful:
Did you make an NFT? Show us on Ed and/or Discord! We'd love to see what you've built ❤️