Contracts
A smart contract is bytecode + storage living at an Ethereum address. Solidity is the dominant language; deployments are immutable; user-facing safety + gas efficiency are the design pressure.
Solidity contract anatomy + tooling
EXAMPLE
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
// 1) State variables (storage), events, modifiers, functions
contract Vault is Ownable, ReentrancyGuard, Pausable {
mapping(address => uint256) public balanceOf;
uint256 public totalLocked;
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
event Paused(); // (Pausable also has its own events)
error InsufficientBalance(uint256 requested, uint256 available);
error ZeroAmount();
constructor() Ownable(msg.sender) {}
// 2) Receive ETH — deposit
function deposit() external payable whenNotPaused {
if (msg.value == 0) revert ZeroAmount();
balanceOf[msg.sender] += msg.value;
totalLocked += msg.value;
emit Deposited(msg.sender, msg.value);
}
// 3) Withdraw — Checks → Effects → Interactions
function withdraw(uint256 amount) external nonReentrant whenNotPaused {
// CHECKS
uint256 bal = balanceOf[msg.sender];
if (amount > bal) revert InsufficientBalance(amount, bal);
// EFFECTS (state changes BEFORE external call)
balanceOf[msg.sender] = bal - amount;
totalLocked -= amount;
// INTERACTIONS
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
emit Withdrawn(msg.sender, amount);
}
// 4) Owner-only admin
function pause() external onlyOwner { _pause(); }
function unpause() external onlyOwner { _unpause(); }
// 5) View / pure — no gas if called externally as a constant
function isEmpty(address user) external view returns (bool) {
return balanceOf[user] == 0;
}
// 6) Receive() / fallback() — handle plain ETH transfers
receive() external payable {
deposit();
}
}
/* ============================================================
TOOLING
============================================================ */
// Foundry — the modern Solidity toolchain
// forge init my-vault # scaffold
// forge build # compile
// forge test # run tests (Solidity-native, FAST)
// forge fmt # format
// forge create --rpc-url … --private-key … src/Vault.sol:Vault
// forge verify-contract … --etherscan-api-key …
// Hardhat — established JS toolchain
// npx hardhat init
// npx hardhat compile
// npx hardhat test
// npx hardhat ignition deploy ./ignition/modules/Vault.ts --network sepolia
// npx hardhat verify --network sepolia <ADDR>
// 7) Tests in Solidity (Foundry)
// test/Vault.t.sol
import "forge-std/Test.sol";
import "../src/Vault.sol";
contract VaultTest is Test {
Vault v;
address alice = address(0xA11CE);
address bob = address(0xB0B);
function setUp() public {
v = new Vault();
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
}
function testDepositAndWithdraw() public {
vm.prank(alice);
v.deposit{value: 1 ether}();
assertEq(v.balanceOf(alice), 1 ether);
vm.prank(alice);
v.withdraw(0.5 ether);
assertEq(v.balanceOf(alice), 0.5 ether);
}
function testCannotWithdrawMoreThanDeposited() public {
vm.prank(alice);
v.deposit{value: 1 ether}();
vm.expectRevert(abi.encodeWithSelector(Vault.InsufficientBalance.selector, 2 ether, 1 ether));
vm.prank(alice);
v.withdraw(2 ether);
}
function testFuzz_DepositWithdraw(uint96 amount) public {
vm.assume(amount > 0 && amount <= 5 ether);
vm.prank(alice);
v.deposit{value: amount}();
vm.prank(alice);
v.withdraw(amount);
assertEq(v.balanceOf(alice), 0);
}
}
// 8) Interact off-chain (ethers v6)
import { ethers } from 'ethers';
import VaultABI from './out/Vault.sol/Vault.json' assert { type: 'json' };
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const vault = new ethers.Contract(VAULT_ADDR, VaultABI.abi, wallet);
await (await vault.deposit({ value: ethers.parseEther('1.0') })).wait();
const bal = await vault.balanceOf(wallet.address);
console.log(ethers.formatEther(bal));
await (await vault.withdraw(ethers.parseEther('0.5'))).wait();
// === Best practices ===
// 1) Checks-Effects-Interactions — defeat reentrancy
// 2) Pull-over-push — let users withdraw, don't push payments
// 3) Use Solidity 0.8+ — built-in overflow checks
// 4) Custom errors > require strings — cheaper, more expressive
// 5) Build on OpenZeppelin — battle-tested implementations
// 6) Pin compiler version (^0.8.26 or =0.8.26)
// 7) ReentrancyGuard on every function with an external call
// 8) Pausable + Ownable for emergency stops
// 9) Multisig (Safe) for ownership in production — never an EOA
// 10) Audit before mainnet for non-trivial contracts
// 11) Set up monitoring + alerts (Forta, OpenZeppelin Defender, Tenderly)
// 12) Verify contracts on Etherscan after deploy
// === Common bugs ===
// • Reentrancy (e.g. The DAO hack)
// • Integer overflow / underflow (pre 0.8)
// • Floating-point with / (Solidity has no floats — use fixed-point libraries)
// • Default visibility (functions default to internal in 0.5+, but be explicit)
// • Storage layout collisions in upgradeable contracts
// • Missing access control on admin functions
// • Front-running / MEV — use commit-reveal or atomic flash-loans for sensitive ops
// • Phishing via signed permits — show the user what they're signing
Why it matters
Foundry + OpenZeppelin + CEI pattern + ReentrancyGuard + multisig ownership is the modern Solidity baseline. Pin the compiler, verify on Etherscan, audit before mainnet — immutability is a feature you can’t take back.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Counter {
uint256 public n;
function bump() external { n += 1; }
}
Try it Yourself »
Exercise
Solidity keyword that starts a contract.
Counter { uint256 public n; }
Eight letters.
Discussion
Loading…