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 buildinvokessolcwith the settings infoundry.toml. Default optimiser runs: 200. This matters — we'll revisit it.forge test -vvvvgives 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.
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:
immutablevalues are set during construction and embedded directly into the contract's runtime bytecode. They never occupy a storage slot. Reading them is aPUSHfrom code, not anSLOAD— 3 gas vs 2100 gas (cold) or 100 gas (warm).isApprovedis the only state variable that occupies storage. It sits at slot 0. Sincebooluses 1 byte but storage slots are 32 bytes, the remaining 31 bytes of slot 0 are zero-padded. If you later add auint96variable adjacent to abool,solcwill 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. Auint128, then anaddress, then anotheruint128wastes an entire slot becauseaddress(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.
Visibility and Custom Errors: Gas and ABI Implications
approve()isexternalnotpublic. For functions never called internally,externalallows 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:externalcommunicates "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. Arevert NotArbiter()costs roughly 24 gas less thanrequire(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. publiconisApprovedauto-generates a getter function. The getter's selector isbytes4(keccak256("isApproved()")). If you later add a function whose selector collides (extremely unlikely, but verified bysolc), 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()andsend()forward exactly 2300 gas. Post-Istanbul (EIP-1884), this is insufficient for contracts whosereceive()function triggers even a singleSLOAD. They are effectively deprecated for sending ETH to arbitrary addresses..call{value: ...}("")forwards all available gas. This re-enables reentrancy: the beneficiary'sreceive()can call back intoapprove(). In our contract, the checks-effects-interactions pattern saves us —isApproved = trueis set before the external call. A reentrant call hitsrevert AlreadyApproved(). This ordering is load-bearing; swap theisApprovedwrite to after the call and you have a reentrancy vulnerability.- If
beneficiaryis a contract that reverts in itsreceive()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.
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.sendervstx.origin: We usemsg.sender. If we'd usedtx.origin, any contract the arbiter interacts with could hijack approval via a phishing transaction.tx.originfor auth is a known anti-pattern (documented in SWC-115), but it still appears in deployed code.- Zero-value deployment: The
constructorispayablebut nothing enforces a nonzeromsg.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 storagereturns the raw value at slot 0 — verify0 isApprovedstate on any testnet deployment