Introduction

If you already understand EVM fundamentals, ABI encoding, and the Ethereum transaction lifecycle, this guide skips the basics and takes you straight into writing a production-quality Solidity smart contract. We will build a minimal escrow contract, dissecting every design decision, security edge case, and gas optimization along the way.

Environment Setup: Foundry-First in 2025

While Hardhat remains popular, Foundry has become the de facto standard for advanced Solidity development in 2025. Initialize your project:

`bash

forge init first-escrow && cd first-escrow

`

Your foundry.toml should target the latest stable EVM version:

`toml

[profile.default]

solc_version = "0.8.28"

evm_version = "cancun"

optimizer = true

optimizer_runs = 10000

via_ir = true

`

Using via_ir = true enables the Yul intermediate representation pipeline, which unlocks deeper stack optimizations โ€” critical when you have functions with many local variables.

The Contract: A Minimal Escrow

`solidity

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.28;

contract Escrow {

enum State { Created, Funded, Released, Refunded }

struct Deal {

address buyer;

address payable seller;

uint96 amount; // packed with seller in same slot

State state;

uint40 deadline; // unix timestamp, sufficient until year 36812

}

mapping(uint256 => Deal) public deals;

uint256 public nextDealId;

error InvalidState(State expected, State actual);

error Unauthorized();

error DeadlinePassed();

error DeadlineNotPassed();

error TransferFailed();

event DealCreated(uint256 indexed dealId, address buyer, address seller, uint96 amount, uint40 deadline);

event DealFunded(uint256 indexed dealId);

event DealReleased(uint256 indexed dealId);

event DealRefunded(uint256 indexed dealId);

modifier onlyInState(uint256 dealId, State expected) {

State actual = deals[dealId].state;

if (actual != expected) revert InvalidState(expected, actual);

_;

}

function createDeal(

address payable _seller,

uint96 _amount,

uint40 _deadline

) external returns (uint256 dealId) {

if (_deadline <= uint40(block.timestamp)) revert DeadlinePassed();

dealId = nextDealId++;

deals[dealId] = Deal({

buyer: msg.sender,

seller: _seller,

amount: _amount,

state: State.Created,

deadline: _deadline

});

emit DealCreated(dealId, msg.sender, _seller, _amount, _deadline);

}

function fund(uint256 dealId) external payable onlyInState(dealId, State.Created) {

Deal storage d = deals[dealId];

if (msg.sender != d.buyer) revert Unauthorized();

if (msg.value != d.amount) revert TransferFailed(); // exact amount required

d.state = State.Funded;

emit DealFunded(dealId);

}

function release(uint256 dealId) external onlyInState(dealId, State.Funded) {

Deal storage d = deals[dealId];

if (msg.sender != d.buyer) revert Unauthorized();

d.state = State.Released;

(bool ok, ) = d.seller.call{value: d.amount}("");

if (!ok) revert TransferFailed();

emit DealReleased(dealId);

}

function refund(uint256 dealId) external onlyInState(dealId, State.Funded) {

Deal storage d = deals[dealId];

if (uint40(block.timestamp) < d.deadline) revert DeadlineNotPassed();

d.state = State.Refunded;

(bool ok, ) = payable(d.buyer).call{value: d.amount}("");

if (!ok) revert TransferFailed();

emit DealRefunded(dealId);

}

}

`

Design Decisions Explained

Storage Packing

The Deal struct is deliberately ordered so seller (20 bytes) + amount (12 bytes as uint96) fit into a single 32-byte storage slot. state (1 byte) + deadline (5 bytes as uint40) + buyer (20 bytes) pack into a second slot. This saves ~20,000 gas per deal creation compared to naive layouts using uint256 for the amount.

  • uint96 supports up to ~79 billion ETH โ€” more than total supply, so it's safe.
  • uint40 supports timestamps until year 36812 โ€” future-proof enough.

Custom Errors over require Strings

Custom errors (error InvalidState(...)) are ABI-encoded using only 4 bytes of selector plus parameters. They save significant gas compared to require(condition, "long string") which stores the string in bytecode and memory. Since Solidity 0.8.26+, custom errors are the idiomatic choice.

Checks-Effects-Interactions (CEI) Pattern

In release() and refund(), we update state before the external .call{}. This is critical to prevent reentrancy. Even though Solidity 0.8.x has built-in overflow checks, it does NOT protect against reentrancy. The state transition acts as our reentrancy guard without the gas overhead of OpenZeppelin's ReentrancyGuard mutex.

Why .call{} Instead of .transfer()

.transfer() and .send() forward only 2300 gas, which breaks when the recipient is a smart contract wallet (e.g., Safe multisig) or any contract with a non-trivial receive() function. Since EIP-1884 repriced SLOAD, 2300 gas is unreliable. Always use .call{value: amount}("").

Edge Cases and Security Considerations

  • Griefing via refusal to accept ETH: If seller is a contract that reverts on receive(), release() will fail permanently. Mitigation: implement a pull-over-push withdrawal pattern where the seller explicitly withdraws funds.
  • Block timestamp manipulation: Miners/validators can manipulate block.timestamp by a few seconds. For deadlines measured in days this is negligible, but avoid second-precision deadlines.
  • Front-running release(): A malicious seller could front-run to extract MEV. In this simple escrow, there is no extractable value, but in more complex contracts consider using commit-reveal or private mempools (Flashbots Protect).
  • Zero-address checks: Production contracts should validate _seller != address(0). Omitted here for brevity.
  • Deal ID overflow: nextDealId is uint256 โ€” overflow is physically impossible within the universe's lifetime.

Testing with Foundry

`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 buyer = address(0xB);

address payable seller = payable(address(0x5));

function setUp() public {

escrow = new Escrow();

vm.deal(buyer, 10 ether);

}

function test_fullLifecycle() public {

vm.prank(buyer);

uint256 id = escrow.createDeal(seller, 1 ether, uint40(block.timestamp + 1 days));

vm.prank(buyer);

escrow.fund{value: 1 ether}(id);

vm.prank(buyer);

escrow.release(id);

assertEq(seller.balance, 1 ether);

}

function test_refundAfterDeadline() public {

vm.prank(buyer);

uint256 id = escrow.createDeal(seller, 1 ether, uint40(block.timestamp + 1 days));

vm.prank(buyer);

escrow.fund{value: 1 ether}(id);

vm.warp(block.timestamp + 2 days);

escrow.refund(id);

assertEq(buyer.balance, 10 ether); // fully refunded

}

function testFail_refundBeforeDeadline() public {

vm.prank(buyer);

uint256 id = escrow.createDeal(seller, 1 ether, uint40(block.timestamp + 1 days));

vm.prank(buyer);

escrow.fund{value: 1 ether}(id);

escrow.refund(id); // should revert

}

}

`

Run with forge test -vvv for detailed stack traces.

Deployment Considerations

  • Use forge create or forge script with hardware wallet signing via --ledger for mainnet deployments.
  • Verify on Etherscan immediately: forge verify-contract
    Escrow --chain mainnet.
  • Consider upgradability only if truly needed โ€” UUPS proxies (ERC-1967) add complexity and attack surface. For immutable contracts, deploy-and-verify is safer.
  • Gas estimation: Use forge snapshot to track gas regressions across commits.

Beyond This Contract

Once comfortable, extend this escrow with:

  • ERC-20 support using SafeERC20 for non-standard token edge cases (USDT's missing return value)
  • Dispute resolution via an arbiter address with timelock
  • EIP-712 typed signatures for off-chain deal creation (gasless UX)
  • Formal verification using halmos or certora to mathematically prove invariants

Conclusion

Building your first smart contract is not about writing HelloWorld โ€” it is about internalizing the patterns that prevent exploits and reduce gas costs from day one. Storage packing, CEI, custom errors, and pull-over-push are not optimizations you add later; they are the foundation of every production Solidity contract in 2025.