DeFi Development Building Decentralized Finance Apps
Introduction
Decentralized Finance — or DeFi — is one of the most transformative developments in the history of money. Instead of relying on a bank in Lahore or a brokerage in Karachi, DeFi protocols let anyone lend, borrow, trade, and earn yield directly on the blockchain, governed entirely by smart contracts. No paperwork, no KYC queues, no bank holidays.
For Pakistani developers, DeFi represents a genuinely exciting frontier. Pakistan's remittance market alone exceeds $27 billion annually, and millions of citizens remain underbanked. The protocols you will learn to build in this tutorial are exactly the kind of infrastructure that could one day change how Fatima in Islamabad earns yield on her savings, or how Ahmad in Lahore sends value to a client in Dubai — instantly, permissionlessly, and at a fraction of the cost of traditional finance.
In this tutorial, you will go from understanding DeFi primitives all the way to forking Uniswap's core contracts, writing your own liquidity pool, and deploying a functional AMM (Automated Market Maker) on a local Hardhat node. This is an advanced DeFi tutorial, so you will be writing real Solidity, wiring up real interfaces, and handling real edge cases.
Prerequisites
Before diving in, make sure you are comfortable with the following:
- Solidity fundamentals — structs, mappings, modifiers, events. If you need a refresher, read our Solidity Tutorial on theiqra.edu.pk.
- Hardhat development environment — compiling, deploying, and testing contracts locally. See our Hardhat Tutorial for a complete setup guide.
- ERC-20 token standard —
transfer,approve,transferFrom,allowance. - Basic JavaScript/Node.js — for writing Hardhat scripts and tests.
- Understanding of Ethereum gas model — why gas optimisation matters in DeFi.
- A working Node.js ≥ 18 installation and a code editor (VS Code recommended).
If you have built and deployed at least one smart contract before, you are ready.
Core Concepts & Explanation
Automated Market Makers and the Constant-Product Formula
Traditional exchanges use an order book — buyers post bids, sellers post asks, and trades happen when they match. This works well with many participants and high liquidity, but on-chain order books are prohibitively expensive in gas and slow by design.
Automated Market Makers (AMMs) solve this with a beautifully simple idea: instead of matching individual orders, a smart contract holds a pool of two tokens and prices trades algorithmically using a mathematical invariant.
Uniswap V2 — still the most widely forked DeFi protocol in existence — uses the constant-product formula:
x · y = k
Where x is the reserve of Token A, y is the reserve of Token B, and k is a constant that must never decrease. When a trader swaps Token A for Token B, they deposit Δx into the pool and receive Δy such that:
(x + Δx) · (y − Δy) = k
Practical Example. Say Ali creates a pool with 1,000 PKR-equivalent USDC (x = 1000) and 1 ETH (y = 1), so k = 1000. A trader wants to buy 0.1 ETH. Solving for Δx:
(1000 + Δx) · (1 − 0.1) = 1000
(1000 + Δx) · 0.9 = 1000
1000 + Δx = 1111.11
Δx ≈ 111.11 USDC
So Ali's trader pays ~111 USDC for 0.1 ETH — an effective price of 1,111 USDC/ETH, slightly above the starting price of 1,000 USDC/ETH. This price impact increases with trade size, naturally discouraging manipulation of small pools.
Liquidity Providers, LP Tokens, and Fee Accrual
The reserves in a pool do not appear from nowhere — they are deposited by Liquidity Providers (LPs). Anyone can add equal value of both tokens to a pool and, in return, receive LP tokens that represent their proportional share of the pool.
LP tokens are themselves ERC-20 tokens. They are redeemable at any time for the underlying reserves, plus a share of the trading fees that have accumulated since deposit. Uniswap V2 charges 0.3% on every swap, which stays inside the pool, growing the reserves — and therefore the redemption value of every LP token.
This mechanism creates a self-sustaining flywheel:
- Traders pay fees to swap.
- Fees grow LP token value.
- Higher yield attracts more LPs.
- Deeper liquidity reduces price impact.
- Better prices attract more traders → back to step 1.
Impermanent Loss is the flip side. If the price ratio between the two tokens shifts significantly after deposit, an LP may end up with less total value than if they had simply held both tokens. Understanding and communicating this risk is essential when building any DeFi front-end for real users.
Practical Code Examples
Example 1: Building a Minimal AMM Pool Contract
Below is a stripped-down but fully functional constant-product AMM pool. It deliberately omits router abstraction so you can see every mechanism clearly.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
/// @title IqraSwapPair — A minimal constant-product AMM
/// @notice Inspired by Uniswap V2 Core for educational purposes
contract IqraSwapPair is ERC20 {
// ── State Variables ──────────────────────────────────────────
IERC20 public immutable token0; // First token in the pair
IERC20 public immutable token1; // Second token in the pair
uint256 public reserve0; // Tracked reserve of token0
uint256 public reserve1; // Tracked reserve of token1
uint256 private constant MINIMUM_LIQUIDITY = 1000; // Prevents price manipulation on first add
uint256 private constant FEE_NUMERATOR = 997; // 0.3% fee (997/1000)
uint256 private constant FEE_DENOMINATOR = 1000;
// ── Events ───────────────────────────────────────────────────
event Mint(address indexed sender, uint256 amount0, uint256 amount1);
event Burn(address indexed sender, uint256 amount0, uint256 amount1, address to);
event Swap(
address indexed sender,
uint256 amount0In,
uint256 amount1In,
uint256 amount0Out,
uint256 amount1Out,
address indexed to
);
// ── Constructor ──────────────────────────────────────────────
constructor(address _token0, address _token1)
ERC20("IqraSwap LP", "IQS-LP")
{
token0 = IERC20(_token0); // Store immutable reference to token0
token1 = IERC20(_token1); // Store immutable reference to token1
}
// ── Internal Helpers ─────────────────────────────────────────
/// @dev Update internal reserves to match actual balances
function _update(uint256 balance0, uint256 balance1) private {
reserve0 = balance0;
reserve1 = balance1;
}
// ── Core Functions ───────────────────────────────────────────
/// @notice Add liquidity; caller must have approved both tokens first
function addLiquidity(uint256 amount0, uint256 amount1)
external
returns (uint256 liquidity)
{
// Transfer tokens from the caller into this contract
token0.transferFrom(msg.sender, address(this), amount0);
token1.transferFrom(msg.sender, address(this), amount1);
uint256 _totalSupply = totalSupply();
if (_totalSupply == 0) {
// First deposit: LP tokens = geometric mean of amounts, minus MINIMUM_LIQUIDITY
// Geometric mean prevents large initial price distortion
liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
// Permanently lock MINIMUM_LIQUIDITY to address(1) to prevent division-by-zero
_mint(address(1), MINIMUM_LIQUIDITY);
} else {
// Subsequent deposits: LP tokens proportional to smaller share added
// This enforces that existing price ratio is respected
liquidity = Math.min(
(amount0 * _totalSupply) / reserve0,
(amount1 * _totalSupply) / reserve1
);
}
require(liquidity > 0, "IqraSwap: INSUFFICIENT_LIQUIDITY_MINTED");
_mint(msg.sender, liquidity); // Award LP tokens to provider
_update( // Sync reserves with new balances
token0.balanceOf(address(this)),
token1.balanceOf(address(this))
);
emit Mint(msg.sender, amount0, amount1);
}
/// @notice Burn LP tokens to reclaim underlying tokens
function removeLiquidity(uint256 lpAmount)
external
returns (uint256 amount0, uint256 amount1)
{
uint256 _totalSupply = totalSupply();
// Calculate proportional share of each reserve
amount0 = (lpAmount * reserve0) / _totalSupply;
amount1 = (lpAmount * reserve1) / _totalSupply;
require(amount0 > 0 && amount1 > 0, "IqraSwap: INSUFFICIENT_LIQUIDITY_BURNED");
_burn(msg.sender, lpAmount); // Destroy the LP tokens
token0.transfer(msg.sender, amount0); // Return token0 share
token1.transfer(msg.sender, amount1); // Return token1 share
_update(
token0.balanceOf(address(this)),
token1.balanceOf(address(this))
);
emit Burn(msg.sender, amount0, amount1, msg.sender);
}
/// @notice Swap an exact amount of token0 for token1
function swapToken0ForToken1(uint256 amountIn, uint256 amountOutMin)
external
returns (uint256 amountOut)
{
require(amountIn > 0, "IqraSwap: ZERO_INPUT");
// Apply 0.3% fee: effective input = amountIn * 997 / 1000
uint256 amountInWithFee = amountIn * FEE_NUMERATOR;
// Derive output using constant-product formula, fee-adjusted
amountOut = (amountInWithFee * reserve1)
/ (reserve0 * FEE_DENOMINATOR + amountInWithFee);
require(amountOut >= amountOutMin, "IqraSwap: SLIPPAGE_EXCEEDED");
require(amountOut < reserve1, "IqraSwap: INSUFFICIENT_LIQUIDITY");
token0.transferFrom(msg.sender, address(this), amountIn); // Pull tokens in
token1.transfer(msg.sender, amountOut); // Push tokens out
_update(
token0.balanceOf(address(this)),
token1.balanceOf(address(this))
);
emit Swap(msg.sender, amountIn, 0, 0, amountOut, msg.sender);
}
}
Line-by-line walkthrough of key sections:
- Lines 10–11 — Both token addresses are
immutable, meaning they are baked into bytecode at deployment and cost zero storage reads. This is a critical gas optimisation in DeFi. - Lines 14–15 —
reserve0andreserve1track the pool's last known balances. They are updated after every state change via_update(). Keeping these separate frombalanceOfprevents re-entrancy price manipulation. - Lines 44–46 — The geometric mean
√(amount0 × amount1)for the first deposit ensures that LP token value is independent of how the initial price was set, and that neither token can dominate unfairly. - Lines 78–80 — When removing liquidity, the LP's proportional share is calculated against
totalSupply()before burning. Order matters — burning first would reduce the denominator and inflate the share. - Lines 96–99 — The fee-adjusted constant-product swap. Multiplying
amountInby 997 before applying the formula is Uniswap's elegant way of deducting fees without extra branches.
Example 2: Real-World Application — Flash Loan Contract
Flash loans are one of DeFi's most novel inventions: uncollateralised loans that borrow any amount, use it, and repay it all within a single transaction. If repayment fails, the entire transaction reverts — so the lender never holds risk.
Below is a minimal flash loan provider and a borrower skeleton that demonstrates the pattern. Think of this as the backbone Fatima would need to build a DeFi arbitrage bot between two DEXes.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
// ─── Flash Loan Receiver Interface ───────────────────────────────────────────
/// @notice Any contract that wants to receive a flash loan must implement this
interface IFlashLoanReceiver {
/// @param token Address of the borrowed token
/// @param amount Amount borrowed
/// @param fee Fee owed on top of `amount`
/// @param data Arbitrary calldata passed through from initiator
function executeOperation(
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external;
}
// ─── Flash Loan Pool ─────────────────────────────────────────────────────────
contract IqraFlashPool {
IERC20 public immutable loanToken; // Token this pool lends
uint256 public constant FLASH_FEE_BPS = 9; // 0.09% fee (9 basis points)
event FlashLoan(address indexed receiver, uint256 amount, uint256 fee);
constructor(address _token) {
loanToken = IERC20(_token);
}
/// @notice Execute a flash loan
/// @param receiver Contract that will use the borrowed funds
/// @param amount How much to borrow
/// @param data Arbitrary data forwarded to `executeOperation`
function flashLoan(
address receiver,
uint256 amount,
bytes calldata data
) external {
uint256 balanceBefore = loanToken.balanceOf(address(this)); // Snapshot balance
require(amount <= balanceBefore, "IqraFlash: INSUFFICIENT_POOL_LIQUIDITY");
// Calculate fee: amount * 9 / 10000
uint256 fee = (amount * FLASH_FEE_BPS) / 10_000;
// Transfer funds to receiver — no collateral taken
loanToken.transfer(receiver, amount);
// Call receiver's logic — THIS is where arbitrage / liquidation happens
IFlashLoanReceiver(receiver).executeOperation(
address(loanToken),
amount,
fee,
data
);
// Verify full repayment (principal + fee) after receiver's callback
uint256 balanceAfter = loanToken.balanceOf(address(this));
require(
balanceAfter >= balanceBefore + fee,
"IqraFlash: REPAYMENT_FAILED" // If this reverts, ENTIRE tx reverts
);
emit FlashLoan(receiver, amount, fee);
}
}
// ─── Example Flash Loan Borrower (Skeleton) ──────────────────────────────────
contract SimpleArbitrageur is IFlashLoanReceiver {
address public owner;
IqraFlashPool public pool;
constructor(address _pool) {
owner = msg.sender;
pool = IqraFlashPool(_pool);
}
/// @notice Initiate the flash loan; Ahmad calls this
function startArbitrage(uint256 amount, bytes calldata swapData) external {
require(msg.sender == owner, "Not authorised");
pool.flashLoan(address(this), amount, swapData);
}
/// @notice Pool calls back here with borrowed funds in hand
function executeOperation(
address token,
uint256 amount,
uint256 fee,
bytes calldata /* data */
) external override {
require(msg.sender == address(pool), "Untrusted caller");
// ── YOUR ARBITRAGE LOGIC GOES HERE ──────────────────────
// 1. Swap borrowed tokens on DEX A for profit
// 2. Swap back on DEX B
// 3. Keep the spread
// ────────────────────────────────────────────────────────
// Repay principal + fee
uint256 repayAmount = amount + fee;
IERC20(token).transfer(address(pool), repayAmount);
}
}
Key takeaways from this code:
- The entire borrow-use-repay cycle lives inside one call stack. There is no waiting, no block confirmation in between.
- The
require(balanceAfter >= balanceBefore + fee)check on line 55 is the atomic guarantee. Either the contract returns with profit and repays, or the EVM rolls back every state change in the transaction. - Notice
require(msg.sender == address(pool))inexecuteOperation. Without this, any contract could call your receiver and drain approved tokens — a classic DeFi vulnerability.

Common Mistakes & How to Avoid Them
Mistake 1: Re-Entrancy in Swap and Withdraw Functions
Re-entrancy is the classic DeFi exploit pattern — an attacker's fallback function calls back into your contract before state is updated, allowing them to drain reserves in a loop. The 2016 DAO hack lost $60 million this way.
Vulnerable pattern:
// ❌ WRONG: Sends tokens BEFORE updating state
function removeLiquidity(uint256 lpAmount) external {
uint256 amount0 = (lpAmount * reserve0) / totalSupply();
token0.transfer(msg.sender, amount0); // External call first — DANGER
_burn(msg.sender, lpAmount); // State update too late
}
Correct pattern — Checks-Effects-Interactions:
// ✅ CORRECT: Update all state BEFORE any external call
function removeLiquidity(uint256 lpAmount) external {
uint256 amount0 = (lpAmount * reserve0) / totalSupply();
_burn(msg.sender, lpAmount); // 1. Effect: burn tokens
reserve0 -= amount0; // 2. Effect: update reserve
token0.transfer(msg.sender, amount0); // 3. Interaction: transfer last
}
Alternatively, add OpenZeppelin's ReentrancyGuard and decorate critical functions with the nonReentrant modifier. In production DeFi, do both.
Mistake 2: Ignoring Slippage and Sandwich Attack Vectors
When a user submits a swap transaction, it sits in the mempool for seconds before inclusion. A MEV bot can spot it, front-run with a large buy (pushing the price up), let the victim's swap execute at a worse price, then immediately sell — profiting from the spread. This is called a sandwich attack.
Every swap function must accept an amountOutMin parameter and enforce it:
// ❌ WRONG: No slippage protection — victim of sandwich attacks
function swap(uint256 amountIn) external {
uint256 amountOut = getAmountOut(amountIn);
token1.transfer(msg.sender, amountOut); // User gets whatever price MEV leaves them
}
// ✅ CORRECT: User specifies minimum acceptable output
function swap(uint256 amountIn, uint256 amountOutMin) external {
uint256 amountOut = getAmountOut(amountIn);
require(amountOut >= amountOutMin, "SLIPPAGE_EXCEEDED");
token1.transfer(msg.sender, amountOut);
}
On your front-end, always allow the user to configure a slippage tolerance (typically 0.5%–1%) and compute amountOutMin = expectedOut * (1 - slippageTolerance) before submitting. This is not optional — it is a user safety requirement.

Practice Exercises
Exercise 1: Add a Fee Withdrawal Mechanism
Problem: The IqraSwapPair contract collects 0.3% fees inside the pool, but there is no way for the protocol treasury to collect a share. Uniswap V2 has an optional feeTo address that receives 1/6 of all fees. Add a similar mechanism: introduce an owner address and a protocolFeeBps variable (e.g., 5 bps). After each swap, transfer the protocol's share to owner.
Solution approach:
address public owner;
uint256 public protocolFeeBps = 5; // 0.05%
constructor(address _token0, address _token1) ERC20("IqraSwap LP", "IQS-LP") {
token0 = IERC20(_token0);
token1 = IERC20(_token1);
owner = msg.sender; // Set deployer as initial fee recipient
}
// Inside swapToken0ForToken1, after computing amountOut:
uint256 protocolFee = (amountIn * protocolFeeBps) / 10_000;
token0.transfer(owner, protocolFee); // Send fee to treasury before _update()
Key consideration: deduct the protocol fee from amountIn before computing the LP fee, so the two fee layers do not compound unexpectedly.
Exercise 2: Write a Hardhat Test for Flash Loan Repayment Failure
Problem: Verify that if executeOperation does not repay the full amount, the entire flashLoan transaction reverts and the pool's balance is unchanged.
Solution:
// test/FlashLoan.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("IqraFlashPool", function () {
let pool, mockToken, attacker;
beforeEach(async function () {
[, attacker] = await ethers.getSigners();
// Deploy a mock ERC-20 and seed the pool with 10,000 tokens
const Token = await ethers.getContractFactory("MockERC20");
mockToken = await Token.deploy("Test", "TST", ethers.parseEther("10000"));
const Pool = await ethers.getContractFactory("IqraFlashPool");
pool = await Pool.deploy(await mockToken.getAddress());
// Fund the pool
await mockToken.transfer(await pool.getAddress(), ethers.parseEther("10000"));
});
it("should revert if borrower does not repay", async function () {
// Deploy a malicious receiver that keeps the borrowed tokens
const BadReceiver = await ethers.getContractFactory("MaliciousReceiver");
const bad = await BadReceiver.deploy(await pool.getAddress());
// Pool balance before
const before = await mockToken.balanceOf(await pool.getAddress());
// Attempt flash loan — expect revert
await expect(
pool.flashLoan(await bad.getAddress(), ethers.parseEther("1000"), "0x")
).to.be.revertedWith("IqraFlash: REPAYMENT_FAILED");
// Pool balance must be unchanged
const after = await mockToken.balanceOf(await pool.getAddress());
expect(after).to.equal(before);
});
});
Run with npx hardhat test. The test should pass, confirming your atomicity guarantee holds.
Frequently Asked Questions
What is the difference between a DEX and a traditional exchange?
A decentralized exchange (DEX) like Uniswap replaces the centralised order book with a smart contract that holds token reserves and prices trades algorithmically using an AMM formula. There is no company, no custodian, and no sign-up — just a contract address anyone can interact with.
How do I fork Uniswap V2 safely for my own DeFi project?
Start from the official Uniswap V2 Core and Periphery repositories, then audit every change you make against known vulnerability lists (Consensys Diligence, SWC Registry). Change only what you need — fee rate, pool token, factory logic — and commission a professional audit before deploying with real funds. Never deploy a forked AMM on mainnet without an audit.
What is impermanent loss and why does it matter for liquidity providers?
Impermanent loss occurs when the price ratio between two pooled tokens changes after you deposit. Because AMM rebalancing sells the appreciating token and buys the depreciating one, your portfolio diverges from a simple hold strategy. It is "impermanent" because it disappears if prices return to the original ratio — but in practice, it often does not.
Can I build DeFi apps on a network cheaper than Ethereum mainnet?
Yes. Ethereum Layer-2 networks like Arbitrum, Optimism, and Polygon all support the same Solidity code and Hardhat tooling, with transaction costs orders of magnitude lower. For a Pakistani developer experimenting with DeFi, deploying to Arbitrum Sepolia testnet first is strongly recommended.
What are the legal considerations for DeFi in Pakistan?
DeFi sits in a regulatory grey zone in Pakistan. The State Bank of Pakistan has issued cautions about virtual assets. If you are building a product with real users, consult a legal professional familiar with fintech regulation in Pakistan. For purely educational or testnet deployments, there are no current restrictions on development itself.
Summary & Key Takeaways
- The constant-product formula (
x · y = k) is the mathematical foundation of most AMM-based DEXes, including Uniswap V2 and its forks. Understanding it deeply is the prerequisite for all advanced DeFi development. - LP tokens represent proportional ownership of pool reserves plus accrued fees. They are themselves ERC-20 tokens and can be composed into yield farming strategies.
- Flash loans are uncollateralised, single-transaction loans enforced entirely by the EVM's atomicity guarantee. They power arbitrage, liquidations, and collateral swaps.
- Always follow the Checks-Effects-Interactions pattern to prevent re-entrancy, and always enforce
amountOutMinslippage guards to protect users from MEV sandwich attacks. - Testing is not optional in DeFi. A single missing
requirecan drain an entire protocol. Write unit tests for every critical path, especially failure cases. - DeFi development is composable — your pool contract can be used as a building block inside yield aggregators, lending protocols, and arbitrage bots written by people you have never met.
Next Steps & Related Tutorials
You have now built a functional AMM pair, a flash loan pool, and a hardened test suite. Here is where to go next on theiqra.edu.pk:
- Solidity Tutorial — If any of the Solidity patterns above felt unfamiliar, this foundational tutorial covers storage layout, function visibility, modifiers, and events in depth.
- Hardhat Tutorial — Master deployment scripts, forking mainnet for integration tests, and writing gas reports — all essential for production DeFi work.
- Web3.js / Ethers.js Tutorial — Learn to wire up your AMM contracts to a React front-end, read live reserve data, and submit typed transactions with slippage parameters.
- Smart Contract Security Tutorial (coming soon) — A deep dive into re-entrancy, oracle manipulation, integer overflow, and access control vulnerabilities with CTF-style challenges to sharpen your auditing skills.
Happy building — the protocols you write today could become the financial infrastructure of tomorrow's Pakistan. 🚀
Test Your Python Knowledge!
Finished reading? Take a quick quiz to see how much you've learned from this tutorial.