The Decisions That Tutorials Flatten

Most "first contract" guides walk you through a counter or a token, gloss over compiler flags, and never explain why storage slot ordering matters or what internal vs private actually costs. The result: developers ship contracts with gas-inefficient layouts, misunderstood visibility modifiers, and no mental model for how the EVM will actually execute their code. This guide builds one contract — a minimal escrow — and justifies every design choice at the EVM level.

We're assuming you have Foundry installed and understand what the EVM is. We're not explaining what a blockchain is.

Setting Up With Foundry — and Why Not Hardhat Here

`bash

forge init first-escrow && cd first-escrow

`

Foundry compiles with solc directly, runs tests in Solidity (not JS), and gives you gas snapshots natively via forge snapshot. For a guide focused on understanding EVM-level behaviour, staying in Solidity end-to-end eliminates a translation layer.

  • forge build invokes solc with the settings in foundry.toml. Default optimiser runs: 200. This matters — we'll revisit it.
  • forge test -vvvv gives you full EVM trace output including SLOAD/SSTORE opcodes, which is how you verify storage behaviour claims yourself.

In foundry.toml, set:

`toml

solc_version = "0.8.28"

evm_version = "cancun"

optimizer = true

optimizer_runs = 200

`

evm_version controls which opcodes solc will emit. Setting cancun unlocks transient storage opcodes (TSTORE/TLOAD) — we won't use them here, but you should be explicit rather than inheriting defaults that shift between Foundry versions.

Key Points
2 insights
01

forge build invokes solc with the settings in foundry.toml. Default optimiser runs: 200. This matters — we'll revisit it.

02

forge test -vvvv gives you full EVM trace output including SLOAD/SSTORE opcodes, which is how you verify storage behaviour claims yourself.

The Contract: A Minimal Escrow

Create src/Escrow.sol:

`solidity

// SPDX-License-Identifier: MIT

pragma solidity 0.8.28;

contract Escrow {

address public immutable depositor;

address public immutable beneficiary;

address public immutable arbiter;

bool public isApproved;

error NotArbiter();

error AlreadyApproved();

error TransferFailed();

event Approved(uint256 balance);

constructor(address _beneficiary, address _arbiter) payable {

depositor = msg.sender;

beneficiary = _beneficiary;

arbiter = _arbiter;

}

function approve() external {

if (msg.sender != arbiter) revert NotArbiter();

if (isApproved) revert AlreadyApproved();

isApproved = true;

emit Approved(address(this).balance);

(bool sent,) = beneficiary.call{value: address(this).balance}("");

if (!sent) revert TransferFailed();

}

}

`

This is deliberately compact. Every line encodes a decision worth examining.

Storage Layout: What `immutable` Actually Does

The three address variables are marked immutable. This is not a gas micro-optimisation — it's a category difference:

  • immutable values are set during construction and embedded directly into the contract's runtime bytecode. They never occupy a storage slot. Reading them is a PUSH from code, not an SLOAD — 3 gas vs 2100 gas (cold) or 100 gas (warm).
  • isApproved is the only state variable that occupies storage. It sits at slot 0. Since bool uses 1 byte but storage slots are 32 bytes, the remaining 31 bytes of slot 0 are zero-padded. If you later add a uint96 variable adjacent to a bool, solc will pack them into the same slot — but only if they're declared consecutively.
  • Misordering declarations is the most common silent gas penalty in production contracts. Solidity packs storage variables sequentially into 32-byte slots. Two uint128s declared consecutively share one slot. A uint128, then an address, then another uint128 wastes an entire slot because address (20 bytes) + uint128 (16 bytes) = 36 bytes, overflowing slot boundaries.

For this contract, layout is trivial. In anything with more than three state variables, sketch your slot packing on paper before writing declarations.

By the numbers
2100
from code not gas
100
not gas gas cold
1 b
sits slot since uses
32 b
nce uses byte but

Visibility and Custom Errors: Gas and ABI Implications

  • approve() is external not public. For functions never called internally, external allows the compiler to read arguments directly from calldata (CALLDATALOAD) rather than copying to memory. With the optimiser on at 200 runs, the practical difference is negligible for small arguments — but the semantic signal matters: external communicates "this is part of the contract's interface, not its internal logic."
  • Custom errors (NotArbiter(), AlreadyApproved()) encode as 4-byte selectors, identical to function selectors. A revert NotArbiter() costs roughly 24 gas less than require(msg.sender == arbiter, "Not arbiter") because the string is never stored or ABI-encoded. More importantly, custom errors are part of the contract ABI, making them parseable by off-chain tooling without string matching.
  • public on isApproved auto-generates a getter function. The getter's selector is bytes4(keccak256("isApproved()")). If you later add a function whose selector collides (extremely unlikely, but verified by solc), the compiler will reject it.

The ETH Transfer: Why `.call` and Where It Breaks

`solidity

(bool sent,) = beneficiary.call{value: address(this).balance}("");

`

This is the current recommended pattern. But "recommended" doesn't mean "safe by default":

  • transfer() and send() forward exactly 2300 gas. Post-Istanbul (EIP-1884), this is insufficient for contracts whose receive() function triggers even a single SLOAD. They are effectively deprecated for sending ETH to arbitrary addresses.
  • .call{value: ...}("") forwards all available gas. This re-enables reentrancy: the beneficiary's receive() can call back into approve(). In our contract, the checks-effects-interactions pattern saves us — isApproved = true is set before the external call. A reentrant call hits revert AlreadyApproved(). This ordering is load-bearing; swap the isApproved write to after the call and you have a reentrancy vulnerability.
  • If beneficiary is a contract that reverts in its receive() function, approve() permanently reverts. The funds are locked. This is a known failure mode of this design. Production escrows mitigate with a withdrawal pattern (beneficiary pulls rather than arbiter pushes) or a fallback sweep to the depositor.

By the numbers
2300
efault and forward exactly
1884
exactly gas post istanbul

Failure Modes and Attack Surface

  • Locked funds if beneficiary is hostile or buggy: As above. A beneficiary contract that consumes all gas or reverts unconditionally bricks the escrow. No timeout mechanism exists in this contract.
  • Arbiter key compromise: Single arbiter, no multisig, no timelock. The arbiter can approve immediately on deployment, front-running any dispute. A production design needs either a time delay, multi-party approval, or an on-chain dispute mechanism.
  • No cancellation path: The depositor has no way to recover funds if the arbiter disappears. This is an intentional design constraint here, but shipping it as-is is negligent.
  • msg.sender vs tx.origin: We use msg.sender. If we'd used tx.origin, any contract the arbiter interacts with could hijack approval via a phishing transaction. tx.origin for auth is a known anti-pattern (documented in SWC-115), but it still appears in deployed code.
  • Zero-value deployment: The constructor is payable but nothing enforces a nonzero msg.value. Deploying with 0 ETH creates a functioning but useless escrow.

Testing: Verify the Invariants You Claimed

`solidity

// test/Escrow.t.sol

import {Test} from "forge-std/Test.sol";

import {Escrow} from "../src/Escrow.sol";

contract EscrowTest is Test {

Escrow escrow;

address beneficiary = makeAddr("beneficiary");

address arbiter = makeAddr("arbiter");

function setUp() public {

escrow = new Escrow{value: 1 ether}(beneficiary, arbiter);

}

function test_approve_sends_balance() public {

vm.prank(arbiter);

escrow.approve();

assertEq(beneficiary.balance, 1 ether);

assertTrue(escrow.isApproved());

}

function test_revert_on_non_arbiter() public {

vm.expectRevert(Escrow.NotArbiter.selector);

escrow.approve();

}

function test_revert_on_double_approve() public {

vm.startPrank(arbiter);

escrow.approve();

vm.expectRevert(Escrow.AlreadyApproved.selector);

escrow.approve();

}

}

`

Run forge test -vvvv and read the traces. You'll see the SSTORE for isApproved fire before the CALL opcode — that's your checks-effects-interactions pattern, verified at the opcode level.

forge snapshot gives you a gas report per test. Baseline these now; every future change has a measurable cost.

Verify / Go Deeper

  • [Solidity docs — Layout of State Variables in Storage](https://docs.soliditylang.org/en/v0.8.28/internals/layout_in_storage.html) — primary source for slot packing rules
  • [Solidity docs — Immutable](https://docs.soliditylang.org/en/v0.8.28/contracts.html#immutable) — confirms bytecode embedding behaviour
  • [EIP-1884](https://eips.ethereum.org/EIPS/eip-1884) — the opcode repricing that broke transfer()
  • [SWC-107 (Reentrancy)](https://swcregistry.io/docs/SWC-107) and [SWC-115 (tx.origin)](https://swcregistry.io/docs/SWC-115) — smart contract weakness classifications
  • [Foundry Book](https://book.getfoundry.sh/) — forge test, forge snapshot, and trace-level debugging
  • Inspect storage directly: cast storage 0 returns the raw value at slot 0 — verify isApproved state on any testnet deployment