- Published on
Smart Contract Development Complete Guide 2025: Solidity, Foundry, Security Patterns, DeFi Implementation
- Authors

- Name
- Youngju Kim
- @fjvbn20031
Why Smart Contracts? The Programmable Money Revolution
The 2016 DAO hack etched the importance of smart contract security into collective memory. A few lines of flawed code led to the theft of 3.6 million ETH (about 50M USD at the time, roughly 12B USD at today's prices). That event forced the Ethereum hard fork that created Ethereum Classic and fundamentally changed how we build on-chain systems.
By 2026, smart contracts are no longer experimental. DeFi TVL (Total Value Locked) has crossed 200 billion USD. Uniswap, Aave, Compound, and MakerDAO serve millions of users. NFTs have expanded into art, gaming, and identity. Real-world asset tokenization is accelerating.
Smart contracts offer three core properties.
First, programmable money. Value moves according to rules encoded in code. "If A pays 100, send token to B" executes without escrow agents or trusted intermediaries.
Second, immutability. Deployed code cannot be changed (proxies exist but are a deliberate design choice). Users know the code will not silently rewrite itself.
Third, transparency. Every line of code and every transaction is recorded on a public chain. Etherscan lets anyone read source and trace history.
These properties cut both ways. Immutability means bug fixes are hard. Transparency means attackers can study your attack surface. Programmable money means a single mistake can vaporize millions in minutes.
Smart contract engineering therefore requires a different mindset. Not "it works" but "it defends against every adversary I can imagine." This guide walks through that mindset end-to-end.
EVM Fundamentals
What the EVM Actually Is
The Ethereum Virtual Machine (EVM) is the state machine where smart contracts run. Every Ethereum node runs the same EVM and must arrive at the same result for every transaction. That consensus is the foundation of decentralization.
Key EVM traits:
- Stack-based: up to 1024 256-bit stack slots. No registers.
- 256-bit word: 32 bytes is the natural unit, matching
keccak256output and explaining whyuint256is everywhere. - Deterministic: same input always yields the same output. No randomness, no clocks, no network calls.
Data Locations: Storage, Memory, Calldata, Stack
| Location | Lifetime | Gas | Purpose |
|---|---|---|---|
| Storage | Permanent | Very expensive (SSTORE 20k) | Contract state |
| Memory | Transaction | Cheap (linear) | Function-local data |
| Calldata | Transaction | Cheapest (read-only) | External function args |
| Stack | Execution | Free (1024 cap) | Locals, intermediates |
Storage is permanent and expensive. First write costs 20,000 gas, subsequent writes 5,000. Memory is cheap but function-scoped. Calldata is the raw transaction input, cheaper than memory because no copy happens.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract DataLocationDemo {
uint256[] private _storedArray;
function processMemory(uint256[] memory data) public pure returns (uint256) {
data[0] = 999;
return data[0];
}
function processCalldata(uint256[] calldata data) external pure returns (uint256) {
return data[0];
}
function storeArray(uint256[] calldata data) external {
_storedArray = data;
}
}
Rule: prefer calldata for external function array/struct arguments. Copy to memory only when mutation is required.
The Gas Model
Every EVM opcode has a gas cost. Two reasons:
- DoS prevention. Bounded execution protects the network from runaway loops.
- Resource pricing. Operators are compensated for compute and storage.
Selected costs (London-era):
ADD,SUB: 3 gasMUL,DIV: 5 gasSLOAD(cold): 2,100; (warm): 100SSTORE: 20,000 / 5,000 / refunds on zeroingCALL(cold): 2,600 plus extrasCREATE: 32,000
You do not need to memorize the table, but internalize this: storage work is hundreds of times more expensive than arithmetic. Most optimization is about reducing storage touches.
Solidity Language Essentials
Types
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract TypeShowcase {
uint256 public unsignedInt;
int256 public signedInt;
uint8 public smallInt;
bool public flag;
address public owner;
address payable public recipient;
bytes32 public hash;
bytes4 public selector;
bytes public dynamicBytes;
string public name;
uint256[] public dynamicArray;
uint256[10] public fixedArray;
mapping(address => uint256) public balances;
mapping(address => mapping(address => uint256)) public allowances;
struct User {
uint256 id;
string name;
uint256 balance;
bool active;
}
mapping(address => User) public users;
enum Status { Pending, Active, Completed, Cancelled }
Status public currentStatus;
}
Points to remember:
uint256is the default. Smaller integers only save gas when packed in the same storage slot.addressis 20 bytes; usebytes32for hashes.stringandbytesare dynamic and expensive. Preferbytes32when size permits.- Every mapping slot is conceptually zero-initialized.
Functions, Visibility, Mutability
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract FunctionDemo {
uint256 private _counter;
uint256 public publicValue;
function incrementExternal() external {
_counter += 1;
}
function incrementPublic() public {
_counter += 1;
}
function _internalHelper() internal view returns (uint256) {
return _counter * 2;
}
function _privateHelper() private pure returns (uint256) {
return 42;
}
function getCounter() external view returns (uint256) {
return _counter;
}
function add(uint256 a, uint256 b) external pure returns (uint256) {
return a + b;
}
function deposit() external payable {}
}
Events
Events are log entries on chain. They are far cheaper than storage and can be streamed by off-chain indexers.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract EventDemo {
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
mapping(address => uint256) private _balances;
function transfer(address to, uint256 amount) external {
require(_balances[msg.sender] >= amount, "Insufficient balance");
_balances[msg.sender] -= amount;
_balances[to] += amount;
emit Transfer(msg.sender, to, amount);
}
}
Frontends and tools like The Graph rely on events to reconstruct history without paying storage costs.
Modifiers
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract ModifierDemo {
address public owner;
bool public paused;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
modifier whenNotPaused() {
require(!paused, "Contract paused");
_;
}
function pause() external onlyOwner whenNotPaused {
paused = true;
}
}
Interfaces and Inheritance
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
}
abstract contract Ownable {
address private _owner;
modifier onlyOwner() {
require(msg.sender == _owner, "Not owner");
_;
}
}
Foundry Deep Dive
Why Foundry
Foundry is a Rust-based Solidity toolchain from Paradigm. It is 10-100x faster than Hardhat, lets you write tests in Solidity, and ships a fuzzer that finds edge cases for free. In 2026, more than 70% of new projects pick Foundry.
Setup
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init my-project
cd my-project
Directory layout:
src/- contractstest/- Solidity testsscript/- deployment scriptslib/- dependenciesfoundry.toml- config
foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.24"
optimizer = true
optimizer_runs = 200
[profile.ci]
fuzz = { runs = 10000 }
invariant = { runs = 1000 }
[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
sepolia = "${SEPOLIA_RPC_URL}"
Writing Tests
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
address alice = address(0x1);
function setUp() public {
counter = new Counter();
vm.deal(alice, 10 ether);
}
function test_Increment() public {
counter.increment();
assertEq(counter.number(), 1);
}
function test_OnlyOwner() public {
vm.prank(alice);
vm.expectRevert("Not owner");
counter.setNumber(100);
}
}
Run them:
forge test
forge test --match-test test_Increment
forge test -vvv
forge test --gas-report
Fuzz Testing
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterFuzzTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
}
function testFuzz_SetNumber(uint256 x) public {
counter.setNumber(x);
assertEq(counter.number(), x);
}
function testFuzz_IncrementBounded(uint8 times) public {
vm.assume(times > 0 && times < 100);
for (uint256 i = 0; i < times; i++) {
counter.increment();
}
assertEq(counter.number(), times);
}
}
Foundry runs 256 iterations by default, minimizes failing inputs automatically, and can scale to tens of thousands of runs in CI.
Invariant Testing
Invariant tests assert properties that must hold after any sequence of calls.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
contract TokenInvariantTest is Test {
MyToken public token;
function setUp() public {
token = new MyToken("Test", "TST", 1_000_000 ether);
}
function invariant_TotalSupplyConstant() public view {
assertEq(token.totalSupply(), 1_000_000 ether);
}
}
cast and anvil
cast call 0xContract "balanceOf(address)(uint256)" 0xUser
cast send 0xContract "transfer(address,uint256)" 0xTo 100 --private-key $PK --rpc-url $RPC
cast to-wei 1 ether
anvil
anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
Mainnet forking lets you test against live Uniswap, Aave, and Chainlink feeds without paying real gas.
Foundry vs Hardhat
| Feature | Foundry | Hardhat |
|---|---|---|
| Test speed | Very fast (Rust) | Slower (Node.js) |
| Test language | Solidity | TypeScript/JS |
| Fuzz testing | Built-in | Plugin |
| Deployment scripts | Solidity | TypeScript |
| Debugging | -vvv traces | Stack traces |
| Plugin ecosystem | Medium | Large |
| Frontend integration | cast CLI | ethers.js/viem |
Recommendation: new projects should pick Foundry. Use Hardhat if tight TypeScript frontend coupling matters. Large teams often use both.
OpenZeppelin Building Blocks
ERC-20 Token
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract MyToken is ERC20, Ownable {
constructor(
string memory name,
string memory symbol,
uint256 initialSupply
) ERC20(name, symbol) Ownable(msg.sender) {
_mint(msg.sender, initialSupply);
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
function burn(uint256 amount) external {
_burn(msg.sender, amount);
}
}
ERC-721 NFT
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract MyNFT is ERC721, ERC721URIStorage, Ownable {
uint256 private _nextTokenId;
constructor() ERC721("MyNFT", "MNFT") Ownable(msg.sender) {}
function safeMint(address to, string memory uri) public onlyOwner {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
}
function tokenURI(uint256 tokenId)
public view override(ERC721, ERC721URIStorage) returns (string memory)
{
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public view override(ERC721, ERC721URIStorage) returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
AccessControl
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract RoleBasedToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
constructor() ERC20("RBT", "RBT") {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, msg.sender);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
}
ReentrancyGuard
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Security Patterns and Vulnerabilities
1. Reentrancy
// Vulnerable
contract VulnerableVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0;
}
}
// Safe - Checks-Effects-Interactions
contract SafeVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
The DAO (2016), Fei Rari (2022, 80M USD), and Euler Finance (2023, 197M USD) all failed to follow this pattern.
2. Integer Overflow
Since Solidity 0.8.0 overflow reverts by default. Inside unchecked blocks it still wraps - use only when mathematically safe.
pragma solidity ^0.8.24;
contract OverflowDemo {
function safeAdd(uint256 a, uint256 b) external pure returns (uint256) {
return a + b;
}
function uncheckedAdd(uint256 a, uint256 b) external pure returns (uint256) {
unchecked {
return a + b;
}
}
}
3. Front-Running / MEV
Public mempools let adversaries reorder or sandwich trades. Defenses: commit-reveal, Flashbots Protect, slippage limits, protocol-level hooks (Uniswap V4).
4. Oracle Manipulation
Use Chainlink, TWAPs, or multiple sources. Flash loans can move single-DEX spot prices enough to drain protocols that trust raw quotes.
5. Unchecked External Calls
function unsafeTransfer(address to, uint256 amount) external {
payable(to).call{value: amount}("");
}
function safeTransfer(address to, uint256 amount) external {
(bool success, ) = payable(to).call{value: amount}("");
require(success, "Transfer failed");
}
Gas Optimization Techniques
1. Storage Packing
contract Unpacked {
uint256 a;
uint256 b;
uint256 c;
}
contract Packed {
uint128 a;
uint64 b;
uint64 c;
}
2. immutable and constant
contract ConstantsDemo {
uint256 public constant MAX_SUPPLY = 1_000_000;
address public immutable owner;
constructor() {
owner = msg.sender;
}
}
3. Custom Errors
contract ErrorDemo {
error ValueMustBePositive();
error InsufficientBalance(uint256 available, uint256 required);
function newWay(uint256 x) external pure {
if (x == 0) revert ValueMustBePositive();
}
function withdraw(uint256 amount, uint256 balance) external pure {
if (balance < amount) {
revert InsufficientBalance(balance, amount);
}
}
}
4. calldata over memory and events over storage
Prefer calldata for external function parameters, and lean on events when data only needs to be reconstructable off-chain.
Upgradeable Contracts
UUPS Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyTokenV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public value;
function initialize() public initializer {
__Ownable_init(msg.sender);
__UUPSUpgradeable_init();
}
function setValue(uint256 _value) external {
value = _value;
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
Upgradeable contracts cannot use constructors; they use initialize() once. Storage layout changes corrupt data. OpenZeppelin Upgrades verifies layouts automatically.
Simple AMM Implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SimpleAMM {
IERC20 public immutable tokenA;
IERC20 public immutable tokenB;
uint256 public reserveA;
uint256 public reserveB;
constructor(address _tokenA, address _tokenB) {
tokenA = IERC20(_tokenA);
tokenB = IERC20(_tokenB);
}
function addLiquidity(uint256 amountA, uint256 amountB) external {
tokenA.transferFrom(msg.sender, address(this), amountA);
tokenB.transferFrom(msg.sender, address(this), amountB);
reserveA += amountA;
reserveB += amountB;
}
function swap(uint256 amountAIn) external returns (uint256 amountBOut) {
require(amountAIn > 0, "Invalid input");
uint256 amountAInWithFee = (amountAIn * 997) / 1000;
amountBOut = (reserveB * amountAInWithFee) / (reserveA + amountAInWithFee);
tokenA.transferFrom(msg.sender, address(this), amountAIn);
tokenB.transfer(msg.sender, amountBOut);
reserveA += amountAIn;
reserveB -= amountBOut;
}
}
Real Uniswap is more complex (LP tokens, fee accrual, flash swap protection), but the invariant x * y = k is the heart of it.
Audit Tools
- Slither: static analysis, dozens of known patterns.
slither src/MyContract.sol. - Mythril: symbolic execution.
myth analyze src/MyContract.sol. - Echidna: fuzz tester beyond Foundry's built-in.
Deployment Workflow
- Local development with
anvilandforge test - Testnet deployment (Sepolia, Holesky)
- External audit (Trail of Bits, OpenZeppelin, Consensys Diligence)
- Bug bounty program (Immunefi)
- Mainnet rollout with TVL caps
- Monitoring (Forta, Tenderly)
forge script script/Deploy.s.sol \
--rpc-url sepolia \
--broadcast \
--verify
forge create src/MyToken.sol:MyToken \
--rpc-url $RPC \
--private-key $PK \
--constructor-args "Name" "SYMBOL" 1000000
Etherscan verification is non-negotiable.
Production Checklist
- Solidity 0.8.x or newer
- Checks-Effects-Interactions everywhere
-
nonReentrantmodifier on value-moving functions -
uncheckedonly with proven safety - Check return value of
call - Index searchable event fields
- Custom errors instead of require strings
- Chainlink TWAP oracles
- Fuzz tests on every function
- Invariant tests on global properties
- Slither clean
- External audit
- Bug bounty
- Multisig admin (Gnosis Safe)
- Timelock on upgrades
Quiz
Q1. Why is Checks-Effects-Interactions important?
If state is updated after an external call, the callee can re-enter the function with stale state and drain funds. Updating state first eliminates the window of vulnerability.
Q2. What is the difference between calldata and memory?
calldata points to immutable transaction input, is the cheapest, and is read-only. memory is a mutable per-call region but copying into it costs gas. Prefer calldata for external function array/struct parameters unless mutation is required.
Q3. Why use unchecked in Solidity 0.8+?
Purely for gas savings. 0.8 inserts overflow checks on every arithmetic op. When overflow is mathematically impossible (e.g. loop counters bounded by length), unchecked removes those checks. Only use it when safety is provable.
Q4. What are the benefits of UUPS proxies?
UUPS keeps upgrade logic in the implementation, so the proxy is lighter and cheaper per call. The trade-off: if a new implementation forgets to expose an upgrade function, the contract becomes permanently frozen.
Q5. Fuzz testing vs invariant testing?
Fuzz tests drive a single function with randomized inputs. Invariant tests drive a random sequence of calls across the whole contract and assert global properties. Invariants are dramatically more powerful for DeFi where state interacts across many actions.