The DeFi Ecosystem and Uniswap V2
The decentralized finance (DeFi) ecosystem is built on a layer of foundational protocols—MakerDAO's DAI stablecoin, Compound's lending protocol, and Synthetix's synthetic assets. Among them, Uniswap is the representative decentralized exchange (DEX) and one of the most liquid trading protocols in DeFi, thanks to its elegant automated market maker (AMM) mechanism.
Uniswap V2 is the version of the protocol that introduced ERC-20/ERC-20 trading pairs (V1 only supported ETH/ERC-20), price oracles, flash swaps, and other key features. For frontend developers, understanding its contract structure and interaction patterns is foundational for building DeFi frontends.
Uniswap V2 Core Contract Architecture
The Uniswap V2 contract system consists of three core components:
The Factory Contract
The Factory contract serves as the registry for all trading pairs. Every ERC-20/ERC-20 pair is created through the Factory and is globally unique:
// UniswapV2Factory core methods
function createPair(address tokenA, address tokenB) external returns (address pair);
function getPair(address tokenA, address tokenB) external view returns (address pair);
function allPairs(uint) external view returns (address pair);
function allPairsLength() external view returns (uint);
The frontend uses getPair() to query the address of a specific token pair. If it returns address(0), the pair has not been created yet.
The Pair Contract
The Pair contract is the actual liquidity pool, holding the reserves of two ERC-20 tokens. Each Pair contract is itself an ERC-20 token (LP Token), representing the share of liquidity providers:
// UniswapV2Pair core methods
function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
function mint(address to) external returns (uint liquidity);
function burn(address to) external returns (uint amount0, uint amount1);
The Router02 Contract
Router02 is the user-facing routing contract, encapsulating complex logic such as multi-hop trades, token wrapping/unwrapping, etc. The vast majority of frontend interactions go through Router02:
// UniswapV2Router02 key methods
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external returns (uint[] memory amounts);
function addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) external returns (uint amountA, uint amountB, uint liquidity);
Reading Liquidity Pool Data from the Frontend
Reading on-chain data is the first step in building a DeFi frontend. Here's how to read Pair contract reserves using ethers.js:
import { ethers } from 'ethers';
const UNISWAP_FACTORY = '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f';
const ROUTER02 = '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D';
const ERC20_ABI = [
'function name() view returns (string)',
'function symbol() view returns (string)',
'function decimals() view returns (uint8)',
'function balanceOf(address) view returns (uint256)',
'function allowance(address,address) view returns (uint256)',
'function approve(address,uint256) returns (bool)',
];
const FACTORY_ABI = [
'function getPair(address,address) view returns (address)',
];
const PAIR_ABI = [
'function getReserves() view returns (uint112,uint112,uint32)',
'function token0() view returns (address)',
'function token1() view returns (address)',
'function totalSupply() view returns (uint256)',
];
const ROUTER_ABI = [
'function getAmountsOut(uint,address[]) view returns (uint[])',
'function getAmountsIn(uint,address[]) view returns (uint[])',
'function swapExactTokensForTokens(uint,uint,address[],address,uint) returns (uint[])',
'function swapExactETHForTokens(uint,address[],address,uint) payable returns (uint[])',
'function addLiquidity(address,address,uint,uint,uint,uint,address,uint) returns (uint,uint,uint)',
'function removeLiquidity(address,address,uint,uint,uint,address,uint) returns (uint,uint)',
];
async function getPairData(provider, tokenA, tokenB) {
const factory = new ethers.Contract(UNISWAP_FACTORY, FACTORY_ABI, provider);
const pairAddress = await factory.getPair(tokenA, tokenB);
if (pairAddress === ethers.constants.AddressZero) {
return null; // Pair does not exist
}
const pair = new ethers.Contract(pairAddress, PAIR_ABI, provider);
const [reserve0, reserve1, timestamp] = await pair.getReserves();
const token0Address = await pair.token0();
const totalSupply = await pair.totalSupply();
// Ensure reserves correspond to the input token order
const isTokenA0 = tokenA.toLowerCase() === token0Address.toLowerCase();
return {
pairAddress,
reserveA: isTokenA0 ? reserve0 : reserve1,
reserveB: isTokenA0 ? reserve1 : reserve0,
reserve0,
reserve1,
timestamp,
totalSupply,
};
}
The reserve0 and reserve1 values returned by getReserves() are sorted—token0 is always the token with the smaller address. The frontend must handle this ordering, otherwise it will introduce a price inversion bug.
Constant Product Formula and Price Calculation
Uniswap V2 uses the constant product formula for pricing: x * y = k, where x and y are the reserves of the two tokens. The product of reserves after a trade must remain constant (before deducting fees).
In actual trades, a 0.3% fee is deducted, meaning 99.7% of the input amount participates in the product calculation:
// Frontend price simulation (for display; actual trade prices are determined on-chain)
function getAmountOut(amountIn, reserveIn, reserveOut) {
const amountInWithFee = amountIn.mul(997);
const numerator = amountInWithFee.mul(reserveOut);
const denominator = reserveIn.mul(1000).add(amountInWithFee);
return numerator.div(denominator);
}
function getAmountIn(amountOut, reserveIn, reserveOut) {
const numerator = reserveIn.mul(amountOut).mul(1000);
const denominator = reserveOut.sub(amountOut).mul(997);
return numerator.div(denominator).add(1);
}
// Slippage calculation
function calculateSlippage(amountIn, reserveIn, reserveOut) {
const amountOut = getAmountOut(amountIn, reserveIn, reserveOut);
// Theoretical price without fees
const theoreticalOut = amountIn.mul(reserveOut).div(reserveIn);
const slippage = theoreticalOut.sub(amountOut).mul(10000).div(theoreticalOut);
return {
amountOut,
slippagePercent: slippage.toNumber() / 100,
};
}
Note that ethers.js's BigNumber does not support floating-point arithmetic—all calculations must be done with integers. The frontend converts wei units to readable amounts only when displaying.
Frontend Swap Transaction Construction
A complete swap transaction flow on the frontend involves the following steps:
- Query the token allowance
- If allowance is insufficient, call
approve() - Call the swap function through the Router
- Wait for transaction confirmation
async function executeSwap(
provider,
signer,
tokenIn,
tokenOut,
amountIn,
slippageTolerance = 0.5 // 0.5%
) {
const router = new ethers.Contract(ROUTER02, ROUTER_ABI, signer);
const token = new ethers.Contract(tokenIn, ERC20_ABI, signer);
// 1. Check allowance
const allowance = await token.allowance(await signer.getAddress(), ROUTER02);
if (allowance.lt(amountIn)) {
const approveTx = await token.approve(ROUTER02, ethers.constants.MaxUint256);
await approveTx.wait();
}
// 2. Calculate minimum output
const path = [tokenIn, tokenOut];
const amounts = await router.getAmountsOut(amountIn, path);
const expectedOut = amounts[amounts.length - 1];
const amountOutMin = expectedOut.mul(1000 - Math.floor(slippageTolerance * 10)).div(1000);
// 3. Set deadline (20 minutes)
const deadline = Math.floor(Date.now() / 1000) + 60 * 20;
// 4. Execute swap
const isETHIn = tokenIn === '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; // WETH
const isETHOut = tokenOut === '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2';
let swapTx;
if (isETHIn) {
swapTx = await router.swapExactETHForTokens(
amountOutMin,
path,
await signer.getAddress(),
deadline,
{ value: amountIn }
);
} else if (isETHOut) {
swapTx = await router.swapExactTokensForETH(
amountOutMin,
path,
await signer.getAddress(),
deadline
);
} else {
swapTx = await router.swapExactTokensForTokens(
amountIn,
amountOutMin,
path,
await signer.getAddress(),
deadline
);
}
// 5. Wait for confirmation
const receipt = await swapTx.wait();
return receipt;
}
Multi-hop Trades and Path Construction
Uniswap's path parameter supports multi-hop routing. For example, [USDC, WETH, DAI] means routing through WETH. The frontend can query the optimal path using The Graph subgraph:
// Multi-hop path price calculation
async function getMultiHopAmountOut(router, amountIn, path) {
const amounts = await router.getAmountsOut(amountIn, path);
return amounts[amounts.length - 1];
}
// Path selection strategy
function findBestPath(amountIn, pairsGraph, tokenIn, tokenOut) {
// BFS search for paths with at most 3 hops
const paths = [];
const queue = [[tokenIn]];
while (queue.length > 0 && paths.length < 10) {
const currentPath = queue.shift();
const lastToken = currentPath[currentPath.length - 1];
if (lastToken === tokenOut && currentPath.length > 1) {
paths.push(currentPath);
continue;
}
if (currentPath.length >= 4) continue; // Limit maximum hops
const neighbors = pairsGraph[lastToken] || [];
for (const neighbor of neighbors) {
if (!currentPath.includes(neighbor)) {
queue.push([...currentPath, neighbor]);
}
}
}
return paths;
}
Adding and Removing Liquidity
Adding liquidity requires depositing both tokens in proportion to the current reserves. The Router's addLiquidity method automatically handles proportion differences and refunds any excess:
async function addLiquidity(
provider,
signer,
tokenA,
tokenB,
amountADesired,
amountBDesired
) {
const router = new ethers.Contract(ROUTER02, ROUTER_ABI, signer);
const tokenAContract = new ethers.Contract(tokenA, ERC20_ABI, signer);
const tokenBContract = new ethers.Contract(tokenB, ERC20_ABI, signer);
// Allowance check
const userAddress = await signer.getAddress();
for (const [token, amount] of [[tokenAContract, amountADesired], [tokenBContract, amountBDesired]]) {
const allowance = await token.allowance(userAddress, ROUTER02);
if (allowance.lt(amount)) {
const tx = await token.approve(ROUTER02, ethers.constants.MaxUint256);
await tx.wait();
}
}
// Calculate minimum accepted amounts (1% slippage tolerance)
const amountAMin = amountADesired.mul(99).div(100);
const amountBMin = amountBDesired.mul(99).div(100);
const deadline = Math.floor(Date.now() / 1000) + 60 * 20;
const tx = await router.addLiquidity(
tokenA,
tokenB,
amountADesired,
amountBDesired,
amountAMin,
amountBMin,
userAddress,
deadline
);
return await tx.wait();
}
When removing liquidity, the frontend needs to first get the user's LP Token balance, then call removeLiquidity:
async function removeLiquidity(provider, signer, tokenA, tokenB, liquidity) {
const router = new ethers.Contract(ROUTER02, ROUTER_ABI, signer);
const factory = new ethers.Contract(UNISWAP_FACTORY, FACTORY_ABI, provider);
const pairAddress = await factory.getPair(tokenA, tokenB);
const pair = new ethers.Contract(pairAddress, PAIR_ABI, signer);
// Approve Router to use LP Token
const userAddress = await signer.getAddress();
const allowance = await pair.allowance(userAddress, ROUTER02);
if (allowance.lt(liquidity)) {
const approveTx = await pair.approve(ROUTER02, ethers.constants.MaxUint256);
await approveTx.wait();
}
// Calculate the token amounts to be received upon removal
const [reserve0, reserve1] = await pair.getReserves();
const totalSupply = await pair.totalSupply();
const token0Address = await pair.token0();
const isTokenA0 = tokenA.toLowerCase() === token0Address.toLowerCase();
const reserveA = isTokenA0 ? reserve0 : reserve1;
const reserveB = isTokenA0 ? reserve1 : reserve0;
const amountAMin = liquidity.mul(reserveA).div(totalSupply).mul(99).div(100);
const amountBMin = liquidity.mul(reserveB).div(totalSupply).mul(99).div(100);
const deadline = Math.floor(Date.now() / 1000) + 60 * 20;
const tx = await router.removeLiquidity(
tokenA,
tokenB,
liquidity,
amountAMin,
amountBMin,
userAddress,
deadline
);
return await tx.wait();
}
Common Causes of Transaction Failures
Transaction failures are the most common UX issue in DeFi frontend development. Here are several typical scenarios:
1. Slippage Exceeds Tolerance
On-chain reserves may change between transaction submission and inclusion (due to frontrunning), causing the actual output to fall below amountOutMin, resulting in a transaction revert. The solution is for the frontend to refresh prices in real-time and recalculate amountOutMin before each transaction.
2. Insufficient Allowance
The user's previously approved allowance may have been consumed or granted to an outdated Router version. The frontend must check allowance every time and initiate an approve transaction when insufficient.
3. Expired Deadline
The deadline parameter is a Unix timestamp. If the transaction is included after the deadline, it will automatically revert. This issue is particularly prominent during network congestion. It's recommended to set a deadline of at least 20 minutes on the frontend.
4. Gas Estimation Failure
When a transaction is destined to revert, the node will reject the gas estimation request, resulting in an eth_estimateGas error on the frontend. You can catch this error and fall back to a static call (eth_call) to obtain the revert reason:
async function estimateGasWithFallback(contract, method, args, overrides) {
try {
return await contract.estimateGas[method](...args, overrides);
} catch (estimateError) {
// Fall back to eth_call to get the revert reason
try {
await contract.callStatic[method](...args, overrides);
} catch (callError) {
if (callError.reason) {
throw new Error(`Transaction will fail: ${callError.reason}`);
}
throw callError;
}
throw estimateError;
}
}
Summary
Uniswap V2's frontend development covers the core patterns of DeFi interaction: contract data reading, price calculation, transaction construction, allowance management, and state synchronization. These patterns are reused in the frontends of almost all DeFi protocols.
The greatest challenge in DeFi frontend development lies in balancing state consistency with user experience. On-chain states change at any moment, and the frontend must strike a balance between real-time accuracy—the displayed price may become invalid in the next second. Reasonably setting slippage tolerance, deadline, and minimum output amount are the three key parameters for ensuring transaction success rates. At the same time, diagnosing transaction failures and providing user feedback is equally important—a good DeFi frontend should give clear reasons when a transaction fails, rather than a vague "Transaction Failed" popup.
