Skip to main content
Level: Beginner | Duration: 2 hoursPrerequisites: Basic programming knowledge (any language)

Overview

This module provides a comprehensive refresher on Solidity fundamentals required before diving into Fully Homomorphic Encryption on the EVM. You will revisit core data types, contract patterns, the ERC-20 standard, and Hardhat testing workflows. By the end of this module you should feel confident reading and writing standard Solidity smart contracts.

Learning Objectives

By completing this module you will be able to:
  1. Declare and use Solidity value types (uint, address, bool) and reference types (string, bytes)
  2. Define and interact with mappings and structs
  3. Emit events and understand their role in off-chain indexing
  4. Write custom modifiers for access control (onlyOwner)
  5. Use msg.sender, require, and revert for input validation
  6. Describe the ERC-20 token standard interface and its core functions
  7. Write and run basic Hardhat tests using ethers.js and Chai

1. Solidity Data Types

Solidity is a statically-typed language. Every variable must have its type declared at compile time.

Value Types

Value types are passed by value — when you assign them to a new variable or pass them to a function, a copy is made.
uint256 public totalSupply;    // 0 to 2^256 - 1
uint8   public decimals = 18;  // 0 to 255
Use uint256 unless you have a specific reason to use a smaller size. The EVM operates on 256-bit words natively.

Reference Types

string public name = "My Token";

Type Comparison Table

TypeSizeDefault ValueExample
uint25632 bytes0uint256 x = 42;
bool1 bytefalsebool ok = true;
address20 bytes0x0...0address a = msg.sender;
stringdynamic""string s = "hello";
bytes3232 bytes0x0...0bytes32 h = keccak256(...);

2. Mappings & Structs

Mappings

A mapping is a key-value store. It is the most gas-efficient way to look up data by a key.
// Syntax: mapping(KeyType => ValueType) visibility name;
mapping(address => uint256) public balances;

// Nested mappings (ERC-20 allowance pattern)
mapping(address => mapping(address => uint256)) public allowance;

function approve(address spender, uint256 amount) external {
    allowance[msg.sender][spender] = amount;
}
Mappings cannot be iterated, have no length, and all possible keys exist (unmapped keys return the default value).

Structs

struct Proposal {
    uint256 id;
    string  description;
    uint256 voteCount;
    bool    executed;
}

Proposal[] public proposals;

function createProposal(string calldata _desc) external {
    proposals.push(Proposal({
        id: proposals.length,
        description: _desc,
        voteCount: 0,
        executed: false
    }));
}

Combining Mappings and Structs

struct UserProfile {
    string  username;
    uint256 reputation;
    bool    exists;
}

mapping(address => UserProfile) public profiles;

function register(string calldata _username) external {
    require(!profiles[msg.sender].exists, "Already registered");
    profiles[msg.sender] = UserProfile(_username, 0, true);
}

3. Events & Emit

Events are the mechanism by which smart contracts communicate with the outside world. When emitted, events write data to the transaction log.

Declaring and Emitting Events

event Transfer(address indexed from, address indexed to, uint256 value);
event Deposit(address indexed user, uint256 amount);

function transfer(address to, uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    
    balances[msg.sender] -= amount;
    balances[to] += amount;
    
    emit Transfer(msg.sender, to, amount);
}
The indexed keyword allows filtering on that parameter when querying logs. You may index up to three parameters per event.

Why Events Matter

  • Off-chain indexing: Services like The Graph index events to build queryable APIs
  • Debugging: Events appear in transaction receipts and are visible in block explorers
  • Cost: Events are much cheaper than storage writes (~375 gas for the topic + 8 gas per byte)

4. Modifiers & Access Control

Modifiers allow you to attach reusable preconditions to functions.

The onlyOwner Pattern

contract Ownable {
    address public owner;
    uint256 public fee;
    
    error NotOwner();
    
    constructor() {
        owner = msg.sender;
    }
    
    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;  // <-- placeholder for the function body
    }
    
    function setFee(uint256 _fee) external onlyOwner {
        fee = _fee;
    }
}

5. msg.sender, require, revert

msg.sender

msg.sender is a global variable that holds the address of the account (or contract) that directly called the current function.
function whoAmI() external view returns (address) {
    return msg.sender;
}

require

require is used for input validation. If the condition is false, the transaction reverts and any state changes are undone.
function withdraw(uint256 amount) external {
    require(amount > 0, "Amount must be > 0");
    require(balances[msg.sender] >= amount, "Insufficient balance");
    
    balances[msg.sender] -= amount;
    payable(msg.sender).transfer(amount);
}

revert with Custom Errors

Since Solidity 0.8.4, custom errors provide a gas-efficient alternative to require with string messages.
error InsufficientBalance(uint256 available, uint256 requested);

function withdraw(uint256 amount) external {
    if (balances[msg.sender] < amount) {
        revert InsufficientBalance(balances[msg.sender], amount);
    }
    // ...
}
PatternGas CostUse When
require(cond, "msg")Higher (stores string)Quick checks, readability
if (!cond) revert CustomError()LowerProduction contracts

6. ERC-20 Standard Overview

ERC-20 is the most widely adopted token standard on Ethereum. It defines a common interface that all fungible tokens implement.

Interface

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
}

Minimal Implementation

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract SimpleToken {
    string  public name     = "SimpleToken";
    string  public symbol   = "STK";
    uint8   public decimals = 18;
    uint256 public totalSupply;
    
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
    
    constructor(uint256 _initialSupply) {
        totalSupply = _initialSupply * 10 ** decimals;
        balanceOf[msg.sender] = totalSupply;
        emit Transfer(address(0), msg.sender, totalSupply);
    }
    
    function transfer(address to, uint256 amount) external returns (bool) {
        require(balanceOf[msg.sender] >= amount, "Insufficient balance");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }
    
    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }
    
    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        require(balanceOf[from] >= amount, "Insufficient balance");
        require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
        
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
        return true;
    }
}

Key Concepts

Most tokens use 18 decimals. USDC uses 6. Always check the decimals of a token before performing arithmetic.For a token with 18 decimals:
  • 1 token = 1 * 10^18 base units
  • 0.5 tokens = 500000000000000000 base units
Allows contracts (like DEXs) to spend tokens on behalf of the owner:
  1. Owner calls approve(spender, amount)
  2. Spender calls transferFrom(owner, recipient, amount)
Production code should check that to != address(0) to prevent accidental token burns.

7. Hardhat Testing Basics

Hardhat is the most popular Solidity development framework. It provides a local EVM environment, compilation, deployment scripting, and a testing framework.

Project Setup

1

Initialize project

mkdir my-project && cd my-project
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init

Writing a Test

test/SimpleToken.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("SimpleToken", function () {
    let token;
    let owner;
    let addr1;
    
    beforeEach(async function () {
        [owner, addr1] = await ethers.getSigners();
        
        const SimpleToken = await ethers.getContractFactory("SimpleToken");
        token = await SimpleToken.deploy(1000);
        await token.waitForDeployment();
    });
    
    describe("Deployment", function () {
        it("should set the correct total supply", async function () {
            const totalSupply = await token.totalSupply();
            expect(totalSupply).to.equal(ethers.parseUnits("1000", 18));
        });
        
        it("should assign all tokens to the deployer", async function () {
            const ownerBalance = await token.balanceOf(owner.address);
            expect(ownerBalance).to.equal(await token.totalSupply());
        });
    });
    
    describe("Transfers", function () {
        it("should transfer tokens between accounts", async function () {
            const amount = ethers.parseUnits("100", 18);
            
            await token.transfer(addr1.address, amount);
            expect(await token.balanceOf(addr1.address)).to.equal(amount);
        });
        
        it("should revert when sender has insufficient balance", async function () {
            const amount = ethers.parseUnits("1", 18);
            
            await expect(
                token.connect(addr1).transfer(owner.address, amount)
            ).to.be.revertedWith("Insufficient balance");
        });
        
        it("should emit a Transfer event", async function () {
            const amount = ethers.parseUnits("50", 18);
            
            await expect(token.transfer(addr1.address, amount))
                .to.emit(token, "Transfer")
                .withArgs(owner.address, addr1.address, amount);
        });
    });
});

Running Tests

npx hardhat test                    # run all tests
npx hardhat test --grep "transfer"  # run tests matching pattern

Common Chai Matchers

MatcherDescriptionExample
expect(x).to.equal(y)Strict equalityexpect(balance).to.equal(100n)
.to.be.revertedWith(msg)Expects revertexpect(tx).to.be.revertedWith("...")
.to.emit(contract, event)Expects eventexpect(tx).to.emit(token, "Transfer")
.to.changeTokenBalanceChecks balance changeexpect(tx).to.changeTokenBalance(token, addr, 100)

Summary

In this module you reviewed the essential Solidity building blocks:
  • Data types: uint256, address, bool, string, bytes
  • Mappings & structs: Primary data structures for on-chain storage
  • Events: Cheap, indexable logs for off-chain consumption
  • Modifiers: Reusable preconditions (onlyOwner, custom guards)
  • Validation: require and custom revert errors
  • ERC-20: The standard fungible token interface
  • Hardhat testing: Write, run, and debug tests locally
These fundamentals form the foundation upon which fhEVM builds. In the next module, you will learn what Fully Homomorphic Encryption is and why it matters for blockchain privacy.

Build docs developers (and LLMs) love