Section B · Technical Core

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:

LocationLifetimeMutabilityCost
calldataFunction callRead-only~3 gas per word read
memoryFunction callRead/write~3 gas + quadratic expansion
storagePersistentRead/writeSLOAD 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
    }
}
Heuristic

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.

Watch out

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

KeywordSet whenStored whereRead cost
constantCompile timeInlined into bytecode3 gas (PUSH)
immutableConstructorInlined post-deploy at fixed offsets~3 gas (PUSH)
regular stateAnytimeStorage slot2100 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:

CategoryExamplesCost class
ArithmeticADD, MUL, DIV, MOD, EXP3-10 gas
Comparison/bitwiseLT, GT, EQ, AND, OR3 gas
Stack/memoryPUSH, POP, MLOAD, MSTORE3 gas + memory expansion
StorageSLOAD, SSTORE, TLOAD, TSTORE2100/20k cold; 100 warm; TSTORE 100
Control flowJUMP, JUMPI, JUMPDEST, RETURN, REVERT8-10 gas
ExternalCALL, STATICCALL, DELEGATECALL, CREATE, CREATE22600 cold; 100 warm + gas forwarded
CryptoKECCAK256, ECRECOVER (precompile)30 + 6/word; precompile fixed
ContextCALLER, ORIGIN, TIMESTAMP, NUMBER, CHAINID2 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:

ConventionScaleUsed by
WAD1e18 = 1.0MakerDAO, Aave, most lending
RAY1e27 = 1.0Aave (for rate accumulators)
RAD1e45 = 1.0Maker (for currency × rate)
Q64.962^96 = 1.0Uniswap 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.
Round in protocol favor

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:

  1. Checks — validate inputs, permissions, invariants.
  2. Effects — update local storage.
  3. 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.

The senior reflex

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.