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.
uint96supports up to ~79 billion ETH โ more than total supply, so it's safe.uint40supports 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
selleris a contract that reverts onreceive(),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.timestampby 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:
nextDealIdisuint256โ 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 createorforge scriptwith hardware wallet signing via--ledgerfor 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 snapshotto track gas regressions across commits.
Beyond This Contract
Once comfortable, extend this escrow with:
- ERC-20 support using
SafeERC20for 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
halmosorcertorato 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.