Before we discuss theory, philosophy, or history, let us write code. Open a text editor and type the following:
Learning Objectives
- Write, compile, and deploy a Solidity smart contract to an Ethereum testnet
- Use Solidity data types, functions, modifiers, events, and error handling correctly
- Explain the differences between storage, memory, and calldata and choose the appropriate data location
- Implement the ERC-20 token standard from scratch, understanding each required function
- Write and run automated tests for smart contracts using Hardhat
In This Chapter
- Your First 10 Lines of Solidity
- Development Environment Setup
- Solidity Fundamentals: Data Types
- Functions, Modifiers, and Events
- Data Locations: Storage, Memory, and Calldata
- Control Flow and Error Handling
- Inheritance, Interfaces, and Abstract Contracts
- The ERC-20 Standard: Building a Token Step by Step
- Progressive Project: VotingToken and SimpleVoting
- Testing with Hardhat
- Deploying to Testnet
- Common Solidity Pitfalls
- Summary
Chapter 13: Solidity Programming: Writing Your First Smart Contract
Your First 10 Lines of Solidity
Before we discuss theory, philosophy, or history, let us write code. Open a text editor and type the following:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract HelloWorld {
string public greeting = "Hello, Blockchain!";
function setGreeting(string calldata _newGreeting) external {
greeting = _newGreeting;
}
}
That is a complete, deployable smart contract. Ten lines. No boilerplate framework, no import chain, no build configuration beyond a compiler version. If you have worked with enterprise Java or even modern web frameworks, the brevity might feel suspicious. It is not. Solidity contracts are meant to be small, focused, and auditable. Every line you add is a line that costs gas to deploy and a line that an attacker might exploit. Conciseness is not laziness here; it is a design virtue.
Let us walk through what you just wrote, line by line, because every token matters in a language where deployed code is immutable and execution costs real money.
Line 1: The License Identifier. The // SPDX-License-Identifier: MIT comment is not decorative. The Solidity compiler emits a warning if you omit it. SPDX (Software Package Data Exchange) identifiers became a convention after the Solidity community recognized that smart contracts are open-source by default — anyone can read the bytecode on-chain and decompile it. The license comment makes the legal intent explicit. Most open-source contracts use MIT or GPL-3.0. If you genuinely want to restrict use, you can write UNLICENSED, but understand that the code is still visible to the world.
Line 2: The Pragma Directive. pragma solidity ^0.8.20; tells the compiler which versions of Solidity can compile this file. The caret (^) means "0.8.20 or higher, but below 0.9.0." This is critical. Solidity has introduced breaking changes between minor versions (the jump from 0.7.x to 0.8.x added automatic overflow checks, fundamentally changing arithmetic behavior). Pinning the version prevents your contract from being compiled with an incompatible compiler that silently changes semantics.
Line 3: The Contract Declaration. contract HelloWorld { ... } is analogous to a class declaration in object-oriented languages. A contract is the fundamental unit of deployment on Ethereum. When you deploy this contract, the EVM stores its bytecode at a unique address, and that address becomes the contract's permanent identity. You can think of a contract as an object that lives on the blockchain: it has state (the greeting variable), behavior (the setGreeting function), and an address (assigned at deployment).
Line 4: State Variable. string public greeting = "Hello, Blockchain!"; declares a state variable. State variables are stored permanently in the contract's storage on the blockchain. The public keyword does double duty: it sets the visibility (anyone can read this variable) and it auto-generates a getter function. After deployment, calling greeting() on this contract returns "Hello, Blockchain!" without you writing a separate getter. This is Solidity being pragmatic — read access is the most common need, so the language automates it.
Lines 6-8: The Function. function setGreeting(string calldata _newGreeting) external { ... } defines behavior. The external keyword means this function can only be called from outside the contract (not by other functions within it). The calldata keyword specifies where the string parameter lives in memory — we will explore this in depth later, but for now know that calldata is the cheapest option for read-only function parameters. The underscore prefix on _newGreeting is a convention (not enforced by the compiler) that distinguishes function parameters from state variables.
This contract, trivial as it looks, demonstrates the core architecture of every Solidity program: state declaration, state mutation through functions, visibility controls, and data location management. Everything else in this chapter builds on these foundations.
💡 Why Start with Code? Traditional programming textbooks often spend chapters on theory before showing a single line of code. We take the opposite approach because Solidity has a unique feedback loop: you can deploy your first contract to a testnet within minutes and interact with it through a block explorer. That immediacy is pedagogically powerful. You are not writing code that runs on your laptop and disappears when you close the terminal. You are writing code that persists on a global network. That reality should hit you early.
Development Environment Setup
To follow along with this chapter, you need a working Solidity development environment. We will use Hardhat, the most widely adopted Ethereum development framework as of 2024-2025. Hardhat provides a local blockchain for testing, a compiler pipeline, a testing framework, and deployment tools in a single package.
Prerequisites
You need Node.js version 18 or later. Verify your installation:
node --version
# Should output v18.x.x or higher
npm --version
# Should output 9.x.x or higher
If you do not have Node.js, download it from nodejs.org. The LTS (Long-Term Support) version is recommended.
Creating a Hardhat Project
Create a new directory and initialize the project:
mkdir solidity-tutorial && cd solidity-tutorial
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
When Hardhat's interactive setup asks what you want to create, select "Create a JavaScript project." Accept the default options. Hardhat generates the following directory structure:
solidity-tutorial/
├── contracts/ # Your Solidity source files
├── scripts/ # Deployment and utility scripts
├── test/ # Test files (JavaScript or TypeScript)
├── hardhat.config.js # Compiler settings, network configuration
└── package.json # Node.js dependencies
Configuring the Compiler
Open hardhat.config.js and verify (or update) the Solidity version:
require("@nomicfoundation/hardhat-toolbox");
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: {
version: "0.8.20",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
};
The optimizer setting is worth understanding. The runs parameter tells the compiler how many times you expect each function to be called. A value of 200 (the conventional default) produces bytecode that balances deployment cost against execution cost. A higher value (say, 10000) produces larger bytecode (more expensive to deploy) but cheaper per-call execution. For learning purposes, 200 is fine.
Your First Compilation
Place the HelloWorld.sol contract from the opening section in the contracts/ directory, then compile:
npx hardhat compile
If you see Compiled 1 Solidity file successfully, your environment is ready. If you see errors, the most common cause is a version mismatch between your pragma statement and the compiler version in hardhat.config.js.
Alternative: Foundry
Hardhat is not the only development framework. Foundry, created by Paradigm, has gained significant adoption since 2022. Foundry's key differentiator is that you write tests in Solidity rather than JavaScript. For developers who find context-switching between Solidity and JavaScript disorienting, Foundry eliminates that friction. It is also substantially faster — compiling and testing large contract suites in seconds rather than tens of seconds. However, Hardhat has a larger ecosystem of plugins, more comprehensive documentation for beginners, and broader community support. We use Hardhat in this textbook because its JavaScript testing layer maps more naturally to concepts most readers already know. If you continue with Solidity development beyond this course, exploring Foundry is strongly recommended.
The Remix Alternative for Quick Experimentation
For rapid prototyping and learning, the Remix IDE (remix.ethereum.org) is invaluable. Remix runs entirely in your browser with zero installation. It provides a Solidity editor with syntax highlighting and autocompletion, a built-in compiler, a JavaScript VM for instant local testing, and a deployment interface that connects to MetaMask for testnet deployment. Many experienced developers keep Remix open for quick experiments even when their primary workflow uses Hardhat or Foundry. The limitation of Remix is that it does not support automated testing, version control integration, or the kind of reproducible build pipeline that production projects require. Think of Remix as your scratch pad and Hardhat as your workshop.
⚠️ Common Pitfall: Global vs. Local Installation. Never install Hardhat globally (
npm install -g hardhat). Ethereum tooling evolves rapidly, and different projects may require different Hardhat versions. Always install it as a local dev dependency and invoke it withnpx.
Solidity Fundamentals: Data Types
Solidity is a statically typed language, meaning every variable must have its type declared at compile time. This is a deliberate design choice for a language that runs on a virtual machine where every operation costs gas. The compiler can optimize storage layout and catch type errors before deployment, which matters enormously when deployed code is immutable.
Value Types
Unsigned Integers: uint. The most frequently used type in Solidity. uint is an alias for uint256, a 256-bit unsigned integer that can represent values from 0 to 2^256 - 1 (a number with 77 digits). Solidity also provides uint8, uint16, uint32, uint64, uint128, and other sizes in 8-bit increments. Token balances, timestamps, counters, and indices are all typically uint256.
uint256 public totalSupply = 1000000;
uint8 public decimals = 18;
Why 256 bits? Because the EVM's word size is 256 bits. Operations on uint256 map directly to single EVM opcodes. Smaller types (uint8, uint128) do not save gas in most contexts because the EVM still operates on 256-bit words internally. The exception is when you pack multiple smaller values into a single storage slot — a technique we will cover shortly.
Signed Integers: int. int256 (aliased as int) represents values from -2^255 to 2^255 - 1. Used less frequently than uint because most blockchain quantities (balances, supplies, timestamps) are inherently non-negative. You would use int for price deltas, temperature readings in oracle contracts, or any value that can legitimately be negative.
Since Solidity 0.8.0, all arithmetic operations revert on overflow and underflow by default. Before 0.8.0, adding 1 to type(uint256).max silently wrapped around to 0, which caused catastrophic bugs in several major DeFi protocols. The SafeMath library, which was once essential, is now unnecessary for Solidity 0.8.x and later. If you deliberately want wrapping behavior (rare), you can use unchecked { ... } blocks.
Address: address. A 20-byte (160-bit) value representing an Ethereum account. There are two flavors:
address public owner; // Can receive address values
address payable public treasury; // Can also receive Ether via .transfer() and .send()
The distinction between address and address payable is a safety feature. You cannot accidentally send Ether to a contract address that does not expect it (well, there are edge cases with selfdestruct and coinbase rewards, but the type system prevents the common case). To convert between them: payable(someAddress).
Boolean: bool. True or false. Costs a full storage slot (256 bits) when stored as a state variable, which surprises developers coming from languages where booleans are single bytes. You can pack multiple booleans into a single slot using bitwise operations, but for readability, most contracts simply accept the cost unless gas optimization is critical.
bool public isActive = true;
bool public isPaused = false;
Fixed-Size Byte Arrays: bytes1 through bytes32. Fixed-length sequences of raw bytes. bytes32 is extremely common because it corresponds to a single EVM word and is the return type of most hashing operations.
bytes32 public merkleRoot;
bytes4 public interfaceId;
Strings: string. Dynamically sized UTF-8 encoded text. Strings in Solidity are more expensive than you might expect — there are no built-in string manipulation functions (no concatenation operator before 0.8.12, no splitting, no searching). This is intentional. String operations are gas-expensive and rarely needed on-chain. If you need to compare strings, hash them: keccak256(abi.encodePacked(str1)) == keccak256(abi.encodePacked(str2)).
string public name = "VotingToken";
📊 Gas Cost Reality Check. Storing a single
uint256in a new storage slot costs approximately 20,000 gas (the SSTORE opcode for a zero-to-nonzero write). At 30 gwei gas price and ETH at $3,000, that is about $1.80 for a single variable write. This is why Solidity developers obsess over storage efficiency in ways that would seem absurd to a Python programmer.
Reference Types
Arrays. Both fixed-size and dynamic arrays are supported:
uint256[5] public fixedArray; // Fixed size: exactly 5 elements
uint256[] public dynamicArray; // Dynamic: can grow and shrink
function addElement(uint256 value) external {
dynamicArray.push(value); // Appends to the end
}
function removeLastElement() external {
dynamicArray.pop(); // Removes the last element
}
Dynamic arrays stored in storage can use push() and pop(). Their length property is readable. Accessing an out-of-bounds index reverts the transaction (unlike C, where it silently corrupts memory).
Mappings. The workhorse data structure of Solidity. A mapping is a hash table that maps keys to values:
mapping(address => uint256) public balances;
mapping(address => mapping(address => uint256)) public allowances; // Nested mapping
Mappings have unusual properties compared to hash tables in other languages. They are not iterable — you cannot loop over all keys. They do not track their size — there is no .length. Every possible key exists and maps to the default value (0 for integers, address(0) for addresses, etc.). This means you cannot distinguish between "this key was never set" and "this key was set to zero." If you need that distinction, use a separate boolean mapping or a struct with an exists field.
Why are mappings not iterable? Because of how they work under the hood. The value for a key is stored at the storage slot keccak256(key . slot), where slot is the mapping's position in the contract's storage layout. There is no linked list or array of keys — just individual storage slots scattered across the 2^256 address space.
Structs. User-defined composite types:
struct Proposal {
uint256 id;
string description;
uint256 voteCount;
uint256 deadline;
bool executed;
}
Proposal[] public proposals;
mapping(uint256 => Proposal) public proposalById;
Structs can contain any type except mappings (when used in memory) and cannot contain instances of themselves (no recursive structs). They are essential for organizing complex contract state.
Enums. Named constants for representing a fixed set of states:
enum ProposalState {
Pending, // 0
Active, // 1
Passed, // 2
Failed, // 3
Executed // 4
}
ProposalState public state = ProposalState.Pending;
Enums are internally represented as the smallest uint type that can hold all values. An enum with 5 values uses uint8. They provide type safety — you cannot accidentally assign a raw integer to an enum variable (though you can explicitly cast with ProposalState(2)).
Constants and Immutables
Solidity provides two mechanisms for values that do not change after deployment, and understanding the distinction matters for gas optimization:
constant — The value must be known at compile time. It is embedded directly into the bytecode and does not occupy a storage slot. Reading a constant costs zero gas because the value is part of the contract code itself.
uint256 public constant MAX_SUPPLY = 1000000 * 10 ** 18;
address public constant BURN_ADDRESS = address(0);
string public constant VERSION = "1.0.0";
immutable — The value is set once in the constructor and cannot be changed afterward. Like constants, immutable values do not occupy storage slots — they are appended to the deployed bytecode during construction. However, unlike constants, immutable values can be computed at deployment time (using constructor arguments or msg.sender).
address public immutable deployer;
uint256 public immutable deployTimestamp;
uint256 public immutable initialSupply;
constructor(uint256 _supply) {
deployer = msg.sender; // Known only at deployment time
deployTimestamp = block.timestamp; // Known only at deployment time
initialSupply = _supply; // Passed as constructor argument
}
The gas savings are significant. Reading from storage costs at least 2,100 gas (the SLOAD opcode after EIP-2929 warm access). Reading a constant or immutable value costs only 3 gas (the PUSH opcode). For values read frequently — like an owner address checked in every access-controlled function — the difference accumulates rapidly. As a rule: if a value never changes, make it constant (if known at compile time) or immutable (if known at construction time). Reserve storage for values that genuinely need to change.
Functions, Modifiers, and Events
Function Syntax
Every Solidity function follows this pattern:
function functionName(parameterType parameterName, ...)
visibilitySpecifier
stateMutabilityModifier
customModifiers
returns (returnType returnName, ...)
{
// function body
}
That is a lot of keywords between the parameter list and the function body. Each one serves a purpose, and omitting the wrong one can create a security vulnerability or waste gas.
Visibility Specifiers
Solidity provides four visibility levels, and choosing the right one is a security decision, not just an API design choice:
public — Callable from everywhere: external transactions, other contracts, and internally within the same contract. The compiler generates both an external entry point (callable via transactions) and an internal jump (callable from within). Use when you genuinely need both access patterns, but be aware that public functions have a slightly higher gas cost than external-only functions due to the dual calling convention.
external — Callable only from outside the contract (via transactions or other contracts). Cannot be called internally using this.functionName() syntax (well, technically you can, but it makes an external call to yourself, which is expensive and bizarre). Preferred for functions that only need to be called by users or other contracts because calldata parameters are cheaper than memory parameters.
internal — Callable only from within the contract and from derived contracts (child contracts that inherit from this one). This is the default visibility for state variables. Similar to protected in Java or C++.
private — Callable only from within the contract where it is defined. Not accessible from derived contracts. Note that private does NOT mean the data is hidden from the world — everything on the blockchain is readable. private only restricts which contracts can call the function. An attacker can still read private state variables by querying the storage slots directly.
⚠️ Critical Security Note. Marking a variable or function as
privatedoes not make its data confidential. It only restricts contract-level access. Every piece of data stored on the Ethereum blockchain can be read by anyone who knows the storage slot layout. Never store secrets (passwords, private keys, unencrypted sensitive data) in a smart contract, regardless of visibility.
State Mutability: view, pure, and payable
view — This function reads state but does not modify it. Calling a view function from outside the blockchain (via an RPC call, not a transaction) is free — it costs no gas because it does not change state. However, if a state-modifying function calls a view function internally, the read operations within the view function do cost gas as part of the transaction.
function getBalance(address account) external view returns (uint256) {
return balances[account];
}
pure — This function neither reads nor modifies state. It operates only on its inputs. Pure functions are useful for utility calculations.
function calculateFee(uint256 amount, uint256 feePercent) public pure returns (uint256) {
return (amount * feePercent) / 100;
}
payable — This function can receive Ether. Without the payable keyword, sending Ether to a function causes the transaction to revert. This is a safety feature: you must explicitly opt in to receiving funds.
function deposit() external payable {
balances[msg.sender] += msg.value;
}
If no state mutability modifier is specified, the function can read and write state but cannot receive Ether.
Custom Modifiers
Modifiers are reusable precondition checks. They are Solidity's answer to the question "How do I avoid repeating the same require statement at the top of every admin function?"
modifier onlyOwner() {
require(msg.sender == owner, "Not the owner");
_;
}
modifier whenNotPaused() {
require(!paused, "Contract is paused");
_;
}
function emergencyWithdraw() external onlyOwner whenNotPaused {
// Only executes if both modifiers pass
payable(owner).transfer(address(this).balance);
}
The _; (underscore) in a modifier marks where the function body gets inserted. You can place code before _; (precondition checks), after _; (postcondition cleanup), or both. Modifiers execute in the order they are listed, and the function body replaces _; in the last modifier in the chain.
Modifiers are powerful but can be overused. A function with four stacked modifiers is harder to audit than a function with explicit require statements. The security auditing community generally recommends using modifiers for simple, frequently repeated access control checks and using explicit require statements for complex business logic validation.
Events: On-Chain Logging
Events are Solidity's mechanism for emitting structured logs. They do not store data in contract storage (which would be expensive); instead, they write to the transaction log, a separate data structure that is not accessible from within contracts but is readable by external applications.
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
function transfer(address to, uint256 amount) external returns (bool) {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
The indexed keyword on event parameters is significant. Up to three parameters can be indexed, which means they are stored as "topics" in the log entry and can be efficiently filtered. Non-indexed parameters are ABI-encoded into the log's data field. If a wallet application wants to find all transfers to a specific address, it can filter by the to topic without scanning every log entry.
Events cost gas (approximately 375 gas base + 375 per indexed topic + 8 gas per byte of data), but far less than storage writes. They are the standard mechanism for DApps to track contract activity. Every ERC-20 token emits Transfer and Approval events, which is how wallet applications and block explorers display your token transaction history.
Special Functions: receive and fallback
Two special functions handle situations where a contract receives Ether or is called with no matching function:
/// @notice Called when the contract receives plain Ether (no calldata)
receive() external payable {
emit Received(msg.sender, msg.value);
}
/// @notice Called when no other function matches, or when calldata is non-empty
/// but no function selector matches
fallback() external payable {
// Can contain logic or simply accept Ether
emit FallbackCalled(msg.sender, msg.value, msg.data);
}
The receive function is triggered when someone sends Ether to the contract with empty calldata (a plain ETH transfer). The fallback function is triggered when the contract is called with calldata that does not match any function selector, or when there is no receive function and Ether is sent with empty calldata. Both must be external. The payable keyword on fallback is optional — without it, the fallback rejects any Ether sent with the call.
If a contract has neither receive nor fallback, sending Ether to it will revert (with the exception of selfdestruct force-sends and coinbase rewards, which bypass the contract's code entirely). This is a safety feature: contracts must explicitly opt in to receiving Ether.
Understanding these special functions matters because they define how your contract interacts with the broader Ethereum ecosystem. A governance treasury contract needs a receive function to accept Ether donations. A proxy contract (Chapter 14) relies heavily on the fallback function to delegate calls to an implementation contract.
Data Locations: Storage, Memory, and Calldata
Every reference type (arrays, structs, strings, bytes) in Solidity must specify where it lives. This concept has no analogue in most mainstream programming languages and causes more confusion for new Solidity developers than perhaps any other feature.
Storage
Storage is the contract's permanent state — the data that persists between function calls and transactions. It is stored on the blockchain, replicated across every Ethereum node, and protected by the consensus mechanism. It is also, by a wide margin, the most expensive place to put data.
Each contract has 2^256 storage slots, each 32 bytes wide. State variables are assigned to these slots sequentially, starting at slot 0. The compiler packs smaller types into a single slot when possible:
contract StorageLayout {
uint256 public a; // Slot 0 (full 32 bytes)
uint128 public b; // Slot 1 (first 16 bytes)
uint128 public c; // Slot 1 (last 16 bytes — packed with b)
uint256 public d; // Slot 2 (full 32 bytes)
}
Understanding storage layout matters for gas optimization. Writing to a previously zero slot costs 20,000 gas. Writing to a nonzero slot costs 5,000 gas. The difference is dramatic, and it explains why experienced developers carefully order their state variables to maximize slot packing.
Memory
Memory is a temporary, byte-addressable scratch space that exists only for the duration of a function call. It is wiped clean after each external function call returns. Memory is cheap compared to storage (3 gas per 32-byte word read or write, plus a quadratic expansion cost for large allocations), but data in memory does not persist.
function processData(uint256[] memory data) public pure returns (uint256) {
uint256 sum = 0;
for (uint256 i = 0; i < data.length; i++) {
sum += data[i];
}
return sum;
}
When you assign a storage variable to a memory variable, Solidity copies the data. Modifying the memory copy does not affect storage:
function doesNotModifyStorage() public view returns (string memory) {
string memory localCopy = greeting; // Copies from storage to memory
// Modifying localCopy would NOT change the stored greeting
return localCopy;
}
Calldata
Calldata is the read-only area where function arguments from external calls are stored. It is the cheapest data location because it does not need to be copied — the EVM reads directly from the transaction's input data.
function processNames(string[] calldata names) external pure returns (uint256) {
return names.length;
}
Calldata parameters cannot be modified within the function. If you need to modify a calldata array, you must copy it to memory first. The general rule: use calldata for external function parameters that you only read, use memory for parameters you need to modify or for internal function parameters.
Data Location Assignment Rules
The rules for when you must specify a data location:
- State variables are always in
storage. You never writeuint256 storage x;at the contract level — it is implicit. - Function parameters of reference types must be
memory(for public/internal functions) orcalldata(for external functions; memory also works but costs more gas). - Local variables of reference types default to
storage(they become pointers to storage) or can be explicitlymemory. - Return values are always
memory.
The Dangerous Storage Pointer
One of Solidity's most subtle footguns is the storage pointer in local variables:
struct User {
string name;
uint256 balance;
}
User[] public users;
function updateUser(uint256 index) external {
User storage user = users[index]; // storage POINTER — modifies the original
user.balance = 100; // This MODIFIES users[index] in storage
User memory userCopy = users[index]; // memory COPY — independent of storage
userCopy.balance = 200; // This does NOT modify users[index]
}
The User storage user declaration creates a reference (pointer) to the storage location. Mutations through this pointer directly modify the contract's state. The User memory userCopy declaration copies the data from storage into memory, creating an independent copy. This distinction trips up every new Solidity developer at least once.
🔗 Connection to Chapter 12. The gas costs described here map directly to the EVM opcodes we studied in Chapter 12.
SSTORE(storage write) costs 20,000 gas for a zero-to-nonzero write,MSTORE(memory write) costs 3 gas, andCALLDATALOAD(calldata read) costs 3 gas. The data location system is Solidity's abstraction over these raw opcode costs.
Control Flow and Error Handling
Conditional Logic and Loops
Solidity's control flow syntax is borrowed from C/JavaScript:
function categorize(uint256 value) public pure returns (string memory) {
if (value == 0) {
return "zero";
} else if (value < 100) {
return "small";
} else {
return "large";
}
}
Loops work as expected, but with an important caveat: unbounded loops are dangerous in smart contracts. Every iteration costs gas, and a transaction has a gas limit. If a loop iterates over a dynamically growing array, the function may become uncallable once the array exceeds a certain size. This is not a theoretical risk — it is a known attack vector called a "denial of service via block gas limit."
// DANGEROUS: Unbounded loop over dynamic array
function sumAll() external view returns (uint256) {
uint256 total = 0;
for (uint256 i = 0; i < dynamicArray.length; i++) {
total += dynamicArray[i];
}
return total;
}
// SAFER: Process in batches with pagination
function sumBatch(uint256 start, uint256 count) external view returns (uint256) {
uint256 end = start + count;
if (end > dynamicArray.length) end = dynamicArray.length;
uint256 total = 0;
for (uint256 i = start; i < end; i++) {
total += dynamicArray[i];
}
return total;
}
Error Handling: require, revert, and assert
Solidity provides three mechanisms for reverting transactions, each with a different semantic purpose:
require(condition, message) — Use for input validation and precondition checks. When the condition is false, the transaction reverts and the error message is returned to the caller. Remaining gas is refunded.
function withdraw(uint256 amount) external {
require(amount > 0, "Amount must be positive");
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
revert(message) or revert CustomError() — Use when the condition logic is more complex than a single boolean check, or when you want to use custom errors (introduced in Solidity 0.8.4) for gas savings.
error InsufficientBalance(uint256 requested, uint256 available);
error Unauthorized(address caller);
function withdraw(uint256 amount) external {
if (amount == 0) revert("Amount must be positive");
if (balances[msg.sender] < amount) {
revert InsufficientBalance(amount, balances[msg.sender]);
}
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
Custom errors are cheaper than string error messages because strings are ABI-encoded and stored in the transaction receipt. Custom errors use only 4 bytes for the selector plus the encoded parameters. For contracts that revert frequently (access control, input validation), the gas savings add up.
assert(condition) — Use for checking invariants that should never be false. If an assert fails, it indicates a bug in your code, not invalid user input. Before Solidity 0.8.0, assert consumed all remaining gas (using the INVALID opcode); since 0.8.0, it reverts cleanly like require. Still, the semantic convention remains: require for user-facing validation, assert for internal consistency checks.
function transfer(address to, uint256 amount) external {
require(to != address(0), "Cannot transfer to zero address");
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
// This should NEVER fail if the logic above is correct
assert(balances[msg.sender] + balances[to] >= amount);
}
Try/Catch for External Calls
Solidity 0.6.0 introduced try/catch for handling failures in external function calls:
interface IExternalContract {
function riskyOperation() external returns (uint256);
}
function safeCall(address target) external returns (uint256) {
try IExternalContract(target).riskyOperation() returns (uint256 result) {
return result;
} catch Error(string memory reason) {
// Catches revert("reason") and require(false, "reason")
emit OperationFailed(reason);
return 0;
} catch (bytes memory lowLevelData) {
// Catches everything else (custom errors, assert failures, out of gas)
emit LowLevelFailure(lowLevelData);
return 0;
}
}
Try/catch only works for external calls and contract creation (new expressions). You cannot wrap internal function calls in try/catch. This limitation reflects the EVM's execution model: internal calls share the same execution context, and a revert in an internal call reverts the entire context.
Inheritance, Interfaces, and Abstract Contracts
Single and Multiple Inheritance
Solidity supports multiple inheritance using the is keyword:
contract Ownable {
address public owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not the owner");
_;
}
}
contract Pausable is Ownable {
bool public paused;
modifier whenNotPaused() {
require(!paused, "Paused");
_;
}
function pause() external onlyOwner {
paused = true;
}
function unpause() external onlyOwner {
paused = false;
}
}
contract MyToken is Ownable, Pausable {
// Inherits owner, onlyOwner, paused, whenNotPaused, pause, unpause
mapping(address => uint256) public balances;
function mint(address to, uint256 amount) external onlyOwner whenNotPaused {
balances[to] += amount;
}
}
When deploying MyToken, the bytecode includes all inherited code. The deployed contract is a single entity on the blockchain — there is no runtime dispatch to parent contracts. Inheritance is resolved entirely at compile time.
The Diamond Problem and C3 Linearization
When a contract inherits from multiple parents that share a common ancestor, Solidity uses C3 linearization (the same algorithm Python uses) to determine the order of inheritance. The rule: list base contracts from "most base-like" to "most derived":
contract A {
function foo() public virtual returns (string memory) { return "A"; }
}
contract B is A {
function foo() public virtual override returns (string memory) { return "B"; }
}
contract C is A {
function foo() public virtual override returns (string memory) { return "C"; }
}
// Correct order: most base-like first, most derived last
contract D is B, C {
function foo() public override(B, C) returns (string memory) { return "D"; }
}
If you call foo() on contract D, it returns "D". If D did not override foo(), the compiler would require that you specify which parent's implementation to use. The virtual keyword marks a function as overridable; override marks a function as overriding a parent's implementation. Both are required — you cannot accidentally override a function.
Interfaces
An interface defines a contract's external API without any implementation:
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);
}
Interfaces cannot have state variables, constructors, or function implementations. Every function must be external. Interfaces are the foundation of composability in Ethereum — a DeFi protocol does not need to know how your token is implemented, only that it conforms to the IERC20 interface.
Abstract Contracts
An abstract contract is a hybrid between a full contract and an interface. It can have both implemented and unimplemented functions:
abstract contract ERC20Base {
mapping(address => uint256) internal _balances;
uint256 internal _totalSupply;
function totalSupply() public view virtual returns (uint256) {
return _totalSupply;
}
// Must be implemented by derived contract
function name() public view virtual returns (string memory);
function symbol() public view virtual returns (string memory);
}
You cannot deploy an abstract contract directly. A derived contract must implement all unimplemented functions before it can be deployed.
The ERC-20 Standard: Building a Token Step by Step
ERC-20 is the most important standard in Ethereum's ecosystem. Proposed by Fabian Vogelsteller in November 2015, it defines a minimal interface that any fungible token must implement. "Fungible" means every token is identical and interchangeable — one token is worth exactly the same as any other token of the same type, just like one dollar bill is interchangeable with another.
Before ERC-20, every token contract had its own API. Exchanges, wallets, and other contracts had to write custom integration code for each token. ERC-20 solved this by establishing six functions and two events that every token must support. This standardization enabled an explosion of composability: any ERC-20 token works with any ERC-20-compatible exchange, wallet, or DeFi protocol.
Let us build an ERC-20 token from scratch. Not by importing OpenZeppelin (though you should in production), but by implementing every function ourselves, so you understand exactly what each one does.
The Interface
We start from the IERC20 interface shown in the interfaces section above. Our token must implement all six functions and emit both events.
State Variables
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VotingToken {
string public name = "VotingToken";
string public symbol = "VOTE";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
address public owner;
Why 18 decimals? Ethereum has no floating-point numbers. Tokens use integer arithmetic with a decimal offset. With 18 decimals, "1 token" is represented internally as 1 * 10^18 = 1000000000000000000. This convention matches Ether's smallest unit (1 ETH = 10^18 wei), making it the community standard.
Events
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
These event signatures are part of the standard. Block explorers, wallets, and DeFi protocols listen for exactly these event signatures. Changing the event names or parameter order would break compatibility.
Constructor and Minting
constructor(uint256 initialSupply) {
owner = msg.sender;
_mint(msg.sender, initialSupply * 10 ** decimals);
}
function _mint(address to, uint256 amount) internal {
require(to != address(0), "Mint to zero address");
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
}
The _mint function is internal — only the contract itself (and derived contracts) can call it. The convention of emitting a Transfer event from address(0) to the recipient is how the ecosystem recognizes token creation. Block explorers display these as "mint" transactions.
The transfer Function
function transfer(address to, uint256 amount) external returns (bool) {
require(to != address(0), "Transfer to zero address");
require(balanceOf[msg.sender] >= amount, "Insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
The transfer function moves tokens from the caller to the recipient. It returns bool per the standard (always true on success; failure reverts rather than returning false). The return value exists for historical reasons and for compatibility with contracts that check it.
The Approve/TransferFrom Pattern
This is the most conceptually complex part of ERC-20. Direct transfer works when you are the token holder sending your own tokens. But what about situations where another contract needs to move your tokens on your behalf? For example, a decentralized exchange needs to pull tokens from your account when executing a trade. You do not send the tokens to the exchange first — you approve the exchange to pull a specific amount.
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(to != address(0), "Transfer to zero address");
require(balanceOf[from] >= amount, "Insufficient balance");
require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
allowance[from][msg.sender] -= amount;
emit Transfer(from, to, amount);
return true;
}
The flow is:
1. Alice calls approve(exchangeAddress, 1000) — "I authorize the exchange to spend up to 1000 of my tokens."
2. The exchange calls transferFrom(aliceAddress, exchangeAddress, 500) — "Pull 500 tokens from Alice's balance to mine."
3. Alice's balance decreases by 500, the exchange's balance increases by 500, and Alice's allowance for the exchange decreases from 1000 to 500.
This two-step pattern (approve then transferFrom) is fundamental to DeFi composability. Every DEX, lending protocol, and yield aggregator relies on it.
⚠️ The Approve Race Condition. There is a well-known race condition in the
approvefunction. If Alice approves Bob for 100 tokens, then changes the approval to 50, Bob can front-run the approval change by spending the original 100 tokens before the new approval takes effect, then spending another 50 after. The standard mitigation is to approve to 0 first, then approve to the new amount — or useincreaseAllowance/decreaseAllowancefunctions (not part of the base standard but provided by OpenZeppelin).
Progressive Project: VotingToken and SimpleVoting
Now we combine everything into the chapter's progressive project milestone. We are building two contracts that will form the foundation of the governance system developed throughout this textbook.
VotingToken: The Governance Token
Our VotingToken extends the ERC-20 implementation above with an onlyOwner minting function, allowing the owner to distribute voting power:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VotingToken {
string public name = "VotingToken";
string public symbol = "VOTE";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
address public owner;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
modifier onlyOwner() {
require(msg.sender == owner, "Not the owner");
_;
}
constructor(uint256 initialSupply) {
owner = msg.sender;
_mint(msg.sender, initialSupply * 10 ** decimals);
}
function transfer(address to, uint256 amount) external returns (bool) {
require(to != address(0), "Transfer to zero address");
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(to != address(0), "Transfer to zero address");
require(balanceOf[from] >= amount, "Insufficient balance");
require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
allowance[from][msg.sender] -= amount;
emit Transfer(from, to, amount);
return true;
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
function _mint(address to, uint256 amount) internal {
require(to != address(0), "Mint to zero address");
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
}
}
SimpleVoting: The Governance Contract
The SimpleVoting contract allows token holders to create proposals and cast votes weighted by their token balance at the time of voting:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function balanceOf(address account) external view returns (uint256);
}
contract SimpleVoting {
struct Proposal {
uint256 id;
string description;
uint256 forVotes;
uint256 againstVotes;
uint256 deadline;
bool executed;
address proposer;
}
IERC20 public votingToken;
address public owner;
uint256 public proposalCount;
uint256 public votingDuration;
uint256 public minimumTokensToPropose;
mapping(uint256 => Proposal) public proposals;
mapping(uint256 => mapping(address => bool)) public hasVoted;
event ProposalCreated(uint256 indexed id, address indexed proposer, string description, uint256 deadline);
event VoteCast(uint256 indexed proposalId, address indexed voter, bool support, uint256 weight);
event ProposalExecuted(uint256 indexed id, bool passed);
modifier onlyOwner() {
require(msg.sender == owner, "Not the owner");
_;
}
constructor(address _tokenAddress, uint256 _votingDuration, uint256 _minimumTokens) {
votingToken = IERC20(_tokenAddress);
owner = msg.sender;
votingDuration = _votingDuration;
minimumTokensToPropose = _minimumTokens;
}
function createProposal(string calldata description) external returns (uint256) {
require(
votingToken.balanceOf(msg.sender) >= minimumTokensToPropose,
"Insufficient tokens to propose"
);
proposalCount++;
uint256 proposalId = proposalCount;
proposals[proposalId] = Proposal({
id: proposalId,
description: description,
forVotes: 0,
againstVotes: 0,
deadline: block.timestamp + votingDuration,
executed: false,
proposer: msg.sender
});
emit ProposalCreated(proposalId, msg.sender, description, block.timestamp + votingDuration);
return proposalId;
}
function castVote(uint256 proposalId, bool support) external {
Proposal storage proposal = proposals[proposalId];
require(proposal.id != 0, "Proposal does not exist");
require(block.timestamp <= proposal.deadline, "Voting has ended");
require(!hasVoted[proposalId][msg.sender], "Already voted");
uint256 weight = votingToken.balanceOf(msg.sender);
require(weight > 0, "No voting power");
hasVoted[proposalId][msg.sender] = true;
if (support) {
proposal.forVotes += weight;
} else {
proposal.againstVotes += weight;
}
emit VoteCast(proposalId, msg.sender, support, weight);
}
function executeProposal(uint256 proposalId) external {
Proposal storage proposal = proposals[proposalId];
require(proposal.id != 0, "Proposal does not exist");
require(block.timestamp > proposal.deadline, "Voting not ended");
require(!proposal.executed, "Already executed");
proposal.executed = true;
bool passed = proposal.forVotes > proposal.againstVotes;
emit ProposalExecuted(proposalId, passed);
}
function getProposal(uint256 proposalId) external view returns (
uint256 id,
string memory description,
uint256 forVotes,
uint256 againstVotes,
uint256 deadline,
bool executed,
address proposer
) {
Proposal storage proposal = proposals[proposalId];
require(proposal.id != 0, "Proposal does not exist");
return (
proposal.id,
proposal.description,
proposal.forVotes,
proposal.againstVotes,
proposal.deadline,
proposal.executed,
proposal.proposer
);
}
}
Notice several design decisions worth discussing. The voting weight is determined by the voter's token balance at the time they cast their vote, not at the time the proposal was created. This is a simplification — a real governance contract would use a snapshot mechanism (ERC-20 Votes extension) to prevent users from buying tokens, voting, and immediately selling. We will implement snapshot-based voting in Chapter 19.
The castVote function uses a storage pointer (Proposal storage proposal) because it needs to modify the proposal's vote counts in storage. The getProposal function also uses a storage pointer but could use memory — it reads but does not modify. Using storage avoids the gas cost of copying the struct to memory.
Design Decisions and Trade-Offs
Several deliberate design choices in these contracts are worth examining because they illustrate the kind of reasoning that separates competent Solidity development from naive code:
Why an IERC20 interface instead of importing the full VotingToken contract? The SimpleVoting contract references the token through the minimal IERC20 interface — it only needs balanceOf. This is a principle from software engineering called the Interface Segregation Principle: depend on the narrowest interface that satisfies your requirements. If we imported the full VotingToken, the SimpleVoting contract would be coupled to VotingToken's implementation. Using the interface means SimpleVoting works with any ERC-20 token, not just ours.
Why calldata for the description parameter in createProposal? The description string is read but never modified within the function. Using calldata instead of memory saves gas because the EVM reads directly from the transaction input data without copying it to memory. For a string that might be 100-500 bytes, this saves a meaningful amount of gas per proposal creation.
Why does executeProposal not actually execute anything? In this simplified version, "execution" merely records the outcome. Real governance contracts execute on-chain actions — transferring funds, updating parameters, upgrading contracts. We separate the recording of the vote outcome from the execution of governance actions because combining them requires reentrancy protection and access control patterns that we have not yet covered. Chapter 14 introduces the necessary patterns, and we will refactor executeProposal to perform real on-chain execution.
Why is there no cancelProposal function? This is intentional. Allowing proposal cancellation introduces complexity: Who can cancel? Only the proposer? What if the proposer's tokens are transferred after creating the proposal? Can the owner cancel any proposal? Each answer creates new attack vectors and governance dynamics. We start simple and add complexity when we can reason about it properly.
Testing with Hardhat
Smart contracts are immutable once deployed. You cannot patch a bug, push a hotfix, or roll back a bad deployment. Testing is not optional — it is the last line of defense between your code and permanent, potentially catastrophic bugs on a public blockchain.
Hardhat provides a local Ethereum network (Hardhat Network) that runs entirely on your machine. Tests deploy contracts to this local network, interact with them, and verify the results. Let us write tests for our VotingToken and SimpleVoting contracts.
Test Structure
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("VotingToken", function () {
let token;
let owner;
let addr1;
let addr2;
beforeEach(async function () {
[owner, addr1, addr2] = await ethers.getSigners();
const VotingToken = await ethers.getContractFactory("VotingToken");
token = await VotingToken.deploy(1000);
});
describe("Deployment", function () {
it("should set the correct name and symbol", async function () {
expect(await token.name()).to.equal("VotingToken");
expect(await token.symbol()).to.equal("VOTE");
});
it("should assign the initial supply to the owner", async function () {
const ownerBalance = await token.balanceOf(owner.address);
expect(await token.totalSupply()).to.equal(ownerBalance);
});
it("should set the deployer as owner", async function () {
expect(await token.owner()).to.equal(owner.address);
});
});
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 fail if 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 fail when transferring to zero address", async function () {
const amount = ethers.parseUnits("100", 18);
await expect(
token.transfer(ethers.ZeroAddress, amount)
).to.be.revertedWith("Transfer to zero address");
});
});
});
Each beforeEach block deploys a fresh contract instance, ensuring tests are independent. The ethers.getSigners() function returns test accounts provided by Hardhat Network, each funded with 10,000 test ETH. The connect(addr1) pattern switches the caller for a specific function call — essential for testing access control.
Running Tests
npx hardhat test
Hardhat compiles your contracts, deploys them to a fresh local network for each test, runs the tests, and reports results. A typical test run takes 1-5 seconds for a small contract suite.
Testing Best Practices
Test the happy path first, then edge cases, then failure cases. For an ERC-20 token, the critical tests are:
- Deployment correctly sets initial state
transfermoves tokens and emits eventsapprovesets allowance and emits eventstransferFrommoves tokens within allowance and reduces allowancetransferreverts on insufficient balancetransferFromreverts on insufficient allowance- Only the owner can mint (access control)
Test events explicitly. Events are the primary mechanism for DApp communication. If an event is wrong or missing, the user interface breaks.
it("should emit Transfer event", async function () {
const amount = ethers.parseUnits("100", 18);
await expect(token.transfer(addr1.address, amount))
.to.emit(token, "Transfer")
.withArgs(owner.address, addr1.address, amount);
});
Test access control thoroughly. The number-one vulnerability in smart contracts is missing or incorrect access control. Every function that modifies critical state should be tested with both authorized and unauthorized callers.
🧪 Test-Driven Development in Solidity. Many experienced Solidity developers write tests before writing the contract. The reason is practical: once you deploy a buggy contract, you cannot fix it. Writing tests first forces you to think about the interface, edge cases, and failure modes before committing to an implementation. The cost of discovering a bug in tests is zero. The cost of discovering it after deployment can be millions of dollars.
Deploying to Testnet
A testnet is a public Ethereum network that behaves identically to mainnet but uses worthless test ETH. Deploying to a testnet lets you verify that your contract works in a real network environment — with real block confirmations, real transaction hashes, and real Etherscan verification — without risking real money.
Choosing a Testnet
As of 2024-2025, the primary Ethereum testnet is Sepolia. Previous testnets (Ropsten, Rinkeby, Goerli) have been deprecated or are in the process of deprecation. Sepolia uses proof-of-stake and mirrors mainnet's consensus mechanism.
Getting Test ETH
You need test ETH to pay for gas on the testnet. Test ETH is available from faucets:
- Google Cloud Sepolia Faucet: cloud.google.com/application/web3/faucet/ethereum/sepolia (requires Google account)
- Alchemy Sepolia Faucet: sepoliafaucet.com (requires Alchemy account)
- Infura Sepolia Faucet: infura.io/faucet/sepolia (requires Infura account)
Faucets typically dispense 0.1-0.5 Sepolia ETH per request, which is more than enough for dozens of contract deployments.
Configuring Hardhat for Sepolia
Update hardhat.config.js to include the Sepolia network:
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: {
version: "0.8.20",
settings: {
optimizer: { enabled: true, runs: 200 },
},
},
networks: {
sepolia: {
url: process.env.SEPOLIA_RPC_URL || "https://rpc.sepolia.org",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
},
};
⚠️ Never Hard-Code Private Keys. The configuration above uses environment variables loaded from a
.envfile (via thedotenvpackage). Your.envfile should be in.gitignore. Exposing a private key in a public repository — even for a testnet account — is a security disaster. Bots continuously scan GitHub for leaked keys and drain associated accounts within minutes.
Create a .env file:
SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY
PRIVATE_KEY=your_testnet_private_key_here
The Deployment Script
const { ethers } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying contracts with:", deployer.address);
console.log("Account balance:", (await ethers.provider.getBalance(deployer.address)).toString());
// Deploy VotingToken with initial supply of 1,000,000 tokens
const VotingToken = await ethers.getContractFactory("VotingToken");
const token = await VotingToken.deploy(1000000);
await token.waitForDeployment();
const tokenAddress = await token.getAddress();
console.log("VotingToken deployed to:", tokenAddress);
// Deploy SimpleVoting
const votingDuration = 7 * 24 * 60 * 60; // 7 days in seconds
const minimumTokens = ethers.parseUnits("100", 18); // 100 tokens to propose
const SimpleVoting = await ethers.getContractFactory("SimpleVoting");
const voting = await SimpleVoting.deploy(tokenAddress, votingDuration, minimumTokens);
await voting.waitForDeployment();
const votingAddress = await voting.getAddress();
console.log("SimpleVoting deployed to:", votingAddress);
console.log("\nDeployment complete!");
console.log("VotingToken:", tokenAddress);
console.log("SimpleVoting:", votingAddress);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Running the Deployment
For local testing (Hardhat's built-in network):
npx hardhat run scripts/deploy.js
For Sepolia testnet:
npx hardhat run scripts/deploy.js --network sepolia
The deployment will take 15-30 seconds on Sepolia as the transactions are mined. The console output will show the deployed contract addresses. Save these addresses — you will need them to interact with your contracts and to verify them on Etherscan.
Verifying on Etherscan
After deployment, you can verify your contract source code on Etherscan so that anyone can read and audit it. Hardhat Toolbox includes the Etherscan verification plugin:
npx hardhat verify --network sepolia DEPLOYED_CONTRACT_ADDRESS "constructor_arg_1"
For the VotingToken:
npx hardhat verify --network sepolia 0xYourTokenAddress 1000000
Verification publishes your source code on Etherscan and links it to the deployed bytecode. Verified contracts show a green checkmark on Etherscan, signaling to users that the code is auditable. Unverified contracts display only raw bytecode, which is a red flag for any serious user.
What Happens After Deployment
Once your contracts are deployed and verified, you can interact with them through several channels:
Etherscan's Read/Write interface. On the verified contract page, Etherscan provides "Read Contract" and "Write Contract" tabs. Read functions can be called directly from the browser (they are free — no gas). Write functions require connecting a wallet (MetaMask) and signing a transaction. This is the simplest way to interact with your deployed contracts for testing.
Hardhat Console. Run npx hardhat console --network sepolia to get an interactive JavaScript REPL connected to your deployed contracts. This is more powerful than Etherscan's interface because you can write scripts, loop over operations, and compose multi-step transactions. It is the preferred tool for debugging deployment issues.
Frontend DApps. In production, users interact with smart contracts through web applications built with ethers.js or web3.js. The frontend connects to the user's wallet (typically MetaMask), constructs transactions, and submits them to the network. Building a frontend is beyond the scope of this chapter, but the contracts we have built are fully compatible with any web3 frontend framework.
Other contracts. Because SimpleVoting's createProposal, castVote, and executeProposal functions are all external, other smart contracts can call them programmatically. This is the foundation of composability — a DAO treasury contract could automatically create proposals, or an automated agent could execute passed proposals based on predefined criteria.
Common Solidity Pitfalls
Before we close this chapter, let us catalog the mistakes that catch every new Solidity developer. These are not obscure edge cases — they are the errors you will make in your first week.
Pitfall 1: Forgetting that state variable order affects gas costs. The compiler packs smaller types into a single 32-byte storage slot when they are declared consecutively. Declaring a uint128, then a uint256, then another uint128 wastes an entire storage slot. Group smaller types together.
// BAD: 3 storage slots
uint128 a; // slot 0 (16 bytes, 16 bytes wasted)
uint256 b; // slot 1 (full 32 bytes)
uint128 c; // slot 2 (16 bytes, 16 bytes wasted)
// GOOD: 2 storage slots
uint128 a; // slot 0 (first 16 bytes)
uint128 c; // slot 0 (last 16 bytes)
uint256 b; // slot 1 (full 32 bytes)
Pitfall 2: Using transfer or send for sending Ether. The transfer and send methods forward only 2,300 gas to the recipient, which is not enough for a contract recipient to do anything useful (not even emit an event since the Istanbul hard fork increased the cost of the SLOAD opcode). Use the low-level call pattern instead:
// Deprecated pattern
payable(recipient).transfer(amount);
// Recommended pattern
(bool success, ) = payable(recipient).call{value: amount}("");
require(success, "Transfer failed");
Pitfall 3: Integer division truncates. Solidity has no floating-point numbers. 5 / 2 equals 2, not 2.5. To handle precision, multiply before dividing and use a scaling factor:
// BAD: loses precision
uint256 fee = amount * 3 / 100; // 3% fee, but rounds down
// BETTER: scale up first
uint256 fee = (amount * 300) / 10000; // Same 3%, more room for precision
Pitfall 4: Not handling the return value of ERC-20 transfers. Some ERC-20 tokens (notably USDT on mainnet) do not return a boolean from transfer. If your contract calls token.transfer(to, amount) and checks the return value, the call will revert for these non-standard tokens. Use OpenZeppelin's SafeERC20 library in production code.
Pitfall 5: Reentrancy. The most famous smart contract vulnerability. If your contract sends Ether to an external address before updating its internal state, the recipient can call back into your contract and execute the withdrawal again before the balance is updated. The fix: always update state before making external calls (the "Checks-Effects-Interactions" pattern). We will cover reentrancy in depth in Chapter 15.
Summary
This chapter took you from zero Solidity knowledge to a working governance system deployed on a testnet. Let us review what you built and what each piece teaches:
Data Types and Storage. Solidity's type system reflects the EVM's constraints. The 256-bit word size, the distinction between value and reference types, and the explicit data location requirements (storage, memory, calldata) are all consequences of a virtual machine designed for deterministic execution on a global network where every operation costs money.
Functions, Modifiers, and Events. The four visibility levels (public, external, internal, private), the state mutability specifiers (view, pure, payable), and custom modifiers form a layered access control system. Events provide cheap, structured logging that powers the entire DApp ecosystem.
The ERC-20 Standard. Six functions and two events. That is all it takes to define a standard that underpins hundreds of billions of dollars in token value. The approve/transferFrom pattern enables composability — the ability for contracts to interact with tokens they did not create and were not designed for.
Testing and Deployment. Hardhat provides the complete workflow: compile, test against a local network, deploy to a testnet, and verify on Etherscan. Testing is not optional in a language where deployed code is immutable and bugs can drain funds.
If you have followed along and run the code, you now have a working understanding of how smart contracts are written, tested, and deployed. This is a foundation, not a destination. The contracts you built are functional but not production-ready — they lack the security hardening, gas optimization, and governance safeguards that real-world protocols require. The gap between "functional" and "production-ready" is where most of the hard work in smart contract development lives, and it is the subject of the next several chapters.
In Chapter 14, we will build on this foundation with advanced Solidity patterns: reentrancy guards, proxy contracts for upgradeability, gas optimization techniques, and the factory pattern. The governance contracts you built here will evolve as we introduce these patterns — starting with a snapshot mechanism that prevents vote manipulation through token transfers. The progression from the simple contracts in this chapter to the hardened, auditable contracts in later chapters mirrors the progression that every smart contract developer goes through: start by getting something working, then systematically address every way it could break.
🔗 Bridge to Chapter 14. You now have VotingToken.sol (an ERC-20 governance token) and SimpleVoting.sol (a basic governance contract). These contracts work but have known limitations: no snapshot mechanism for vote weight, no quorum requirement, no time-lock on execution, and no upgradeability. Chapter 14 addresses all four limitations using advanced Solidity patterns. Keep your code — we will refactor it, not replace it.