What Is Ethereum Development?
Ethereum is a decentralized blockchain that goes beyond simple value transfer (like Bitcoin) to execute arbitrary code — "smart contracts." Smart contracts are programs stored on the blockchain that run exactly as programmed without the possibility of downtime, censorship, fraud, or third-party interference.
Full-stack Ethereum development involves:
- Smart contracts (Solidity) — the backend logic stored on-chain
- Development environment (Hardhat or Foundry) — compile, test, and deploy contracts
- Frontend (React + ethers.js or wagmi) — the user interface that interacts with contracts
- Wallets (MetaMask) — browser extension that manages user keys and signs transactions
Core Concepts
Accounts: Two types exist — Externally Owned Accounts (EOAs) controlled by private keys (your wallet) and Contract Accounts (smart contracts) controlled by their code.
Transactions: Every state change on Ethereum requires a transaction signed by an EOA. Transactions cost gas — a fee paid in ETH that compensates validators for computation.
Gas: Measured in gwei (1 ETH = 1,000,000,000 gwei). Complex operations cost more gas. Users set a gasLimit and maxFeePerGas. After EIP-1559 (London upgrade), the base fee is burned and users can add a priority tip.
ABI (Application Binary Interface): The JSON description of a contract's functions and events. Your frontend uses this to encode/decode interactions with the contract.
Setting Up Your Development Environment
mkdir eth-dapp && cd eth-dapp
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
Choose "Create a JavaScript project." This sets up Hardhat with a sample contract, test, and deployment script.
hardhat.config.js:
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: {
version: "0.8.24",
settings: {
optimizer: { enabled: true, runs: 200 },
},
},
networks: {
hardhat: {}, // Local development network
sepolia: { // Ethereum testnet
url: process.env.SEPOLIA_RPC_URL,
accounts: [process.env.PRIVATE_KEY],
},
mainnet: {
url: process.env.MAINNET_RPC_URL,
accounts: [process.env.PRIVATE_KEY],
},
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY,
},
};
Writing Smart Contracts in Solidity
Solidity is a statically typed, contract-oriented programming language compiled to EVM bytecode:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/// @title SimpleStorage — A basic key-value store
contract SimpleStorage is Ownable, ReentrancyGuard {
// State variables (stored on-chain permanently)
mapping(address => string) private userMessages;
uint256 public messageCount;
// Events (emitted for frontend listeners)
event MessageSet(address indexed user, string message, uint256 timestamp);
event MessageDeleted(address indexed user);
// Custom errors (gas-efficient)
error EmptyMessage();
error MessageTooLong(uint256 length, uint256 maxLength);
constructor() Ownable(msg.sender) {}
/// @notice Store a message for the caller
function setMessage(string calldata message) external {
if (bytes(message).length == 0) revert EmptyMessage();
if (bytes(message).length > 280) revert MessageTooLong(bytes(message).length, 280);
userMessages[msg.sender] = message;
messageCount++;
emit MessageSet(msg.sender, message, block.timestamp);
}
/// @notice Retrieve a user's stored message
function getMessage(address user) external view returns (string memory) {
return userMessages[user];
}
/// @notice Delete the caller's message
function deleteMessage() external {
delete userMessages[msg.sender];
if (messageCount > 0) messageCount--;
emit MessageDeleted(msg.sender);
}
}
Key Solidity concepts:
- msg.sender: The address that called the function
- msg.value: ETH sent with the transaction
- view/pure functions: Don't modify state, don't cost gas when called externally
- payable: Functions or addresses that can receive ETH
- memory vs storage: Where data is stored (memory = temporary, storage = permanent/expensive)
- event: Log entries stored efficiently, readable by frontends
- mapping: Hash table for key-value storage
Testing Smart Contracts
Test thoroughly — deployed contracts are immutable:
// test/SimpleStorage.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("SimpleStorage", function () {
let storage, owner, user1;
beforeEach(async function () {
[owner, user1] = await ethers.getSigners();
const SimpleStorage = await ethers.getContractFactory("SimpleStorage");
storage = await SimpleStorage.deploy();
});
describe("setMessage", function () {
it("should store a message for the caller", async function () {
await storage.connect(user1).setMessage("Hello, Ethereum!");
expect(await storage.getMessage(user1.address)).to.equal("Hello, Ethereum!");
});
it("should increment message count", async function () {
await storage.setMessage("Test");
expect(await storage.messageCount()).to.equal(1);
});
it("should emit MessageSet event", async function () {
await expect(storage.setMessage("Test"))
.to.emit(storage, "MessageSet")
.withArgs(owner.address, "Test", anyValue);
});
it("should revert on empty message", async function () {
await expect(storage.setMessage("")).to.be.revertedWithCustomError(
storage, "EmptyMessage"
);
});
});
});
Run tests:
npx hardhat test
npx hardhat coverage # Code coverage report
Deploying Contracts
// scripts/deploy.js
const { ethers } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying with account:", deployer.address);
const balance = await ethers.provider.getBalance(deployer.address);
console.log("Account balance:", ethers.formatEther(balance), "ETH");
const SimpleStorage = await ethers.getContractFactory("SimpleStorage");
const contract = await SimpleStorage.deploy();
await contract.waitForDeployment();
const address = await contract.getAddress();
console.log("SimpleStorage deployed to:", address);
// Verify on Etherscan
if (network.name !== "hardhat") {
await run("verify:verify", { address });
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
npx hardhat run scripts/deploy.js --network sepolia
Building the React Frontend
npm create vite@latest frontend -- --template react
cd frontend
npm install ethers
Connect to MetaMask and interact with the contract:
// src/App.jsx
import { useState, useEffect } from 'react';
import { ethers } from 'ethers';
import SimpleStorageABI from './abi/SimpleStorage.json';
const CONTRACT_ADDRESS = '0xYourDeployedContractAddress';
export default function App() {
const [provider, setProvider] = useState(null);
const [signer, setSigner] = useState(null);
const [contract, setContract] = useState(null);
const [account, setAccount] = useState('');
const [message, setMessage] = useState('');
const [storedMessage, setStoredMessage] = useState('');
const [loading, setLoading] = useState(false);
async function connectWallet() {
if (!window.ethereum) return alert('Please install MetaMask!');
const provider = new ethers.BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
const address = await signer.getAddress();
const contract = new ethers.Contract(CONTRACT_ADDRESS, SimpleStorageABI, signer);
setProvider(provider);
setSigner(signer);
setContract(contract);
setAccount(address);
// Read existing message
const msg = await contract.getMessage(address);
setStoredMessage(msg);
}
async function handleSetMessage(e) {
e.preventDefault();
if (!contract) return;
setLoading(true);
try {
const tx = await contract.setMessage(message);
await tx.wait(); // Wait for transaction to be mined
setStoredMessage(message);
setMessage('');
alert('Message saved on-chain!');
} catch (err) {
console.error(err);
alert('Transaction failed: ' + err.message);
} finally {
setLoading(false);
}
}
return (
<div>
<h1>Ethereum Message Board</h1>
{!account ? (
<button onClick={connectWallet}>Connect MetaMask</button>
) : (
<div>
<p>Connected: {account.slice(0,6)}...{account.slice(-4)}</p>
<p>Your message: {storedMessage || '(none stored)'}</p>
<form onSubmit={handleSetMessage}>
<input
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="Enter your message"
maxLength={280}
/>
<button type="submit" disabled={loading || !message}>
{loading ? 'Processing...' : 'Save to Blockchain'}
</button>
</form>
</div>
)}
</div>
);
}
Common Patterns and Security
Reentrancy Guard: Prevents contracts from being called recursively mid-execution (the DAO hack). Use OpenZeppelin's ReentrancyGuard modifier.
Checks-Effects-Interactions: Always check conditions, update state, then interact with external contracts — in that order.
OpenZeppelin Contracts: Battle-tested, audited implementations of common patterns (ERC20 tokens, ERC721 NFTs, access control, pausable). Always use these rather than rolling your own:
npm install @openzeppelin/contracts
ERC-20 Token (fungible):
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
constructor() ERC20("MyToken", "MTK") {
_mint(msg.sender, 1_000_000 * 10**decimals());
}
}
ERC-721 NFT (non-fungible):
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
The Ethereum Ecosystem
- Layer 2s: Polygon, Arbitrum, Optimism — faster and cheaper than mainnet, fully EVM-compatible
- IPFS: Decentralized file storage for NFT metadata and frontend hosting
- The Graph: Indexing blockchain data for efficient querying
- Chainlink: Decentralized oracles for off-chain data (prices, randomness)
- Hardhat/Foundry: Competing development frameworks (Foundry is faster, Rust-based)