Web3 Development
Solidity Smart Contract Development: The Complete 2026 Guide
Last updated: April 14, 2026
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
curl -L https://foundry.paradigm.xyz | bash
foundryupThis 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
forge init my-protocol
cd my-protocolThis scaffolds a clean project structure:
my-protocol/
├── src/ # Your contracts
├── test/ # Foundry tests (in Solidity)
├── script/ # Deployment scripts
├── lib/ # Dependencies (git submodules)
└── foundry.toml # ConfigurationInstall OpenZeppelin
forge install OpenZeppelin/openzeppelin-contractsThen configure remappings in foundry.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:
// 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:
// 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:
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:
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:
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:
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:
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:
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:
// 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:
- Storage packing —
UserStakefits in two 32-byte slots by usinguint128anduint64. - SafeERC20 — never call
.transfer()directly on ERC-20 tokens. Some tokens (like USDT) do not return a boolean. - CEI pattern — every function follows Checks-Effects-Interactions.
- 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
// 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:
forge test -vvvThe -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:
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:
[fuzz]
runs = 1000I 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:
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:
forge snapshotThis 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:
// 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:
forge script script/Deploy.s.sol \
--rpc-url $ARBITRUM_SEPOLIA_RPC \
--broadcast \
--verify \
--etherscan-api-key $ARBISCAN_API_KEYThe --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:
- All fuzz tests pass with 10,000 runs
- Invariant tests pass with 1,000 call sequences
- Gas snapshot is within budget
- Internal code review completed
- External audit completed (for contracts holding significant value)
- Emergency pause mechanism tested
- Multi-sig set as admin (not an EOA)
- Deployment script tested on fork:
forge script script/Deploy.s.sol \
--rpc-url $MAINNET_RPC \
--fork-url $MAINNET_RPC \
--broadcastWhich 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
nonReentrantas 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?

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.