IAMUVIN

Web3 Development

Solidity Smart Contract Development: The Complete 2026 Guide

Uvin Vindula·January 15, 2024·14 min read

Last updated: April 14, 2026

Share

TL;DR

Solidity smart contract development in 2026 means writing in Solidity 0.8.24+, testing with Foundry, and deploying to L2s like Arbitrum, Base, and Optimism. The stack I use daily: forge for compilation and testing, OpenZeppelin contracts for ERC standards and access control, and a strict Checks-Effects-Interactions pattern on every function that touches state. You need fuzz testing — not just unit tests. You need gas benchmarks before every deploy. And you need an emergency pause mechanism in every production contract. This guide walks through the exact workflow I follow at Terra Labz for mainnet deployments: from forge init to verified contracts on Etherscan. No theory. Real code, real patterns, real security considerations.


Setting Up Your Solidity Development Environment

Before you write a single line of Solidity, your toolchain needs to be right. I switched from Hardhat to Foundry in late 2022 and never looked back. Foundry is faster, written in Rust, and lets you write tests in Solidity itself — no context-switching to JavaScript.

Install Foundry

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

This gives you four tools:

  • forge — build, test, deploy
  • cast — interact with contracts from the CLI
  • anvil — local Ethereum node
  • chisel — Solidity REPL

Initialize a Project

bash
forge init my-protocol
cd my-protocol

This scaffolds a clean project structure:

my-protocol/
├── src/           # Your contracts
├── test/          # Foundry tests (in Solidity)
├── script/        # Deployment scripts
├── lib/           # Dependencies (git submodules)
└── foundry.toml   # Configuration

Install OpenZeppelin

bash
forge install OpenZeppelin/openzeppelin-contracts

Then configure remappings in foundry.toml:

toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.24"
optimizer = true
optimizer_runs = 200

remappings = [
  "@openzeppelin/=lib/openzeppelin-contracts/"
]

I set optimizer_runs to 200 for most contracts. If your contract gets called thousands of times (like a DEX router), bump it to 10,000. For one-off deployment contracts, drop it to 1. This directly affects gas costs.

Why Not Hardhat?

Hardhat still has its place — the plugin ecosystem is massive, and if your team lives in TypeScript, it works fine. But for solo developers and small teams, Foundry wins on speed. My test suite runs in 2 seconds with Foundry. The same tests took 45 seconds in Hardhat. When you run tests hundreds of times a day, that adds up.

I still use Hardhat for specific tasks: Hardhat Ignition for complex deployment orchestration, and when a client's existing codebase is already Hardhat-based. But for new projects, it is Foundry every time.


Solidity Language Fundamentals You Actually Need

I am not going to teach you what a uint256 is. If you are reading this, you know the basics. Here is what actually matters in production Solidity — the patterns that separate toy contracts from mainnet-ready code.

Storage Layout and Gas Optimization

Every storage slot on Ethereum is 32 bytes. Solidity packs variables into slots when it can. Your variable ordering matters:

solidity
// BAD — 3 storage slots (wastes gas)
contract BadLayout {
    uint128 amount;     // Slot 0 (16 bytes, rest wasted)
    address owner;      // Slot 1 (20 bytes, rest wasted)
    uint128 deadline;   // Slot 2 (16 bytes, rest wasted)
}

// GOOD — 2 storage slots (packed)
contract GoodLayout {
    uint128 amount;     // Slot 0 (first 16 bytes)
    uint128 deadline;   // Slot 0 (next 16 bytes) — PACKED
    address owner;      // Slot 1 (20 bytes)
}

Every extra storage slot costs 20,000 gas on first write. That is real money. I review storage layout on every contract before deployment.

Custom Errors Over Require Strings

Since Solidity 0.8.4, custom errors save gas compared to require with string messages:

solidity
// OLD — expensive (stores string in bytecode)
require(msg.sender == owner, "Not the owner");

// NEW — cheap and descriptive
error NotOwner(address caller, address owner);

if (msg.sender != owner) {
    revert NotOwner(msg.sender, owner);
}

Custom errors save roughly 50 gas per revert and reduce deployment costs because the string is not stored in bytecode. Use them everywhere.

Events for Off-Chain Indexing

Every state change should emit an event. This is not optional. Your frontend, your subgraph, your monitoring — everything depends on events:

solidity
event Deposited(
    address indexed user,
    uint256 amount,
    uint256 timestamp
);

function deposit() external payable {
    balances[msg.sender] += msg.value;
    emit Deposited(msg.sender, msg.value, block.timestamp);
}

Mark up to three parameters as indexed for efficient filtering. I index addresses and IDs — never amounts, because you rarely filter by exact amount.

Immutable and Constant

If a value is set once at deployment and never changes, mark it immutable. If it is a compile-time constant, mark it constant. Both save gas because they do not use storage slots:

solidity
contract MyToken {
    address public immutable TREASURY;    // Set in constructor
    uint256 public constant MAX_SUPPLY = 1_000_000e18;  // Compile-time

    constructor(address treasury_) {
        TREASURY = treasury_;
    }
}

Security Patterns That Prevent Real Exploits

I have reviewed audit reports on rekt.news every week since 2022. The same patterns keep showing up. These are the defenses I put in every contract.

Checks-Effects-Interactions (CEI)

This is the single most important pattern in Solidity. It prevents reentrancy attacks, and it is non-negotiable:

solidity
function withdraw(uint256 amount) external {
    // 1. CHECKS — validate everything first
    if (amount == 0) revert ZeroAmount();
    if (balances[msg.sender] < amount) revert InsufficientBalance();

    // 2. EFFECTS — update state BEFORE external calls
    balances[msg.sender] -= amount;

    // 3. INTERACTIONS — external calls LAST
    (bool success, ) = msg.sender.call{value: amount}("");
    if (!success) revert TransferFailed();

    emit Withdrawn(msg.sender, amount);
}

The DAO hack in 2016 happened because effects came after interactions. Eight years later, protocols still get drained by the same bug. Follow CEI religiously.

Reentrancy Guard

Even with CEI, add the nonReentrant modifier as defense-in-depth. OpenZeppelin makes this trivial:

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

contract Vault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function withdraw(uint256 amount) external nonReentrant {
        if (balances[msg.sender] < amount) revert InsufficientBalance();
        balances[msg.sender] -= amount;

        (bool success, ) = msg.sender.call{value: amount}("");
        if (!success) revert TransferFailed();

        emit Withdrawn(msg.sender, amount);
    }
}

Access Control

Never roll your own access control. OpenZeppelin's AccessControl gives you role-based permissions:

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

contract Treasury is AccessControl {
    bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE");
    bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");

    constructor(address admin) {
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
        _grantRole(MANAGER_ROLE, admin);
    }

    function setLimit(uint256 newLimit) external onlyRole(MANAGER_ROLE) {
        withdrawalLimit = newLimit;
    }

    function emergencyWithdraw() external onlyRole(WITHDRAWER_ROLE) {
        // ...
    }
}

In production, the DEFAULT_ADMIN_ROLE should be a multi-sig (Gnosis Safe). Never a single EOA.

Emergency Pause

Every production contract needs a circuit breaker. When an exploit is in progress, you need to stop it immediately:

solidity
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract Protocol is Pausable, AccessControl {
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    function deposit() external payable whenNotPaused {
        // Normal operation
    }

    function pause() external onlyRole(PAUSER_ROLE) {
        _pause();
    }

    function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {
        _unpause();
    }
}

Notice that anyone with PAUSER_ROLE can pause, but only the admin can unpause. This is intentional — in an emergency, speed matters for pausing. Unpausing should require higher authority.


A Complete Production Contract

Here is a full staking contract that uses every pattern from this guide. This is close to what I deploy in production:

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

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

/// @title SimpleStaking
/// @author Uvin Vindula (IAMUVIN)
/// @notice Stake ERC-20 tokens and earn rewards over time
contract SimpleStaking is ReentrancyGuard, Pausable, AccessControl {
    using SafeERC20 for IERC20;

    // ── Errors ──────────────────────────────────────────────
    error ZeroAmount();
    error InsufficientStake();
    error NoRewardsToClaim();
    error StakingPeriodNotEnded();

    // ── Events ──────────────────────────────────────────────
    event Staked(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);
    event RewardsClaimed(address indexed user, uint256 reward);
    event RewardRateUpdated(uint256 oldRate, uint256 newRate);

    // ── State ───────────────────────────────────────────────
    IERC20 public immutable STAKING_TOKEN;
    IERC20 public immutable REWARD_TOKEN;

    bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE");

    uint256 public rewardRatePerSecond;
    uint256 public totalStaked;

    struct UserStake {
        uint128 amount;
        uint128 rewardDebt;
        uint64 lastClaimTimestamp;
        uint64 stakeTimestamp;
    }

    mapping(address => UserStake) public stakes;

    // ── Constructor ─────────────────────────────────────────
    constructor(
        address stakingToken_,
        address rewardToken_,
        uint256 initialRewardRate_,
        address admin_
    ) {
        STAKING_TOKEN = IERC20(stakingToken_);
        REWARD_TOKEN = IERC20(rewardToken_);
        rewardRatePerSecond = initialRewardRate_;

        _grantRole(DEFAULT_ADMIN_ROLE, admin_);
        _grantRole(MANAGER_ROLE, admin_);
    }

    // ── Core Functions ──────────────────────────────────────
    function stake(uint256 amount) external nonReentrant whenNotPaused {
        if (amount == 0) revert ZeroAmount();

        UserStake storage user = stakes[msg.sender];

        // Claim pending rewards before updating stake
        uint256 pending = _pendingReward(user);
        if (pending > 0) {
            user.rewardDebt += uint128(pending);
        }

        // Effects
        user.amount += uint128(amount);
        user.lastClaimTimestamp = uint64(block.timestamp);
        if (user.stakeTimestamp == 0) {
            user.stakeTimestamp = uint64(block.timestamp);
        }
        totalStaked += amount;

        // Interactions
        STAKING_TOKEN.safeTransferFrom(msg.sender, address(this), amount);

        emit Staked(msg.sender, amount);
    }

    function withdraw(uint256 amount) external nonReentrant {
        UserStake storage user = stakes[msg.sender];
        if (amount == 0) revert ZeroAmount();
        if (user.amount < amount) revert InsufficientStake();

        // Claim pending rewards
        uint256 pending = _pendingReward(user);

        // Effects
        user.amount -= uint128(amount);
        user.lastClaimTimestamp = uint64(block.timestamp);
        totalStaked -= amount;

        // Interactions
        STAKING_TOKEN.safeTransfer(msg.sender, amount);
        if (pending > 0) {
            REWARD_TOKEN.safeTransfer(msg.sender, pending);
            emit RewardsClaimed(msg.sender, pending);
        }

        emit Withdrawn(msg.sender, amount);
    }

    function claimRewards() external nonReentrant {
        UserStake storage user = stakes[msg.sender];
        uint256 pending = _pendingReward(user);
        if (pending == 0) revert NoRewardsToClaim();

        // Effects
        user.lastClaimTimestamp = uint64(block.timestamp);

        // Interactions
        REWARD_TOKEN.safeTransfer(msg.sender, pending);

        emit RewardsClaimed(msg.sender, pending);
    }

    // ── Admin Functions ─────────────────────────────────────
    function setRewardRate(uint256 newRate) external onlyRole(MANAGER_ROLE) {
        uint256 oldRate = rewardRatePerSecond;
        rewardRatePerSecond = newRate;
        emit RewardRateUpdated(oldRate, newRate);
    }

    function pause() external onlyRole(MANAGER_ROLE) {
        _pause();
    }

    function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {
        _unpause();
    }

    // ── View Functions ──────────────────────────────────────
    function pendingReward(address account) external view returns (uint256) {
        return _pendingReward(stakes[account]);
    }

    // ── Internal ────────────────────────────────────────────
    function _pendingReward(
        UserStake storage user
    ) internal view returns (uint256) {
        if (user.amount == 0 || user.lastClaimTimestamp == 0) return 0;
        uint256 elapsed = block.timestamp - user.lastClaimTimestamp;
        return (uint256(user.amount) * elapsed * rewardRatePerSecond) / 1e18;
    }
}

A few things to note about this contract:

  1. Storage packingUserStake fits in two 32-byte slots by using uint128 and uint64.
  2. SafeERC20 — never call .transfer() directly on ERC-20 tokens. Some tokens (like USDT) do not return a boolean.
  3. CEI pattern — every function follows Checks-Effects-Interactions.
  4. Events on every state change — essential for frontend indexing with The Graph.

Testing Smart Contracts with Foundry

Writing the contract is half the job. Testing is the other half. Foundry lets you write tests in Solidity, which means you catch type errors at compile time instead of at runtime.

Basic Test Structure

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

import {Test, console2} from "forge-std/Test.sol";
import {SimpleStaking} from "../src/SimpleStaking.sol";
import {MockERC20} from "./mocks/MockERC20.sol";

contract SimpleStakingTest is Test {
    SimpleStaking public staking;
    MockERC20 public stakingToken;
    MockERC20 public rewardToken;

    address public admin = makeAddr("admin");
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");

    function setUp() public {
        stakingToken = new MockERC20("Stake", "STK", 18);
        rewardToken = new MockERC20("Reward", "RWD", 18);

        staking = new SimpleStaking(
            address(stakingToken),
            address(rewardToken),
            1e15, // 0.001 tokens per second per token staked
            admin
        );

        // Fund the staking contract with rewards
        rewardToken.mint(address(staking), 1_000_000e18);

        // Give Alice and Bob tokens to stake
        stakingToken.mint(alice, 10_000e18);
        stakingToken.mint(bob, 10_000e18);

        // Approve staking contract
        vm.prank(alice);
        stakingToken.approve(address(staking), type(uint256).max);
        vm.prank(bob);
        stakingToken.approve(address(staking), type(uint256).max);
    }

    function test_stake() public {
        vm.prank(alice);
        staking.stake(1_000e18);

        (uint128 amount, , , ) = staking.stakes(alice);
        assertEq(amount, 1_000e18);
        assertEq(staking.totalStaked(), 1_000e18);
    }

    function test_revert_stakeZeroAmount() public {
        vm.prank(alice);
        vm.expectRevert(SimpleStaking.ZeroAmount.selector);
        staking.stake(0);
    }

    function test_claimRewardsAfterTime() public {
        vm.prank(alice);
        staking.stake(1_000e18);

        // Fast-forward 1 hour
        vm.warp(block.timestamp + 3600);

        uint256 pending = staking.pendingReward(alice);
        assertGt(pending, 0);

        vm.prank(alice);
        staking.claimRewards();

        assertEq(rewardToken.balanceOf(alice), pending);
    }
}

Run tests with:

bash
forge test -vvv

The -vvv flag shows stack traces on failures. Use -vvvv to see every call trace, including successful ones.

Fuzz Testing

This is where Foundry shines. Fuzz testing generates random inputs to find edge cases you would never think of:

solidity
function testFuzz_stakeAndWithdraw(uint256 amount) public {
    // Bound the input to reasonable values
    amount = bound(amount, 1, 10_000e18);

    vm.startPrank(alice);
    staking.stake(amount);

    // Fast-forward some time
    vm.warp(block.timestamp + 1 days);

    staking.withdraw(amount);
    vm.stopPrank();

    (uint128 staked, , , ) = staking.stakes(alice);
    assertEq(staked, 0);
}

Foundry runs this with 256 random inputs by default. Increase it in foundry.toml:

toml
[fuzz]
runs = 1000

I run 1,000 fuzz iterations during development and 10,000 before any mainnet deployment.

Invariant Testing

Invariant tests verify properties that should always be true, regardless of what sequence of actions users take:

solidity
function invariant_totalStakedMatchesBalance() public view {
    assertEq(
        staking.totalStaked(),
        stakingToken.balanceOf(address(staking))
    );
}

This is how you catch accounting bugs that unit tests miss. If totalStaked ever diverges from the actual token balance, your contract has a critical bug.

Gas Snapshots

Before every deployment, I run gas benchmarks:

bash
forge snapshot

This generates .gas-snapshot with gas costs for every test. Commit this file to git. If a code change increases gas by more than 5%, investigate before merging.


Deploying to Production

Deployment is where theory meets reality. Here is the workflow I follow.

Deployment Script

Foundry uses Solidity scripts for deployment:

solidity
// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {Script, console2} from "forge-std/Script.sol";
import {SimpleStaking} from "../src/SimpleStaking.sol";

contract DeployScript is Script {
    function run() external {
        uint256 deployerKey = vm.envUint("PRIVATE_KEY");
        address admin = vm.envAddress("ADMIN_ADDRESS");
        address stakingToken = vm.envAddress("STAKING_TOKEN");
        address rewardToken = vm.envAddress("REWARD_TOKEN");

        vm.startBroadcast(deployerKey);

        SimpleStaking staking = new SimpleStaking(
            stakingToken,
            rewardToken,
            1e15,
            admin
        );

        console2.log("Staking deployed at:", address(staking));

        vm.stopBroadcast();
    }
}

Deploy to Testnet First

Always deploy to a testnet before mainnet. I use Arbitrum Sepolia for testing:

bash
forge script script/Deploy.s.sol \
  --rpc-url $ARBITRUM_SEPOLIA_RPC \
  --broadcast \
  --verify \
  --etherscan-api-key $ARBISCAN_API_KEY

The --verify flag automatically verifies the contract on Etherscan/Arbiscan. Verified contracts build trust. Unverified contracts look suspicious.

Mainnet Deployment Checklist

Before I deploy anything to mainnet, I go through this list:

  1. All fuzz tests pass with 10,000 runs
  2. Invariant tests pass with 1,000 call sequences
  3. Gas snapshot is within budget
  4. Internal code review completed
  5. External audit completed (for contracts holding significant value)
  6. Emergency pause mechanism tested
  7. Multi-sig set as admin (not an EOA)
  8. Deployment script tested on fork:
bash
forge script script/Deploy.s.sol \
  --rpc-url $MAINNET_RPC \
  --fork-url $MAINNET_RPC \
  --broadcast

Which Chain to Deploy On

In 2026, I deploy most contracts to L2s. Gas costs on Ethereum mainnet are still prohibitive for most applications:

  • Arbitrum — my default for DeFi protocols. Best ecosystem, lowest risk.
  • Base — best for consumer-facing dApps. Growing fast.
  • Optimism — great for public goods and governance projects.
  • Ethereum mainnet — only for high-value protocols that need maximum security and decentralization.

The deployment process is identical across all EVM chains. Change the RPC URL and you are done.


Essential Tools and Resources

Here is the toolchain I use daily, with honest opinions:

Development

  • [Foundry](https://getfoundry.sh) — the best Solidity development framework, period. Fast, native Solidity testing, great CLI.
  • [OpenZeppelin Contracts](https://docs.openzeppelin.com/contracts) — battle-tested implementations of every standard. Never write your own ERC-20.
  • [Solidity docs](https://docs.soliditylang.org) — the official documentation. Read the section on storage layout at least twice.

Security

  • [Slither](https://github.com/crytic/slither) — static analysis. Catches common vulnerabilities automatically. Run it on every PR.
  • [Mythril](https://github.com/Consensys/mythril) — symbolic execution. Finds deeper bugs but takes longer to run.
  • [Solodit](https://solodit.xyz) — database of real audit findings. I read these weekly to learn from other people's mistakes.

Monitoring and Indexing

  • [The Graph](https://thegraph.com) — subgraph indexing for your contract events. Essential for any frontend that reads blockchain data.
  • [Tenderly](https://tenderly.co) — transaction simulation and monitoring. I use it to debug failed transactions in production.
  • [OpenZeppelin Defender](https://www.openzeppelin.com/defender) — automated incident response and admin operations.

Frontend Integration

For connecting your contracts to a React frontend, the stack I use is wagmi + viem + RainbowKit. wagmi provides React hooks for every contract interaction, viem handles the low-level TypeScript Ethereum client, and RainbowKit gives you a wallet connection modal that works with every major wallet.

If you are building a Web3 project and need help with architecture, contract development, or security review, that is exactly what I do at Terra Labz.


Key Takeaways

  • Use Foundry for new Solidity projects. The speed advantage is massive, and writing tests in Solidity catches bugs earlier.
  • Pack your storage variables. Review struct layouts manually. Every wasted slot costs 20,000 gas.
  • Follow Checks-Effects-Interactions on every function. Add nonReentrant as defense-in-depth. These two patterns prevent the majority of smart contract exploits.
  • Fuzz test with at least 1,000 runs during development and 10,000 before mainnet deployment. Unit tests alone are not enough.
  • Deploy to L2s by default. Arbitrum and Base cover 90% of use cases. Mainnet is for high-value protocols only.
  • Verify your contracts on Etherscan immediately after deployment. Ship with an emergency pause mechanism and a multi-sig as admin.

*Last updated: April 14, 2026*

Written by Uvin Vindula

Uvin Vindula (IAMUVIN) is a Web3 and AI engineer based in Sri Lanka and the United Kingdom. He is the author of The Rise of Bitcoin, Director of Blockchain and Software Solutions at Terra Labz, and founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.

For development projects: hello@iamuvin.com Book a call: calendly.com/iamuvin

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.