📖 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.
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
codeFile 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).
// 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
SPDXMIT, Apache-2.0, GPL-3.0, or UNLICENSED for proprietary code. Always the first line of the file.pragma^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.contractinterface), 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
.sol) → solc compiler (also bundled inside Remix and Hardhat).0x60806040…. This is what lives on-chain at your contract's address. The EVM executes it directly.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.
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.
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
return anything. Its implicit "return" is the deployed contract address itself.msg.sender is the EOA (or contract) that submitted the deploy transaction. Standard idiom: owner = msg.sender;immutableimmutable 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.payablepayable: 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.
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. |
uint8 … uint248 |
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. |
int8 … int248 |
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). |
bytes1 … bytes32 |
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.
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".
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.
// 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
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)..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.)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. 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).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.
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.
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 varexternal 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)private state varexternal state varexternal 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 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.
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.
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.sendermsg.valuepayable functions — elsewhere it's always 0.msg.datamsg.sigmsg.data). Useful for generic logging or dispatcher patterns.The block.* family — when, where, who's the miner
block.timestampblock.numberblock.chainidblock.coinbaseblock.basefeeOther useful globals
tx.origintx.origin is still the user; msg.sender is the attacker. Use msg.sender.address(this).balancewithdraw() patterns: send address(this).balance all at once.gasleft()keccak256(bytes)bytes32. Used for: ABI selectors, storage slot calculation, commit-reveal, event-topic signatures. abi.encodePacked(...) is a common pairing.abi.encode / decodebytes. 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 & 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.
// 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]keccak256("EventName(arg1Type,arg2Type,…)") — the event signature hash, auto-generated. This is how Etherscan and indexers know which event was emitted.topic[1..3]indexed, each padded to 32 bytes. (Reference-type indexed params are stored as their keccak256 hash, not the value.)dataWhen 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.
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.
// 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.
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.
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
onlyOwnerOwnable contract provides this out of the box.nonReentrantReentrancyGuard is the canonical implementation. Use when you must make external calls before finalising state.whenNotPausedpaused flag is on. Used in emergency stop patterns. OpenZeppelin's Pausable ships with this.onlyRole(ROLE)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.
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.
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:
payable marker). Neither receive nor fallback is consulted. This is the normal case.msg.data is empty (bare transfer)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.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.
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.
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
.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.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..transfer and .send; use .call + CEI instead. OpenZeppelin's Address.sendValue wrapper appears, codifying the pattern..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.