Market cap $2.28 T −6.41 % over 24 hours24h volume $91.15 BBTC dominance 58.7 %ETH 10.1 %Fear & Greed 13 Extreme fearCoins 22,238

How to learn Solidity: your first contract, tests and security basics

Learn Solidity hands-on: install Foundry, write and compile a first contract, test it with forge, then break and fix it with a reentrancy attack.

Intermediate 27 min readUpdated Read first: How to become a blockchain developer: a learning path
A laptop with a shield, a software bug and a magnifying glass beside it
Table of contents
  1. What you need before you start
  2. Set up a Solidity environment: Remix in the browser, Foundry on your machine
    1. Remix: try a contract in 20 minutes with zero install
    2. Foundry: install forge, cast, anvil and chisel with foundryup
    3. Why this lesson uses Foundry rather than Hardhat
    4. Create the project and read what forge init made
  3. Learn Solidity by writing your first contract, line by line
    1. The header: SPDX licence and pragma
    2. State variables, mappings and visibility
    3. Events and custom errors
    4. The modifier, the constructor and msg.sender
    5. deposit: payable functions and msg.value
    6. withdraw: why the order of three lines matters
    7. Compile it with forge build
  4. Test it with Foundry: forge test, cheatcodes and fuzzing
    1. How a Foundry test is built
    2. Cheatcodes: vm.prank, vm.deal and vm.expectEmit
    3. Fuzz tests: let Foundry invent the inputs
    4. Run the suite and read failures
    5. Run a local chain with anvil and query it with cast
  5. Solidity security basics: reentrancy, access control, tx.origin, overflow
    1. Reentrancy, demonstrated on your own machine
    2. Access control: who may call what
    3. Never authorise with tx.origin
    4. Integers: overflow checks since 0.8.0, and what they do not cover
    5. Five more pitfalls to know by name
    6. Use audited libraries and run Slither
    7. What tests and tools do not catch
  6. Which Solidity version to use in October 2026
  7. What to build after your first contract

If you want to learn Solidity, the fastest route is one small contract taken all the way: written, compiled, tested, attacked, fixed. That is this lesson. In about an hour on your own machine you will have a piggy bank contract that holds ether for each user, a test suite that checks deposits, withdrawals, errors and access rights, a fuzz test that throws random amounts at it, and a deliberately broken copy that a second contract drains in a reentrancy attack. No wallet, no real money, no network.

One rule frames all of it. A smart contract is public code that holds money and cannot be quietly patched once it is deployed, so a bug is a public, permanent and often expensive mistake. That is why the tests and the attacks come in the first hour here, not after the syntax.

Everything runs on a local chain. Deploying to a public test network, and plugging a web page into the contract, belongs to a later lesson of this course.

What you need before you start

This is lesson 3 of the careers path and it assumes the previous one: our guide on how to become a blockchain developer explains why Solidity is the language of Ethereum and the chains that copy its virtual machine, and what to learn first. Concretely, you should be at ease with one programming language (JavaScript, Python, Go or Rust all transfer well), with a terminal and with Git.

You also need a mental model of the chain. A contract runs on Ethereum or a compatible network; it only does something when a transaction or another contract calls it; and every call is signed by an account holding a private key. If msg.sender below sounds abstract, read how a crypto transaction is built and signed first. On a real network each step that writes data costs gas; on the local test chain used here, gas is still measured but paid with fake ether, so nothing costs you a cent.

Set up a Solidity environment: Remix in the browser, Foundry on your machine

Two set-ups are worth knowing. Remix needs nothing installed and is the quickest way to see a contract run. Foundry is a command-line toolkit and is what this lesson uses for the project, because tests are written in Solidity itself.

Remix: try a contract in 20 minutes with zero install

Remix is an IDE that runs in the browser at remix.ethereum.org (a desktop app also exists). You create a file, paste a contract, compile it from the side panel and deploy it to the Remix VM, which the Remix documentation describes as a sandbox blockchain in the browser. The state of that sandbox is saved between page reloads unless you switch the option off in the settings, but browser storage is fragile, so keep anything that matters in a Git repository.

Use Remix to test an idea or follow a short tutorial. For a project with tests, version control and libraries, move to a local toolkit.

Foundry: install forge, cast, anvil and chisel with foundryup

Foundry is a set of four command-line tools: forge builds and tests contracts, cast talks to a chain from the terminal, anvil runs a local Ethereum node and chisel is a Solidity shell for trying one-liners. The official installation page gives a two-step install on macOS and Linux:

curl -L https://foundry.paradigm.xyz | bash

Then open a new terminal (or reload your shell profile, for example with source ~/.zshrc) and run:

foundryup
forge --version

The first line downloads the foundryup installer; foundryup then fetches the latest stable release of the four tools and, according to the docs, checks the SHA-256 hash of each binary against the release attestation on GitHub. Piping a script from the internet into your shell is a trust decision: the address above is the one the Foundry docs give, and you can open the script and read it first, or use the official Docker image (ghcr.io/foundry-rs/foundry:latest) instead.

On Windows, the docs ask for Git Bash or WSL; PowerShell and the Command Prompt are not supported. As of 27 October 2026, the latest stable Foundry release on GitHub is v1.8.6 (21 October 2026, a patch release for dependency security fixes), and nightly builds come out daily. Run foundryup again from time to time to update.

Why this lesson uses Foundry rather than Hardhat

Hardhat is the other major toolkit for Ethereum contracts, and the previous lesson presented both. Both are serious, maintained tools, and the choice is a matter of taste. The reason here is practical: in Foundry a test is just another Solidity contract, so you practise the language you are learning in every test you write, and there is no JavaScript layer to set up next to it. Nothing you learn here is lost if a future employer uses Hardhat: the contract code is the same.

Create the project and read what forge init made

Create the project folder. The first form keeps Foundry’s example Counter contract, which is handy for a first look; the second starts empty.

forge init piggybank

or, without the example files:

forge init --empty piggybank
cd piggybank
forge build
forge test

We read the source of forge init in Foundry v1.8.6 to check what it creates. You get src/ for contracts, test/ for tests, script/ for deployment scripts, lib/ with the forge-std testing library installed, a foundry.toml configuration file and a README. Unless you pass --no-git, it also makes the folder a Git repository and adds a .gitignore and a GitHub Actions workflow that runs the tests, when those files do not exist yet. Without --empty, the example files are src/Counter.sol, test/Counter.t.sol and script/Counter.s.sol: run forge test once on them to check the install works, then delete them.

A warning about older tutorials: many show forge init --no-commit or forge install --no-commit. Recent Foundry no longer commits by default, the current reference lists only --commit, so drop the old flag. The forge-std library that forge init installs was at v1.17.0 (30 September 2026) as of 27 October 2026.

For the editor, VS Code with a Solidity extension gives syntax highlighting and jump-to-definition. forge init --vscode writes a .vscode/settings.json and a remappings.txt so the extension finds the libraries in lib/; the Foundry source says those settings target Juan Blanco’s vscode-solidity extension. Other extensions work too; pick one that is still updated.

Learn Solidity by writing your first contract, line by line

The contract is a piggy bank. Anyone can deposit ether, each address can withdraw its own balance and nothing more, and the owner (the account that deployed it) can pause new deposits. It is short enough to read in full, and every line teaches one feature of the language. Here it is whole; save it as src/PiggyBank.sol.

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

/// @notice Each user can deposit ether and withdraw their own balance.
contract PiggyBank {
    address public immutable owner;
    bool public paused;
    mapping(address => uint256) public balanceOf;

    event Deposited(address indexed account, uint256 amount);
    event Withdrawn(address indexed account, uint256 amount);

    error NotOwner();
    error IsPaused();
    error ZeroAmount();
    error InsufficientBalance(uint256 available, uint256 requested);
    error TransferFailed();

    modifier onlyOwner() {
        require(msg.sender == owner, NotOwner());
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function setPaused(bool newState) external onlyOwner {
        paused = newState;
    }

    function deposit() external payable {
        if (paused) revert IsPaused();
        if (msg.value == 0) revert ZeroAmount();
        balanceOf[msg.sender] += msg.value;
        emit Deposited(msg.sender, msg.value);
    }

    function withdraw(uint256 amount) external {
        uint256 available = balanceOf[msg.sender];
        if (amount > available) revert InsufficientBalance(available, amount);

        // Effects first: update the balance BEFORE sending ether out.
        balanceOf[msg.sender] = available - amount;
        emit Withdrawn(msg.sender, amount);

        // Interaction last.
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, TransferFailed());
    }
}

The header: SPDX licence and pragma

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

The first line is a machine-readable licence tag. The compiler warns when it is missing; MIT is a common choice for open code. The second line is the pragma: ^0.8.28 means “any compiler from 0.8.28 up to, but not including, 0.9.0”. By default (the auto_detect_solc setting is on), Foundry reads the pragma, picks a compiler version that satisfies it and downloads that version if it is not on your machine yet.

Why 0.8.28 as the floor? The contract uses require with a custom error. According to the Solidity changelog, 0.8.26 (May 2024) introduced that form for the via-IR pipeline only, and 0.8.27 (September 2024) made it work with the default, legacy pipeline that most projects use. A pragma states the oldest compiler your code is known to work with; we compiled every file in this lesson with solc 0.8.28 and 0.8.38, with no errors and no warnings. Do not write an open-ended >=0.8.0 on code that holds money: you would be promising it compiles and behaves the same on versions you never tried.

State variables, mappings and visibility

address public immutable owner;
bool public paused;
mapping(address => uint256) public balanceOf;

These three lines are the contract’s storage, kept on chain between calls. owner is an address marked immutable: it is set once in the constructor and then baked into the deployed code, which makes it cheaper to read and impossible to change. paused is a plain boolean that starts as false.

The mapping is the heart of the bank. It works like a hash table from an address to a number, with one twist the Solidity docs stress: every possible key already “exists” with the value zero, and you cannot list the keys. So balanceOf[someone] is 0 until that someone deposits, and if you ever need the list of depositors you must store it yourself.

The word public makes the compiler write a getter function for free, so anyone can call owner(), paused() or balanceOf(address). Functions have four visibilities: public, external (callable only from outside), internal (this contract and contracts that inherit from it) and private (this contract only). Private does not mean secret: all contract storage can be read by anyone who queries the chain.

Events and custom errors

event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);

error NotOwner();
error IsPaused();
error ZeroAmount();
error InsufficientBalance(uint256 available, uint256 requested);
error TransferFailed();

Events are logs. A contract cannot read them, but wallets, block explorers and the decentralised applications built on top of a contract listen to them to update a screen without re-reading all the storage. Marking account as indexed lets an app filter the log, for instance “all deposits by this address”.

Custom errors give a failed call a name and, if needed, data. InsufficientBalance(available, requested) tells the caller exactly what went wrong, which helps both users and tests. They are also cheaper than long revert strings, because only a 4-byte selector plus the arguments is returned.

The modifier, the constructor and msg.sender

modifier onlyOwner() {
    require(msg.sender == owner, NotOwner());
    _;
}

constructor() {
    owner = msg.sender;
}

function setPaused(bool newState) external onlyOwner {
    paused = newState;
}

msg.sender is the address that made the current call: an account signing a transaction, or another contract calling this one. In the constructor, which runs once at deployment, it is the deployer, so that address becomes the owner.

A modifier wraps a function. onlyOwner runs its check, then the _; placeholder runs the body of the function it decorates. require(condition, NotOwner()) takes the condition that must be true first: if it is false, the call reverts with the custom error. It is the mirror image of the if (...) revert form used in deposit below, where the if names the situation that must not happen. Both give the caller the same error; pick the one that reads better. A revert undoes every change of the call, as if it had never happened. setPaused is external because nothing inside the contract calls it.

deposit: payable functions and msg.value

function deposit() external payable {
    if (paused) revert IsPaused();
    if (msg.value == 0) revert ZeroAmount();
    balanceOf[msg.sender] += msg.value;
    emit Deposited(msg.sender, msg.value);
}

Only a function marked payable can receive ether; send ether to any other function and the call reverts. msg.value is the amount sent, in wei (1 ether is 1018 wei; the code can write 1 ether directly). The function checks first, with the if (...) revert Error(); form, which is the other way to raise a custom error and works on every 0.8 compiler since 0.8.4. Then it updates storage, then it emits the event.

withdraw: why the order of three lines matters

function withdraw(uint256 amount) external {
    uint256 available = balanceOf[msg.sender];
    if (amount > available) revert InsufficientBalance(available, amount);

    // Effects first: update the balance BEFORE sending ether out.
    balanceOf[msg.sender] = available - amount;
    emit Withdrawn(msg.sender, amount);

    // Interaction last.
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, TransferFailed());
}

Read the order. First the check (does the caller have enough?). Then the effect: the balance goes down in storage. Only at the end comes the interaction, sending ether with the low-level .call{value: amount}(""), which returns a success flag that the code must test: require(ok, TransferFailed()) reverts the whole withdrawal, balance update included, when the transfer fails. This sequence has a name, checks-effects-interactions, and the security section below shows what happens when the last two steps swap places.

This is also the “pull” pattern the Solidity docs recommend: users come and withdraw their own money, instead of the contract pushing ether to a list of people, where one failing recipient could block everybody.

Compile it with forge build

Delete the Counter files if you kept them, save the contract and run forge build. If no matching solc compiler is installed yet, Foundry downloads one first; then it writes the contract artifacts into out/, the default output folder per the configuration reference. For each contract, the artifact holds the ABI (the list of functions, events and errors that other programs use to call it) and the bytecode that would be deployed. A typo makes the build stop with the file, line and a message; warnings do not stop it, but treat each one as a bug report.

A document, a turning gear, stacked blocks and coins on a plain background
forge build turns the piggy bank source into an ABI and bytecode, ready to hold users' ether.

A note on our own checks: we compiled this contract and every test file in this lesson with the official solc-js compiler, versions 0.8.28 and 0.8.38, against forge-std v1.17.0 and OpenZeppelin Contracts v5.7.0. All compiled without a warning. We traced the test logic by hand but did not run forge on this machine, so we do not print forge’s output: run the commands and read your own.

Test it with Foundry: forge test, cheatcodes and fuzzing

A contract you have not tested is a contract you have not finished. In Foundry, tests are Solidity contracts in the test/ folder, in files ending in .t.sol, as the Foundry testing guide lays out. Save the following as test/PiggyBank.t.sol. The first half sets the scene and checks the happy paths.

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

import {Test} from "forge-std/Test.sol";
import {PiggyBank} from "../src/PiggyBank.sol";

contract PiggyBankTest is Test {
    PiggyBank bank;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    // Same event as in the contract, so the test can say what it expects.
    event Deposited(address indexed account, uint256 amount);

    function setUp() public {
        bank = new PiggyBank(); // this test contract becomes the owner
        vm.deal(alice, 10 ether);
        vm.deal(bob, 10 ether);
    }

    function test_DepositUpdatesBalance() public {
        vm.prank(alice);
        bank.deposit{value: 1 ether}();

        assertEq(bank.balanceOf(alice), 1 ether);
        assertEq(address(bank).balance, 1 ether);
    }

    function test_DepositEmitsEvent() public {
        vm.expectEmit(true, false, false, true);
        emit Deposited(alice, 1 ether);

        vm.prank(alice);
        bank.deposit{value: 1 ether}();
    }

    function test_WithdrawReturnsEther() public {
        vm.prank(alice);
        bank.deposit{value: 1 ether}();
        vm.prank(alice);
        bank.withdraw(1 ether);

        assertEq(alice.balance, 10 ether);
        assertEq(bank.balanceOf(alice), 0);
    }

    // ... the revert and fuzz tests follow below
}

How a Foundry test is built

The test contract inherits Test from forge-std, which brings the assertions and the cheatcodes. setUp() runs before every single test, so each one starts from a fresh bank: tests never depend on each other. Every function whose name starts with test is a test; the convention is test_ plus what it proves.

assertEq(a, b) fails the test when the two values differ. makeAddr("alice") returns a fresh address and labels it “alice” in traces, so you read names instead of hex. In setUp, the test contract deploys the bank, so the test contract itself is the owner; that detail matters for the pause test below.

Cheatcodes: vm.prank, vm.deal and vm.expectEmit

Cheatcodes are special functions of the test environment, reached through the vm object. Behind it sits a fixed address, 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D, which Foundry intercepts. The three used so far:

  • vm.deal(alice, 10 ether) sets Alice’s ether balance out of thin air. It exists only in the test environment.
  • vm.prank(alice) makes the next call come from Alice. In the words of the Foundry reference, it “sets msg.sender to the specified address for the next call”. Only the next one: that is why the withdraw test calls it twice.
  • vm.expectEmit(true, false, false, true) announces that the next call must emit an event. The four booleans say which parts to compare: the first indexed field (here account), the second and third (unused), and the data (amount). You then emit the event you expect, copied into the test contract, and make the call.

The second half of the file tests what must fail, plus one fuzz test. Paste it in place of the comment line.

    function test_RevertWhen_DepositIsZero() public {
        vm.expectRevert(PiggyBank.ZeroAmount.selector);
        vm.prank(alice);
        bank.deposit();
    }

    function test_RevertWhen_WithdrawingTooMuch() public {
        vm.prank(alice);
        bank.deposit{value: 1 ether}();

        vm.expectRevert(
            abi.encodeWithSelector(PiggyBank.InsufficientBalance.selector, 1 ether, 2 ether)
        );
        vm.prank(alice);
        bank.withdraw(2 ether);
    }

    function test_RevertWhen_NotOwnerPauses() public {
        vm.expectRevert(PiggyBank.NotOwner.selector);
        vm.prank(bob);
        bank.setPaused(true);
    }

    function test_OwnerCanPause() public {
        bank.setPaused(true); // msg.sender is this test contract, the owner

        vm.expectRevert(PiggyBank.IsPaused.selector);
        vm.prank(alice);
        bank.deposit{value: 1 ether}();
    }

    function testFuzz_DepositThenWithdraw(uint256 amount) public {
        amount = bound(amount, 1, 100 ether);
        vm.deal(alice, amount);

        vm.prank(alice);
        bank.deposit{value: amount}();
        vm.prank(alice);
        bank.withdraw(amount);

        assertEq(alice.balance, amount);
        assertEq(bank.balanceOf(alice), 0);
    }

vm.expectRevert says the next call must revert, and with which error. A custom error without arguments is matched by its selector, PiggyBank.ZeroAmount.selector; one with arguments needs the full encoded error, which abi.encodeWithSelector builds. The docs specify that other cheatcode calls between expectRevert and the reverting call are ignored, so the vm.prank in between is fine. Tests for failures are as important as tests for success: test_RevertWhen_NotOwnerPauses is the one that proves the access control works.

Fuzz tests: let Foundry invent the inputs

A test function with parameters becomes a fuzz test. Foundry calls testFuzz_DepositThenWithdraw with many random values of amount, 256 runs per test by default according to the testing configuration reference. If one input breaks an assertion, the test fails and forge reports the input that did it.

bound(amount, 1, 100 ether) folds any random number into the range you care about. The alternative, vm.assume(condition), throws away inputs that do not match, and too many rejections make the run fail (the default limit is 65,536); Foundry’s own guides prefer bound. To run more cases, add this to foundry.toml:

[fuzz]
runs = 1000

Run the suite and read failures

forge test
forge test -vv
forge test --match-test testFuzz_DepositThenWithdraw -vvv

Each extra “v” shows more. The test guide lists the levels:

FlagWhat forge shows
(none)Summary: which tests passed or failed
-vvLogs emitted during tests
-vvvExecution traces of failing tests
-vvvvTraces of all tests, set-up included
-vvvvvStorage changes too
–match-test, –match-contract, –match-pathRun only the tests whose name, contract or file matches

When a test fails, run it alone with --match-test and -vvv: the trace shows each call, who made it, what it returned and where it reverted.

The next step after fuzzing is the invariant test: a function named invariant_... states something that must stay true whatever sequence of calls happens, for example “the bank’s ether balance is never below the sum of all user balances”. Foundry then calls the contract’s functions in random order (256 runs of depth 500 by default) and checks the statement after each. The invariant testing guide shows the pattern; try it once the unit tests feel natural.

Run a local chain with anvil and query it with cast

Tests run in forge’s own in-memory EVM, so you do not need a node for them. When you want a chain that behaves like a real network, start anvil in a second terminal. Per its reference page, it listens on 127.0.0.1:8545 and creates 10 development accounts holding 10,000 test ether each. Then ask it questions with cast:

anvil

In another terminal, with one of the test addresses anvil lists in place of the placeholder:

cast balance <address> --ether --rpc-url http://127.0.0.1:8545

The keys of anvil’s development accounts are public knowledge: never send real funds to those addresses. More generally, never paste the key of a real wallet into a tutorial project, a .env file or a Git repository; if keys are new to you, read how private and public keys work. Deploying to anvil and then to a public testnet uses forge scripts, which a later lesson covers.

Solidity security basics: reentrancy, access control, tx.origin, overflow

Contracts are attacked because they hold value and their code is public. Most losses do not come from breaking the chain’s cryptography; they come from contract logic that does something its author did not expect, as our guide on whether a blockchain can be hacked explains. The Solidity documentation has a security considerations page that every beginner should read once. Here are the classics, starting with one you can run.

Reentrancy, demonstrated on your own machine

Below is a bank written badly on purpose, an attacker contract and a test that proves the attack. It is a textbook example run only on Foundry’s test chain with made-up addresses. Save it as test/Reentrancy.t.sol; do not deploy this code anywhere.

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

import {Test} from "forge-std/Test.sol";

// BAD ON PURPOSE: sends ether before updating the balance.
contract VulnerableBank {
    mapping(address => uint256) public balanceOf;

    function deposit() external payable {
        balanceOf[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balanceOf[msg.sender];
        require(amount > 0, "nothing to withdraw");
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
        balanceOf[msg.sender] = 0; // too late
    }
}

contract Attacker {
    VulnerableBank public immutable bank;

    constructor(VulnerableBank _bank) {
        bank = _bank;
    }

    function attack() external payable {
        bank.deposit{value: msg.value}();
        bank.withdraw();
    }

    // Runs every time the bank sends ether to this contract.
    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw();
        }
    }
}

contract ReentrancyTest is Test {
    function test_AttackerDrainsVulnerableBank() public {
        VulnerableBank bank = new VulnerableBank();
        address victim = makeAddr("victim");
        address thief = makeAddr("thief");
        vm.deal(victim, 10 ether);
        vm.deal(thief, 1 ether);

        vm.prank(victim);
        bank.deposit{value: 10 ether}();

        Attacker attacker = new Attacker(bank);
        vm.prank(thief);
        attacker.attack{value: 1 ether}();

        assertEq(address(bank).balance, 0);
        assertEq(address(attacker).balance, 11 ether);
    }
}

Follow the ether. The victim deposits 10 ether. The attacker contract deposits 1, so the bank holds 11 and records 1 for the attacker. Then it calls withdraw. The bank reads the balance (1), sends 1 ether, and only after that would it set the balance to zero.

But sending ether to a contract runs that contract’s receive function, and the low-level call passes along nearly all the remaining gas. The attacker’s receive calls withdraw again. The bank still has the attacker on record with 1 ether, because the line that zeroes it has not run yet, so it pays again. The bank pays out eleven times in all, until it is empty. Then each nested call finishes and sets the balance to zero, far too late.

The two assertions check the result: the bank ends with 0 and the attacker with 11 ether, the victim’s 10 plus its own 1. The Solidity security page gives reentrancy its own section near the top of its pitfalls, right after private information and randomness, with a vulnerable example close to this one.

A hooded attacker beside an open vault, a broken chain and a warning sign
In the test, the vulnerable bank pays the attacker contract eleven times before it updates a single balance.

The fix is the line order you already wrote in PiggyBank.withdraw: update the balance before the external call. When the attacker re-enters, its balance is already zero and the check stops it. That ordering is the checks-effects-interactions pattern, which the Solidity docs recommend in their list of general advice. A second layer is a guard that refuses re-entry outright. OpenZeppelin’s ReentrancyGuard provides it as a nonReentrant modifier:

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

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract FixedBank is ReentrancyGuard {
    mapping(address => uint256) public balanceOf;

    function deposit() external payable {
        balanceOf[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 amount = balanceOf[msg.sender];
        require(amount > 0, "nothing to withdraw");
        balanceOf[msg.sender] = 0; // effect before interaction
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
    }
}

Use both: the right order is the real fix, the guard protects you on the day someone adds a function in the wrong order. OpenZeppelin also ships ReentrancyGuardTransient, a cheaper variant that relies on transient storage, for chains that support it.

Access control: who may call what

PiggyBank checks its owner by hand with a modifier, which is fine for one rule. For real projects, use OpenZeppelin’s Ownable. Since version 5 its constructor takes the initial owner as an argument, so you always state who owns the contract:

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

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract OwnedSwitch is Ownable {
    bool public paused;

    constructor() Ownable(msg.sender) {}

    function setPaused(bool newState) external onlyOwner {
        paused = newState;
    }
}

For several roles (one account that pauses, another that mints), the same library has AccessControl. Whatever you use, write the negative test, as test_RevertWhen_NotOwnerPauses does. One function without its check is enough to hand control of a contract to anyone, and a happy-path test will never show it.

Never authorise with tx.origin

tx.origin is the account that signed the transaction, even when the call reached your contract through other contracts; msg.sender is the immediate caller. If your wallet contract checks tx.origin == owner, a malicious contract you are tricked into calling can call your wallet in turn, and the check passes. The Solidity docs put it in five words: “Never use tx.origin for authorization.” Use msg.sender.

Integers: overflow checks since 0.8.0, and what they do not cover

Since Solidity 0.8.0, arithmetic operations revert on overflow and underflow, per the 0.8.0 breaking-changes page. Subtracting 2 from a balance of 1 now reverts instead of wrapping around to a huge number, which removed a whole family of bugs found in older code. An unchecked { ... } block switches the checks off to save gas, and you should only see it where the code proves the operation cannot overflow.

The checks do not save you from logic errors (a correct sum of the wrong numbers), from rounding in divisions (Solidity has no decimals, and 7 / 2 is 3), or from explicit conversions to smaller types: converting a large uint256 into a uint96 keeps only the low bits, without reverting.

Five more pitfalls to know by name

  • Loops over a list that users can grow can run out of gas one day and lock the function for good. Avoid unbounded loops over storage.
  • Anything in storage is readable by anyone, including private variables. Never store a password or a secret answer in a contract.
  • Block data such as the timestamp is not a source of randomness: block producers influence it. Use a verifiable randomness service when money depends on chance.
  • Deleting a struct that contains a mapping does not clear the mapping. The Solidity docs list it under “clearing mappings”.
  • A contract can receive ether without any of its functions running, so never assume the contract’s ether balance equals what your own bookkeeping says.

Use audited libraries and run Slither

Do not rewrite tokens, ownership or guards yourself. OpenZeppelin Contracts is a widely used library of audited building blocks; its latest stable release is v5.7.0, published on 29 July 2026. Install a tagged release and map the import path; the docs warn against installing from the master branch.

forge install OpenZeppelin/[email protected]
echo "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/" >> remappings.txt

Then run a static analyser. Slither, developed in the open under the crytic organisation on GitHub, reads your code without running it and flags known bug patterns, reentrancy included. Its latest release is 0.11.6 (28 July 2026), and it needs Python 3.10 or later. In a Foundry project, make sure forge build passes first, then:

python3 -m pip install slither-analyzer
slither .

Its list of detectors includes several for reentrancy. Read every finding, fix it or write down why it is harmless, and expect false positives; the point is to look at each one.

What tests and tools do not catch

A green test suite proves the cases you thought of. Experienced teams add external audits, public audit contests and bug bounties before real money goes in; the previous lesson of this path explains how those contests work and why they are a good place to learn. For your own projects, keep the money a contract can hold small until the code has been read by someone other than you.

Which Solidity version to use in October 2026

As of 27 October 2026, the latest Solidity release is 0.8.38, published on 22 October 2026, according to the project’s GitHub releases. The release announcement describes it as fixing a “low-severity bug in the Yul optimizer”: when a variable used to compute a memory or storage address was reassigned, the optimizer could keep outdated knowledge and replace a load with a stale value or drop a needed store. It concerns code compiled with the optimizer on, from 0.8.19 through 0.8.37: with the via-IR pipeline any code may be affected, with the default pipeline mainly compiler-generated helper functions and some inline assembly.

ToolLatest versionRelease date
Solidity compiler0.8.3822 October 2026
Solidity, previous0.8.3710 September 2026
Foundry (stable)v1.8.621 October 2026
forge-stdv1.17.030 September 2026
OpenZeppelin Contractsv5.7.029 July 2026
Slither0.11.628 July 2026

Versions as listed on each project’s GitHub releases page, checked on 27 October 2026.

In practice: compile with the latest 0.8 release, keep a pragma range whose floor you have actually tested, and when you deploy something real, pin one exact compiler version in foundry.toml so the bytecode can be reproduced and verified. When a tutorial uses a much older version, such as 0.6 or 0.7, the syntax may still compile but the safety rules differ, overflow checks first among them; read the Solidity introduction for 0.8.38 rather than an old blog post.

What to build after your first contract

You now have the loop that every Solidity developer repeats, and the best way to learn Solidity from here is to run it again and again: write, build, test the success and the failures, fuzz, read the traces, run the analyser. Practise it on variations before moving on. Add a withdrawal limit per day. Let the owner withdraw nothing but pause everything. Write an invariant test that the bank always holds at least the sum of its balances. Each change will break a test, which is the point.

Two later lessons of this course pick up from here: one gathers the books, documentation and certifications worth your time, and another connects a contract to a web page and deploys it on a public test network. If you want the month-by-month plan again, it is in our guide to becoming a blockchain developer, and the blockchain jobs guide shows the roles where these skills are used.

A last remark for US and UK readers. This lesson is about code, not law. A contract that takes in users’ money or issues tokens can raise questions for regulators such as the SEC or the CFTC in the United States and the FCA in the United Kingdom; that is a separate study, and a teaching project on a local chain raises none of them.

Sources

  1. Solidity documentation: Introduction to Smart Contracts (v0.8.38) docs.soliditylang.org
  2. Solidity documentation: Security Considerations docs.soliditylang.org
  3. Solidity documentation: Solidity v0.8.0 Breaking Changes docs.soliditylang.org
  4. Solidity 0.8.38 release announcement (22 October 2026) soliditylang.org
  5. Solidity releases on GitHub github.com
  6. Foundry: installation getfoundry.sh
  7. Foundry: writing tests getfoundry.sh
  8. Foundry: testing configuration reference getfoundry.sh
  9. OpenZeppelin Contracts 5.x documentation docs.openzeppelin.com
  10. Slither static analyser (crytic, GitHub) github.com
Coindaro Editorial Team

Who wrote this article

Editorial team

Guides, reviews and glossary checked against primary sources

  • Crypto education
  • Exchange reviews
  • Wallets
  • Glossary

The Coindaro editorial team researches and writes every article, review and guide published on Coindaro. Each fact is checked against primary sources such as official documents, regulators and project documentation, and every page lists its sources. We cover crypto markets, technology and regulation for readers in the United States and Europe, independently and without paid placements.

Keep learning