Core Fundamentals
The Solidity, EVM, and arithmetic primitives every senior protocol engineer should be able to discuss without notes. The stuff every later chapter assumes you breathe.
Storage layout
The EVM gives each contract 2^256 storage slots, each 32 bytes. Every state variable maps to one or more slots; the compiler decides where, deterministically, in declaration order. A senior engineer should be able to predict the storage layout of a contract just by reading the source.
contract Market {
address public loanToken; // slot 0, lower 20 bytes
address public collateral; // slot 1, lower 20 bytes
uint128 public totalBorrow; // slot 2, lower 16 bytes ┐ packed
uint128 public totalSupply; // slot 2, upper 16 bytes ┘
uint256 public lastAccrual; // slot 3
mapping(address => uint256) public balance; // keccak256(addr . 4) => slot
}
Three things to know cold:
- Mappings live at
keccak256(abi.encode(key, slotIndex)). There is no enumeration; you cannot list keys without indexing events. - Dynamic arrays store length at the slot, and elements at
keccak256(slotIndex) + i. - Structs are laid out inline, slot-by-slot in declaration order, packing within each slot when fields permit.
Inspect any contract with forge inspect <Name> storage — the output is the source of truth.
Calldata vs memory vs storage
Three locations, three gas profiles, three reasons to choose each:
| Location | Lifetime | Mutability | Cost |
|---|---|---|---|
calldata | Function call | Read-only | ~3 gas per word read |
memory | Function call | Read/write | ~3 gas + quadratic expansion |
storage | Persistent | Read/write | SLOAD 2100 cold / 100 warm; SSTORE 20k zero→non-zero, 5k change, 0 same |
// Pass arrays as calldata when you only read them.
function sumAll(uint256[] calldata xs) external pure returns (uint256 s) {
for (uint256 i; i < xs.length; ++i) s += xs[i];
}
// Cache storage to a local stack variable inside loops.
function accrueAll(uint256 marketCount) external {
for (uint256 i; i < marketCount; ++i) {
Market storage m = markets[i]; // pointer, free
uint256 last = m.lastAccrual; // SLOAD once
if (last == block.timestamp) continue;
// ... compute ...
m.lastAccrual = uint128(block.timestamp); // SSTORE once
}
}
If you find yourself reading the same storage slot twice in a function, cache it to a local. If you mutate a struct field three times, mutate a memory copy and SSTORE the whole struct once at the end (only when storage layout permits — careful with reentrancy).
Transient storage (EIP-1153)
Introduced in Cancun (Ethereum, March 2024). Two opcodes: TSTORE and TLOAD. Same key-value semantics as storage, but the values reset at the end of the transaction. Cheap (100 gas). Designed for cross-call state that does not need to persist.
Canonical use case: a per-transaction reentrancy lock, or a per-transaction price cache.
contract TransientLock {
bytes32 private constant LOCK_SLOT = keccak256("lock");
modifier nonReentrant() {
bytes32 slot = LOCK_SLOT;
assembly { if tload(slot) { revert(0, 0) } tstore(slot, 1) }
_;
assembly { tstore(slot, 0) }
}
}
In Solidity 0.8.24+, you can use the transient data location: uint256 transient locked;. This compiles down to TLOAD/TSTORE.
Transient storage is per-transaction, not per-call. If you set a transient flag in call A and forget to clear it before call B in the same transaction, B sees the stale value. Always clear in a finally-equivalent path (the postfix of your modifier).
Custom errors, require, revert
Custom errors (introduced in 0.8.4) are dramatically cheaper than revert strings and self-document the error in the ABI.
// Bad — ~50 gas/character to deploy, expensive to revert.
require(amount <= balance, "Market: insufficient balance");
// Better.
error InsufficientBalance(uint256 requested, uint256 available);
if (amount > balance) revert InsufficientBalance(amount, balance);
The 4-byte selector of a custom error appears in the revert data, followed by ABI-encoded arguments. Off-chain consumers can decode it; tools like cast 4byte resolve selectors.
Senior-protocol idiom: define all errors at the top of the contract or in a separate library. Group them by domain.
Immutable vs constant
| Keyword | Set when | Stored where | Read cost |
|---|---|---|---|
constant | Compile time | Inlined into bytecode | 3 gas (PUSH) |
immutable | Constructor | Inlined post-deploy at fixed offsets | ~3 gas (PUSH) |
| regular state | Anytime | Storage slot | 2100 cold / 100 warm |
Use immutable for any value known at deploy time but not at compile time — token addresses, chain ID at construction, deployer-set parameters. Use constant for literals that never change.
contract Pool {
IERC20 public immutable asset; // set in constructor
uint256 public constant FEE_BPS = 30; // hard-coded
constructor(IERC20 _asset) { asset = _asset; }
}
Packing and slot economics
The compiler packs sequential state variables into the same 32-byte slot when they fit. Order matters.
// Unpacked: 3 slots
contract Bad {
uint128 a;
uint256 b; // forces b into its own slot, a leaves slot 0 half-used
uint128 c; // its own slot
}
// Packed: 2 slots
contract Good {
uint128 a; // slot 0, lower 16 bytes
uint128 c; // slot 0, upper 16 bytes
uint256 b; // slot 1
}
Three subtleties:
- Packing helps reads only when both fields are touched in the same call. SSTORE on a packed slot still costs a full SSTORE.
- Reading one field of a packed slot still loads the full word and masks; cost is identical to reading an unpacked uint256.
- Beware of "false packing" — three uint96s pack into one slot, but if you only ever touch one at a time, you have not saved gas, only complicated the layout.
EVM opcodes — the mental model
You do not need to memorize all 140 opcodes, but you should hold a model of the categories:
| Category | Examples | Cost class |
|---|---|---|
| Arithmetic | ADD, MUL, DIV, MOD, EXP | 3-10 gas |
| Comparison/bitwise | LT, GT, EQ, AND, OR | 3 gas |
| Stack/memory | PUSH, POP, MLOAD, MSTORE | 3 gas + memory expansion |
| Storage | SLOAD, SSTORE, TLOAD, TSTORE | 2100/20k cold; 100 warm; TSTORE 100 |
| Control flow | JUMP, JUMPI, JUMPDEST, RETURN, REVERT | 8-10 gas |
| External | CALL, STATICCALL, DELEGATECALL, CREATE, CREATE2 | 2600 cold; 100 warm + gas forwarded |
| Crypto | KECCAK256, ECRECOVER (precompile) | 30 + 6/word; precompile fixed |
| Context | CALLER, ORIGIN, TIMESTAMP, NUMBER, CHAINID | 2 gas |
Rules of thumb that survive most interviews:
- SSTORE dominates almost any function's gas cost.
- External calls are not free even cold; warm-access discount applies after first touch.
- Loops over storage are death; cache or denormalize.
- Memory expansion is quadratic past 724 bytes — building large arrays in memory is not free.
Fixed-point math
The EVM has no native floating-point. DeFi math is integer arithmetic with scaling factors:
| Convention | Scale | Used by |
|---|---|---|
| WAD | 1e18 = 1.0 | MakerDAO, Aave, most lending |
| RAY | 1e27 = 1.0 | Aave (for rate accumulators) |
| RAD | 1e45 = 1.0 | Maker (for currency × rate) |
| Q64.96 | 2^96 = 1.0 | Uniswap v3 sqrt price |
// WadRayMath-style
uint256 constant WAD = 1e18;
function wadMul(uint256 a, uint256 b) pure returns (uint256) {
return (a * b + WAD / 2) / WAD; // round-half-up
}
function wadDiv(uint256 a, uint256 b) pure returns (uint256) {
return (a * WAD + b / 2) / b;
}
// Rounding-down variants for protocol-favor:
function wadMulDown(uint256 a, uint256 b) pure returns (uint256) {
return (a * b) / WAD;
}
Production libraries worth knowing by name:
- WadRayMath / PercentageMath — Aave's canonical math lib.
- PRBMath — Paul Berg's fixed-point lib; SD59x18 (signed 18-decimal) and UD60x18 (unsigned).
- FixedPointMathLib — Solmate/Solady; tight, fast.
- SignedMath / Math — OpenZeppelin's abs, max, min, ceilDiv.
When computing shares from assets on deposit, round down (user gets fewer shares). When computing assets to repay from debt shares, round up (user pays more). Get this wrong and you donate value to the first attacker who finds the asymmetry.
Reentrancy primitives
Reentrancy is when an external call hands control to attacker-controlled code, which calls back into your contract before your state has been finalized. The canonical fix is to update state before the external call (CEI), or to lock against re-entry.
// OZ ReentrancyGuard — pre-Cancun
abstract contract ReentrancyGuard {
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status = _NOT_ENTERED;
modifier nonReentrant() {
require(_status != _ENTERED, "REENTRANT");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
}
// Cancun+ — transient storage lock, much cheaper
abstract contract TransientReentrancyGuard {
bool transient _entered;
modifier nonReentrant() {
require(!_entered, "REENTRANT");
_entered = true;
_;
_entered = false;
}
}
Three reentrancy flavors to recognize:
- Classic — DAO-era. State updated after external call.
- Cross-function — function A makes a call; attacker calls function B which observes stale state.
- Read-only — view function returns stale state mid-update; another protocol reads it as oracle and gets fooled.
Checks-Effects-Interactions
The order:
- Checks — validate inputs, permissions, invariants.
- Effects — update local storage.
- Interactions — make external calls (transfers, callbacks).
function withdraw(uint256 amount) external nonReentrant {
// Checks
if (balance[msg.sender] < amount) revert Insufficient();
// Effects
balance[msg.sender] -= amount;
totalSupply -= amount;
// Interactions
asset.safeTransfer(msg.sender, amount);
}
If a callback is essential (flash loans, liquidations with hook), CEI is not enough on its own — you need a transient lock and careful invariant-after-callback checks. See chapter 06.
When you read someone else's contract, your eye should naturally check: is the external call the last thing? Is there a nonReentrant? Is there any state mutation after the call? If yes, your alarm fires automatically. Build this reflex.