← Course home Reference · ~15 min scan Blockchain — Solidity

📖 Solidity Quick Reference

A concise lookup for the Solidity syntax and patterns you'll actually use: pragma, types, visibility, state mutability, the three ways to send ETH, events, errors, modifiers, and the common safety patterns. Bookmark this page — you'll keep coming back to it.

~15 min scan · jump to any section Cheatsheet · all levels Ξ Solidity 0.8.x
▶ Start here

Welcome & how to use this page

This isn't a tutorial — it's a cheatsheet. The goal is one thing: when you're writing Solidity and need to remember "how does visibility work again?" or "which is the modern way to send ETH?", you land here, scan the relevant section in 30 seconds, and get back to work. Everything is organised for fast lookup: small concept blocks, comparison tables, copy-pasteable code, and pointers to the deeper labs when you want to actually build something with each idea.

🎯

Who this is for

Developers who can read JavaScript/TypeScript and now have to write or audit Solidity. No prior Solidity required — every keyword is explained as it appears. If you've used another typed language (Java, C#, Go, Rust), most of this will feel familiar; what's different is what we emphasise.

🧭

How to use it

Scan it once top-to-bottom (~15 min) to see what's in here. After that, treat the sidebar as your index — click any section to jump. The page is mobile-friendly, so this works on a phone when you're stuck mid-debugging.

⚗️

Then go build

Reading reference material is necessary but not sufficient. Deploy something. Lab 1 — Donation Pool walks you from MetaMask install to a verified Sepolia contract with a working frontend. Lab 2 — Event Tickets adds a peer-to-peer marketplace on top.

💡

A note on Solidity versions

All examples target Solidity 0.8.x (currently 0.8.28 is recent and well-supported). The 0.8 line is the one to learn — it has built-in overflow checks, custom errors, immutable variables, and unchecked { ... } blocks when you need them. Older codebases on 0.6 or 0.7 exist in production, but new contracts should start on 0.8. Anything older (0.4, 0.5) is legacy.

Conventions in this reference

Code blocks
Full snippets are syntax-highlighted Solidity. Click the Copy button to paste them straight into Remix.
Inline code
Short identifiers, keywords, or tokens — written inline in the surrounding text exactly as they appear in your source.
Tables
Wherever a concept has 3+ orthogonal options (visibility levels, transfer methods, etc.) you get a comparison table. These are the parts you'll come back to most.
⚠️ & 💡 callouts
⚠️ = pitfall, common bug, or security footgun. 💡 = mental model, useful intuition. 🎯 = the rule of thumb you should remember.
▶ Anatomy

File anatomy — what every .sol file needs

Every Solidity source file has three mandatory ingredients at the top, then one or more contracts. The compiler emits two artifacts you'll use later: bytecode (what gets stored on-chain) and the ABI (what your frontend and other contracts need to call yours).

MyContract.sol Solidity
// SPDX-License-Identifier: MIT          // 1. license header — required
pragma solidity ^0.8.28;                // 2. compiler version pin — required

contract MyContract {                  // 3. one or more contracts
    // state, events, errors, functions…
}

The three mandatory parts

SPDX
A machine-readable license tag. Required since Solidity 0.6.8 — omit it and the compiler emits a warning (not an error, but every audit tool will flag it). Common values: MIT, Apache-2.0, GPL-3.0, or UNLICENSED for proprietary code. Always the first line of the file.
pragma
Tells the compiler which version range can build this file. ^0.8.28 means "0.8.28 or higher, but below 0.9.0". You can also pin exact (0.8.28) or use ranges (>=0.8.20 <0.9.0). For production, pin a specific minor (0.8.28 with no caret) so the deployed bytecode is reproducible.
contract
The container. One file can declare multiple contracts, interfaces (interface), libraries (library), or abstract contracts (abstract contract). Each becomes a separately-deployable unit (interfaces and libraries don't get their own address; they're inlined or called via delegatecall).

The compilation pipeline

1
Source (.sol) → solc compiler (also bundled inside Remix and Hardhat).
2
Bytecode — a long hex string starting with 0x60806040…. This is what lives on-chain at your contract's address. The EVM executes it directly.
3
ABI (Application Binary Interface) — a JSON description of every external function and event. Your frontend (ethers/viem/web3.js) needs this to encode calls; Etherscan needs it to build the Read/Write UI; other contracts need it to know your interface.
💡

Mental model

Think of the ABI as a TypeScript .d.ts file for your contract: it lists every callable function and its types, so anything outside the contract can talk to it safely. The bytecode is the compiled binary; the ABI is the public interface. Keep both — you'll need them for the frontend and for Etherscan verification.

▶ Anatomy

Constructor — runs once, at deploy

The constructor is a special function that runs exactly once when the contract is deployed, then is gone forever from the bytecode. Use it to set initial state and lock in immutable configuration that should never change.

Constructor example Solidity
contract MyContract {
    address public immutable owner;       // set once, never changes
    uint256 public immutable cap;
    string  public name;                    // not immutable: strings can't be
    uint256 public totalSupply;             // mutable state

    constructor(string memory _name, uint256 _cap) {
        owner = msg.sender;                 // deployer's EOA address
        name  = _name;
        cap   = _cap;
    }
}

Rules to remember

Runs once
Executed during the deploy transaction. After that, the constructor is not part of the deployed bytecode — you cannot call it again, ever.
Takes arguments
Whatever parameters you declare on the constructor are passed when you deploy: in Remix you fill them in under the Deploy button; in ethers/viem you pass them to the contract factory. These are the constructor's only input.
No return value
The constructor cannot return anything. Its implicit "return" is the deployed contract address itself.
msg.sender = deployer
Inside the constructor, msg.sender is the EOA (or contract) that submitted the deploy transaction. Standard idiom: owner = msg.sender;
Only place to set immutable
An immutable variable can be assigned exactly once, and only inside the constructor. After construction, it is baked into the bytecode — reading it costs ~3 gas instead of the 2100 gas of a storage slot.
Can be payable
If you want the contract to receive an initial ETH endowment at deploy, mark the constructor payable: constructor() payable { ... }. The deployer attaches ETH to the deploy transaction. Otherwise, sending ETH at deploy reverts.
⚠️

Common pitfalls

Three traps that catch beginners: (1) trying to make a string or bytes immutable — won't compile, only value types can be immutable. (2) reading a non-immutable storage variable in the constructor and finding it's zero — that's because state vars start at their zero value; only after your constructor assigns them do they have your intended value. (3) calling your own external functions from the constructor — the contract's runtime code isn't deployed yet, so external self-calls fail. Use internal helpers instead.

▶ Types

Value types — copied on assignment

Value types are copied when passed around (between functions, into mappings, etc.) — like JavaScript's number or boolean. The EVM operates on a 256-bit word, so the canonical size is 256 bits. Anything smaller still occupies a full slot but adds masking overhead. Use uint256 for standalone variables; reach for smaller types only when you pack multiple into one storage slot.

Integers

Type Range Notes
uint256 0 to 2²⁵⁶−1 Unsigned 256-bit integer. The default. uint is an alias for uint256.
uint8uint248 Smaller bit widths (multiples of 8) Only useful for storage packing inside a struct. As a standalone variable, costs more gas than uint256 due to mask/clean opcodes.
int256 −2²⁵⁵ to 2²⁵⁵−1 Signed 256-bit integer. int is the alias.
int8int248 Smaller signed integers Same packing rule as unsigned.
🎯

Overflow checks since 0.8.0

Solidity 0.8+ automatically reverts on arithmetic overflow / underflow. So uint8 x = 255; x + 1; reverts instead of silently wrapping to 0 (which is how it worked pre-0.8 — and how many old hacks worked). If you want wraparound (rare — e.g. counters with intentional rollover), wrap the operation in an unchecked { ... } block. The bonus: skipping the check saves ~50 gas per op.

Other value types

Type Default Notes
bool false Two values: true or false. Logical operators !, &&, || short-circuit.
address 0x000…000 20-byte Ethereum address. Has methods: .balance (uint256), .code (bytes), .codehash, .call(...), .staticcall(...), .delegatecall(...).
address payable 0x000…000 Same 20 bytes, but explicitly marked as able to receive ETH. Adds .transfer(amount) and .send(amount) (both deprecated — see §13). Cast a plain address with payable(addr).
bytes1bytes32 0x00… Fixed-size byte arrays. bytes32 commonly used for hashes (keccak256 returns one). Prefer over string when you need a fixed length.
enum first member User-defined integer-backed labels: enum Status { Pending, Active, Cancelled }. Stored as the smallest uint that fits the count. Useful for state-machine flags.
💡

Type conversions in one sentence

Solidity is strict about explicit casts when types lose information or change sign. uint8(uint256Value) is allowed (you opt in to truncation). Address ↔ uint conversion needs explicit casts: address(uint160(x)). Bool ↔ integer is not allowed — use a ternary.

▶ Types

Reference types & data locations

Reference types hold dynamic-size data (strings, arrays, structs). Unlike value types, they live in one of three data locations — and the location decides whether you're passing a copy or a pointer. Get this wrong and you'll spend an hour wondering why a function mutation didn't stick.

The reference types

Type Example Notes
string string memory name = "alice"; Dynamic UTF-8 text. No built-in concatenation operator (string.concat(a, b) works since 0.8.12). Cannot compare with == — compare hashes: keccak256(bytes(a)) == keccak256(bytes(b)).
bytes bytes memory data = hex"deadbeef"; Dynamic byte array. Prefer over string when you don't need text semantics; cheaper and supports indexed access (data[0]).
Fixed array uint256[3] memory triple; Length is part of the type and fixed at compile time. Default-initialised to zeros.
Dynamic array uint256[] storage ids; Has .length, .push(x), .pop(). Storage arrays can grow; memory arrays must be sized at creation (new uint[](n)).
struct struct User { address addr; uint256 balance; } Composite of named fields. Used heavily for grouped data (e.g. a Ticket = id + owner + listedPrice). Fields can be any type, including mappings (storage-only).

The three data locations

Location Lifetime Cost Use it for
storage Permanent (on-chain) Expensive: 20 000 gas to write a new slot, 5 000 to update State variables. Anything declared at contract level. Pointers to state variables inside functions.
memory Until function returns Cheap (linear in size, quadratic past 22 KB) Local variables holding reference types inside a function. Argument and return values for non-external function calls. Modifications don't persist.
calldata The duration of an external call Cheapest: read-only, no copy needed Arguments of external functions. Read-only. Cheapest choice for array/string args you don't need to modify.
🎯

Rule of thumb

For function arguments of reference type, default to calldata on external functions (cheapest) and memory on internal/public functions (you may need to modify them, and calldata isn't always available). For local variables, use memory unless you specifically need a pointer to a storage variable — then declare it storage and remember that changes through that pointer write to the chain.

⚠️

The reference-pointer footgun

If you write Ticket storage t = tickets[id]; t.owner = newOwner; — you just modified the on-chain ticket, even though the line looks like a local-variable assignment. The storage keyword turned t into a pointer. If you wanted a copy, you would have written Ticket memory t = tickets[id]; — but then changes to t wouldn't persist. The storage keyword is the difference between "I'm reading" and "I'm modifying".

▶ Types

Mappings — the dictionary you'll use most

A mapping is Solidity's version of a hash-map / dictionary / object: lookup a key, get a value. It's by far the most common data structure on-chain — ERC-20 balances, NFT owners, ticket holders, donation tallies, you name it. The mental model is "infinite array initialised to zero": every possible key is pre-filled with the value type's default, and writing only allocates the slots you actually touch.

Mapping syntax Solidity
// Simple mapping: address → uint256
mapping(address => uint256) public balances;

balances[msg.sender] = 100;          // write
uint256 b = balances[msg.sender];   // read (returns 0 if never written)
delete balances[msg.sender];          // reset to default; refunds some gas

// Nested mapping: address → (tokenId → bool)
mapping(address => mapping(uint256 => bool)) public approved;

approved[owner][tokenId] = true;

// Mapping to a struct
struct User { uint256 balance; bool active; }
mapping(address => User) public users;
users[msg.sender].balance = 42;       // direct field assignment works

Rules & limitations

Default values everywhere
Every key returns the value type's default until it's been written. uint defaults to 0, address to 0x0, bool to false, etc. You cannot tell "key was set to 0" apart from "key was never set" — if you need that distinction, use a separate bool flag or check != 0 conventions (e.g. ids starting at 1).
No iteration, no length, no enumeration
You cannot loop over a mapping. There's no .keys(), no .length, no way to ask "give me all donors". If you need iteration, maintain a separate address[] alongside and push to it when you add a key. (See the workaround pattern below.)
Key types: limited
Keys can be any value type (uint*, address, bool, bytes32, etc.) plus string and bytes. Cannot be a mapping, struct, or array. Values can be any type, including other mappings.
Storage-only
Mappings can only live in storage. You cannot create a mapping inside a function, in memory, in calldata, or as a function return type. They're only state variables (or fields of a struct that itself is in storage).
Public auto-getter
mapping(address => uint) public balances; generates a function balances(address) external view returns (uint) — frontends can call contract.balances(addr) directly. For nested mappings: contract.approved(owner, tokenId). For struct values, you get all fields as a tuple.
💡

Iteration workaround: keys array

When you genuinely need to iterate (e.g. "list all donors"), keep a parallel array of keys: address[] public donors;. When someone donates the first time, push them. When they donate again, skip. The mapping gives you O(1) lookup; the array gives you enumeration. Cost: a bit more storage per new donor, and you must remember to keep them in sync. For most contracts you don't need this — emit an event and let the off-chain indexer (Etherscan, The Graph) do the enumeration for free.

▶ Functions

Visibility — who can call what

Every function and state variable carries one of four visibility modifiers. They answer the question "who is allowed to reach this". Getting the level wrong is the most common access-control bug in junior contracts — either you make something public that should have been internal, or you forget the modifier entirely (Solidity 0.5+ now requires you to specify one).

Keyword Callable from Use when Gas note
external Other contracts / EOAs only Entry points that nothing inside your own contract calls. Most user-facing functions. Cheapest for array/struct args — reads them directly from calldata.
public Anyone (external + internal callers) Entry points that your own contract also needs to call. State variables you want a free getter for. When called internally, args of reference type are copied to memory — slightly more gas than external.
internal This contract + contracts that inherit from it Helpers shared with subclasses. Like protected in Java/C#. No function-selector lookup, just a jump — slightly cheaper than calling a public function from inside.
private This contract only Internal helpers you don't want subclasses to override or call. Last-line-of-defence sanity functions. Same as internal. No real gas difference.

Visibility on state variables (properties)

State variables — the "properties" of a contract — accept the same visibility keywords, but the rules differ slightly from functions. The biggest one: only public, internal, and private are allowed on state variables. external is a function-only modifier (a variable cannot be "callable from outside but not from inside" — every state variable is always readable from inside its own contract). Default if you omit the keyword: internal.

State-variable visibility — all three side by side Solidity
contract Example {
    // public  — readable everywhere; compiler generates a free getter
    uint256 public totalSupply;        // → function totalSupply() external view returns (uint256)

    // internal (default) — this contract + subclasses; NO auto-getter
    uint256 internal reserve;          // readable inside, invisible from the ABI
    address admin;                    // same as `address internal admin;` — internal is the default

    // private — this contract only; subclasses can't even read it
    bytes32 private secretHash;        // see ⚠️ — still readable on-chain by anyone

    // external — NOT ALLOWED on state variables (compile error)
    // uint256 external badIdea;
}
public state var
Auto-generates an external view getter with the same name. uint256 public count; gives you a free function count() external view returns (uint256). The variable itself is still readable from inside the contract by its bare name. For a mapping, the getter takes the key as argument: mapping(address => uint) public balances; generates function balances(address) external view returns (uint).
internal state var (default)
Readable inside the contract and its subclasses only. No auto-getter is generated, so the field doesn't appear in the ABI. If you omit the visibility keyword entirely on a state variable, this is what you get. (Note: functions have no default — since Solidity 0.5.0 you must specify their visibility explicitly.)
private state var
Readable inside the declaring contract only — not by subclasses. No auto-getter. Still publicly visible on-chain (see warning).
external state var
Not allowed — Solidity rejects this at compile time. external applies to functions only. If you want "readable from outside but no internal access", that concept doesn't exist for state variables; every state variable is always readable from inside its declaring contract.
Constant & immutable are separate
constant and immutable are not visibility keywords — they describe whether the value can change after construction. They combine freely with visibility: uint256 public constant FEE_BPS = 25; or address private immutable owner;. See §3 for the constructor rules.
⚠️

private ≠ secret

A private variable is still publicly readable by anyone running an Ethereum node — the EVM stores it in plain bytes at a deterministic slot, and tools like cast storage or eth_getStorageAt can fetch it in one call. private only restricts which contracts can read it via Solidity references; it doesn't hide the value from the world. Never put secrets, passwords, or commit-reveal values in a smart contract field — they will be visible.

🎯

Rule of thumb

Default to the most restrictive level that compiles. Start every helper as private; promote to internal only when a subclass needs it; promote to external when users need to call it; promote to public only if both external and internal callers need it. This minimises your attack surface — every external/public function is a door for an attacker to try.

▶ Functions

State mutability — what a function can do with state and money

After visibility, every function carries a second label: its state mutability. This declares what the function is allowed to do with on-chain state and with ETH. The compiler enforces it — try to write storage from a view function and you get a build error. It also drives how the function is called: pure/view calls are free if you ask from outside the chain; everything else is a paid transaction.

Keyword Reads state Writes state Receives ETH Caller pays gas?
pure Free when called off-chain. Use for stateless utilities: function add(uint a, uint b) external pure returns (uint) { return a + b; }
view Free when called off-chain. The most common modifier on getters: function balanceOf(address) external view returns (uint).
(nonpayable, default) Yes — sender pays gas. Reverts automatically if any ETH is attached. This is the default when you don't specify any mutability.
payable Yes — sender pays gas plus any ETH they choose to attach. The attached amount appears as msg.value.

Two transaction kinds you'll see in MetaMask

📤

"Transactions" — write

Calls to non-view, non-pure functions. Signed by your wallet, broadcast to the network, mined into a block. MetaMask pops up with a confirmation and gas estimate. Returns a transaction hash, not the function's return value (Solidity return values from state-changing functions are invisible to off-chain callers — use events instead).

📥

"Calls" — read

Calls to view or pure functions, executed locally by your RPC node via eth_call. No broadcast, no mining, no gas, no MetaMask popup. Returns the actual value. This is why reading a balance is instant in your frontend.

💎

payable is the keyword that unlocks money

If a function is not marked payable, any transaction that attaches ETH to it reverts automatically. This is Solidity protecting you: ETH cannot accidentally enter a function that didn't ask for it. Inside a payable function the attached amount is msg.value, in Wei. Also: a contract that wants to receive plain ETH transfers (with no function call) needs to declare either a receive() or fallback() function — see the Receive & fallback section for the full decision tree.

▶ Functions

Globals — the variables that just exist

Solidity exposes a handful of magic variables that are always in scope. You don't import them or declare them — they're filled in by the EVM at execution time. These give your contract access to the calling context (who, how much, when) and to a few helpers.

The msg.* family — who's calling, with what

msg.sender
The address that called this function in the current call frame. EOA for direct calls; a contract address when one contract is calling another. This is the standard "who is asking" reference for access control.
msg.value
Amount of ETH (in Wei) attached to the current call. Only non-zero inside payable functions — elsewhere it's always 0.
msg.data
The complete calldata — function selector (first 4 bytes) + ABI-encoded arguments. Rarely needed directly; useful in proxies and meta-tx implementations.
msg.sig
The 4-byte function selector of the call (the first 4 bytes of msg.data). Useful for generic logging or dispatcher patterns.

The block.* family — when, where, who's the miner

block.timestamp
Unix seconds at the time of the block. Accurate to ~12 seconds on Ethereum (post-Merge). Never use as randomness — validators have a small ability to influence it. OK for deadlines and time-locks where ~12 s slop is acceptable.
block.number
The current block number (1-indexed). Useful for block-based vesting and snapshot mechanics. Ethereum mainnet ~12 s per block, so number jumps ~7200/day.
block.chainid
The chain's identifier — 1 for Ethereum mainnet, 11155111 for Sepolia, 8453 for Base, etc. Used inside signed-message domain separators (EIP-712) to prevent cross-chain replay.
block.coinbase
The block proposer's (validator's) address. Rarely useful; sometimes used in MEV-related logic. Post-Merge it's the proposer, not a "miner".
block.basefee
The current block's EIP-1559 base fee, in Wei. Useful for gas-aware dynamic pricing.

Other useful globals

tx.origin
The original EOA that started the entire call chain. Almost always the wrong choice for access control — if a user is tricked into calling a malicious contract that calls yours, tx.origin is still the user; msg.sender is the attacker. Use msg.sender.
address(this).balance
The ETH balance of the current contract, in Wei. Use to know how much you can send out; useful in withdraw() patterns: send address(this).balance all at once.
gasleft()
Remaining gas at the point of the call, as a uint256. Useful for "stop if we're running low" guards or for measuring how much gas a sub-call consumed.
keccak256(bytes)
The hashing function Ethereum uses internally (SHA-3 variant). Pass any bytes, get a bytes32. Used for: ABI selectors, storage slot calculation, commit-reveal, event-topic signatures. abi.encodePacked(...) is a common pairing.
abi.encode / decode
Serialise / deserialise arbitrary typed values to/from bytes. abi.encode(...) pads each value to 32 bytes (safer for hashing); abi.encodePacked(...) concatenates tightly (smaller, but ambiguous for hashing variable-length args — use carefully).
▶ Events & errors

Events & emit — talking to off-chain

Events are append-only logs that smart contracts write during execution. They cost a tiny fraction of storage gas, are immutable once mined, and — crucially — are indexed by every Ethereum node, so frontends, indexers (The Graph), and explorers (Etherscan) can subscribe to them in real time. This is how on-chain state communicates with off-chain UIs.

Event declaration & emit Solidity
// Declaration: up to 3 indexed params, plus any number of non-indexed
event Transfer(
    address indexed from,
    address indexed to,
    uint256         amount      // not indexed
);

// Emit inside a function — exactly when state changes
function transfer(address to, uint256 amount) external {
    balances[msg.sender] -= amount;
    balances[to]          += amount;
    emit Transfer(msg.sender, to, amount);
}

The log shape — topics + data

Each emit produces a log record with up to four topics and a data blob:

topic[0]
Always keccak256("EventName(arg1Type,arg2Type,…)") — the event signature hash, auto-generated. This is how Etherscan and indexers know which event was emitted.
topic[1..3]
The first three parameters marked indexed, each padded to 32 bytes. (Reference-type indexed params are stored as their keccak256 hash, not the value.)
data
All non-indexed parameters, ABI-encoded and concatenated. Cheap to write but slow to filter on.
🎯

When to indexed

Index the fields users will want to filter on — sender, recipient, token id. Indexed fields can be queried by nodes in O(1) using eth_getLogs with a topic filter; non-indexed fields require scanning + decoding every event. Cost: each indexed param adds a small amount of gas at emit time (~375 gas per topic). Max 3 indexed per event.

⚠️

Contracts cannot read their own events

Once emitted, events are written to the transaction receipt — outside the EVM's state tree. No Solidity function can read past events; only off-chain consumers can. Don't try to use events as cheap storage; they're write-only from the contract's perspective. Conversely: don't store data on-chain just to read it back when you could emit it and let the UI subscribe.

▶ Events & errors

Errors — three ways to revert

When something goes wrong, a function must revert — undoing all state changes from the current transaction and refunding the unused gas. Solidity gives you three ways to express this, but in modern code one of them dominates: custom errors. They're cheaper, more expressive, and Etherscan decodes them beautifully.

Three ways to fail Solidity
// 1. require — terse, with a string message
require(msg.value >= price, "insufficient payment");

// 2. revert with a string — equivalent, more flexible (can be inside if/else)
if (msg.value < price) revert("insufficient payment");

// 3. Custom error — declared once, revert with arguments (the modern way)
error InsufficientPayment(uint256 sent, uint256 required);

if (msg.value < price)
    revert InsufficientPayment(msg.value, price);

// 4. assert — for "should never happen" invariants only
assert(totalSupply == sum);   // since 0.8.0: reverts with Panic(uint256), gas refunded normally

Comparison

Form Gas at revert Can carry data? Use when
require(cond, "msg") String stored in bytecode; ~50 bytes overhead per call site String only Quick checks where the cost of a custom error declaration isn't worth it. Legacy code.
revert("msg") Same as require with a string String only Inside if/else branches, or when the condition is complex enough that a positive require would be unclear.
revert MyError(args) ~50 bytes total: 4-byte selector + ABI-encoded args Typed values The modern default. Cheaper at every revert (no string in bytecode), carries useful debugging data, and Etherscan decodes the args by name.
assert(cond) Same as a custom error — reverts with Panic(uint256), gas refunded (since 0.8.0; pre-0.8 it used INVALID and burned all gas) None — emits Panic(uint256) Internal invariants that should be mathematically impossible to violate. Not for user input validation.
🎯

Rule of thumb

Default to custom errors. Declare them at the top of your contract, give them descriptive names (NotOwner, SoldOut, TargetNotReached), include relevant arguments (the value that failed the check + the expected one). Save ~50 gas per revert path vs string messages, and turn every Etherscan failed-transaction page into a self-documenting error report. Reserve assert for "this can't possibly happen" sanity checks deep inside complex math.

▶ Events & errors

Modifiers — reusable function preconditions

A modifier is a wrapper around a function body. It runs code before (and optionally after) the function executes. The most common use is access control — define onlyOwner once, apply it to any function that should only run for the owner. Modifiers compile inline; there's no real runtime cost compared to writing the same checks directly.

Modifier pattern Solidity
contract Vault {
    address public immutable owner;
    error NotOwner();

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;                              // run the wrapped function body here
    }

    constructor() { owner = msg.sender; }

    function withdraw(uint256 amount) external onlyOwner {
        payable(owner).transfer(amount);
    }

    function pause() external onlyOwner {  // reused — that's the point
        // …
    }
}

The _; placeholder

Inside a modifier, the underscore-semicolon _; marks "insert the function body here". Code before _; runs as a pre-check; code after runs as post-processing. Most modifiers only have pre-checks. If you forget the _;, the wrapped function never runs — the compiler doesn't warn you, but every call silently no-ops.

Common modifiers you'll see

onlyOwner
Only the deployer (or designated admin) may call. The OpenZeppelin Ownable contract provides this out of the box.
nonReentrant
Toggles a storage flag at entry and exit, reverting if re-entered during execution. OpenZeppelin's ReentrancyGuard is the canonical implementation. Use when you must make external calls before finalising state.
whenNotPaused
Reverts if a global paused flag is on. Used in emergency stop patterns. OpenZeppelin's Pausable ships with this.
onlyRole(ROLE)
More flexible access control: multiple named roles (MINTER, BURNER, ADMIN, …) each with their own member set. From OpenZeppelin's AccessControl.

Modifiers with arguments

Modifiers can take parameters, just like functions: modifier costs(uint256 price) { require(msg.value >= price); _; }, then apply function buy() external payable costs(0.1 ether) { … }. The arguments are evaluated at the call site of the wrapped function, so you can use other function arguments inside the modifier.

⚠️

Two pitfalls

(1) Forgetting _;: the body silently doesn't execute, but the modifier check still passes. Always put _; on its own line at the end of the modifier. (2) Modifiers that themselves call external code: a modifier is just inlined into the function, so re-entrancy concerns apply equally. Don't put a .call inside a modifier unless you've thought hard about reentrancy.

▶ Sending ETH

Receive & fallback — how a contract accepts plain ETH

Most ETH that enters a contract arrives through a normal payable function call (someone called buyTicket() and attached value). But what happens if someone sends ETH to the contract address with no function call at all — a bare transfer from MetaMask, a selfdestruct from another contract, or a call with empty data? Solidity provides two special functions for exactly this: receive() and fallback(). Knowing which one fires when is one of the most-asked Solidity interview questions.

Both special functions, side by side Solidity
contract Receiver {
    event EthReceived(address indexed from, uint256 amount);
    event FallbackCalled(address indexed from, uint256 amount, bytes data);

    // Plain ETH transfer, NO calldata. Must be external + payable. No name, no args.
    receive() external payable {
        emit EthReceived(msg.sender, msg.value);
    }

    // Calldata that doesn't match any function selector — or no receive() defined.
    // payable is OPTIONAL: omit it if you don't want fallback to accept ETH.
    fallback() external payable {
        emit FallbackCalled(msg.sender, msg.value, msg.data);
    }
}

The decision tree — which one fires?

When a transaction arrives at a contract, the EVM looks at two things: is msg.data empty, and is there a matching function selector? The flow:

Calldata matches a function selector
That function runs (whether or not ETH is attached, subject to its payable marker). Neither receive nor fallback is consulted. This is the normal case.
msg.data is empty (bare transfer)
(a) If receive() is defined → it runs. (b) Otherwise, if fallback() is defined and payable → it runs. (c) Otherwise, the transaction reverts and the ETH stays with the sender.
Calldata is non-empty but matches nothing
If fallback() is defined → it runs (with the unknown calldata in msg.data). If ETH was attached, fallback must also be payable or the call reverts. If no fallback is defined → revert.

receive() vs fallback() — quick comparison

Feature receive() fallback()
Triggers when msg.data is empty (plain transfer) Calldata doesn't match any function or no receive exists for an empty-data call
Must be payable? Yes — always Optional. Non-payable fallback reverts ETH but still accepts unknown calldata.
Has a name? Args? Return? No name, no args, no return No name, no args, no return — but msg.data contains the raw bytes
Visibility Must be external Must be external
How many per contract? At most one At most one
⚠️

The 2300-gas trap

When another contract sends you ETH via the legacy .transfer or .send primitives (see §13), it only forwards 2300 gas into your receive / fallback. That's enough for an emit on a small event but not much else — any SSTORE or non-trivial logic will run out of gas and revert. This is one of the reasons the community moved away from .transfer/.send. Modern .call{value:}("") forwards all remaining gas, so receive/fallback can do whatever they need.

💡

Reentrancy applies here too

Anything you write inside receive or fallback is reachable from any contract that sends ETH to yours — including attackers. If you call back into your own contract (address(this).withdraw()) from receive, you've built a reentrancy hole. Keep these functions tiny: ideally just an emit, or empty (the default behaviour is just to accept the ETH and update address(this).balance).

🎯

Rule of thumb

If your contract is supposed to receive ETH: add an explicit receive() external payable {} (empty body is fine) so bare transfers don't revert. If your contract should refuse stray ETH: omit both receive and the payable on fallback — Solidity will revert any unsolicited ETH for you. Don't put business logic inside fallback — anyone who guesses a typo of your function name will trigger it. Proxy contracts (EIP-1967, transparent proxies, UUPS) are the one major exception where fallback does carry real logic — they use it to forward all unknown calls to an implementation contract via delegatecall.

▶ Sending ETH

The three ways to send ETH — and why you only need one

Solidity gives you three primitives for moving ETH from your contract to another address. Two are legacy and discouraged; one is the modern default. This is one of the most common interview questions for a Solidity dev — and one of the most common sources of "why did my contract break after the chain upgraded?" bugs. Skim the table, then read the rule.

All three forms Solidity
address payable recipient = payable(someAddress);
uint256 amount = 1 ether;

// 1. .transfer — DEPRECATED. Reverts on failure. 2300-gas budget.
recipient.transfer(amount);

// 2. .send — DEPRECATED. Returns bool. 2300-gas budget. You must check the bool.
bool ok = recipient.send(amount);
if (!ok) revert TransferFailed();

// 3. .call{value:}("") — RECOMMENDED. Forwards all gas. Returns (bool, bytes).
(bool success, ) = recipient.call{value: amount}("");
if (!success) revert TransferFailed();

Side-by-side comparison

Method Forwards how much gas? Returns / failure? Current status
.transfer(amount) 2300 gas (hard-coded) Reverts automatically on failure ⚠️ Deprecated. The 2300-gas stipend was sized in 2016 and made unreliable by 2019's Istanbul fork (EIP-1884), which raised the cost of common opcodes. Recipient contracts with non-trivial fallbacks silently fail.
.send(amount) 2300 gas (hard-coded) Returns bool. Silent failure if you forget to check it ⚠️ Deprecated. Same gas problem as .transfer, plus you have to remember to check the return value (every linter will yell at you if you don't).
.call{value: x}("") All remaining gas (can cap with {gas: n}) Returns (bool success, bytes data). Must explicitly check and handle ✅ Modern default. Works regardless of the recipient's gas needs; future-proof against chain upgrades. Pair with CEI to neutralise reentrancy.

Why .call won — the short version

2016 — Solidity 0.4
.transfer and .send ship with a 2300-gas budget — picked because it's enough to emit an event in the recipient's fallback but not much else. The thinking: limited gas = limited reentrancy exposure.
Dec 2019 — Istanbul fork
EIP-1884 raises the gas cost of SLOAD from 200 to 800, plus other opcode bumps. A recipient that does any real work in its fallback now exceeds 2300 gas — and .transfer/.send silently fail. Lots of production contracts break overnight.
2020 — community consensus
The Solidity team and major auditors (Consensys, OpenZeppelin, Trail of Bits) coordinate on: stop using .transfer and .send; use .call + CEI instead. OpenZeppelin's Address.sendValue wrapper appears, codifying the pattern.
Today
Any audit will flag .transfer/.send. The official Solidity docs label them "deprecated against new code". You'll still see them in legacy contracts — but new code should always use .call.
🎯

The rule, in one sentence

Always send ETH with (bool ok, ) = recipient.call{value: amount}(""); if (!ok) revert TransferFailed();, and always do it after updating your state (CEI — see §14). That's it. Don't reach for .transfer or .send — they look simpler but are landmines waiting for the next chain upgrade.

⚠️

The flip side: .call forwards all gas

Since .call forwards all remaining gas, a malicious recipient can do anything in their fallback — including calling back into your contract to drain it (reentrancy). The defence is the CEI pattern: update all your state before the external .call. By the time control could re-enter, the post-update invariants already make the second call revert (e.g. "already withdrawn" flag is set). For complex flows where CEI is hard, use OpenZeppelin's ReentrancyGuard modifier.