Skip to main content

Part 5: Frontend Integration - Build DEX User Interface

📋 Tujuan Pembelajaran

Setelah menyelesaikan bagian ini, Anda akan mampu:

  • ✅ Membangun frontend modern untuk DEX menggunakan React + Vite
  • ✅ Mengintegrasikan smart contract dengan UI menggunakan Wagmi dan Viem
  • ✅ Mengimplementasikan wallet connection dengan RainbowKit
  • ✅ Membuat swap interface dengan real-time price calculation
  • ✅ Membuat liquidity management interface
  • ✅ Menampilkan pool statistics dan price charts
  • ✅ Menangani transaction history dari blockchain events
  • ✅ Deploy frontend ke production

🎯 Apa yang Akan Kita Bangun?

Kita akan membangun SimpleDEX UI - sebuah aplikasi web modern dengan fitur:

Fitur Utama

  • 🔄 Token Swapping: Tukar CAMP ↔ USDC dengan real-time price calculation
  • 💧 Liquidity Management: Add/remove liquidity dengan auto-calculation
  • 📊 Pool Analytics: TVL, volume, APR, dan price charts
  • 📜 Transaction History: Real-time tracking dari blockchain events
  • 👛 Wallet Integration: Connect dengan MetaMask, WalletConnect, dll
  • Real-time Updates: Live data menggunakan contract event listeners

Tech Stack

  • Framework: React 19.1.0 + Vite 6.3.5 + TypeScript 5.8.3
  • Blockchain: Wagmi 2.15.6 + Viem 2.31.3 (Ethereum library)
  • Wallet: RainbowKit 2.2.8 (wallet connection UI)
  • State: TanStack React Query 5.80.10 (caching & state)
  • Styling: TailwindCSS 4.1.10
  • Charts: Recharts 2.15.3 (data visualization)
  • Icons: Lucide React 0.518.0
  • Notifications: React Hot Toast 2.5.2

📁 Struktur Project

simple-dex-ui/
├── public/
│ └── dex-logo.png # Logo aplikasi
├── src/
│ ├── components/
│ │ ├── DEXContainer.tsx # Container utama dengan tabs
│ │ ├── Header.tsx # Header dengan wallet connection
│ │ ├── SwapInterface.tsx # UI untuk swap tokens
│ │ ├── LiquidityInterface.tsx # UI untuk add/remove liquidity
│ │ ├── PoolStats.tsx # Statistik pool (TVL, volume, APR)
│ │ ├── PriceChart.tsx # Real-time price chart
│ │ └── TransactionHistory.tsx # Riwayat transaksi
│ ├── hooks/
│ │ ├── useTokenBalance.ts # Hook untuk balance & allowance
│ │ ├── usePoolData.ts # Hook untuk data pool
│ │ ├── useSwap.ts # Hook untuk swap operations
│ │ └── useLiquidity.ts # Hook untuk liquidity operations
│ ├── constants/
│ │ ├── ERC20_ABI.json # ABI untuk ERC20
│ │ ├── SIMPLE_DEX_ABI.json # ABI untuk SimpleDEX
│ │ └── index.tsx # Contract addresses & config
│ ├── types/
│ │ └── defi.ts # TypeScript types
│ ├── utils/
│ │ ├── formatters.ts # Formatting utilities
│ │ └── calculations.ts # AMM calculations
│ ├── App.tsx # Root component dengan providers
│ ├── main.tsx # Entry point
│ └── index.css # Global styles
├── .env.local # Environment variables
├── package.json # Dependencies
├── vite.config.ts # Vite configuration
├── tsconfig.json # TypeScript config
└── tailwind.config.js # TailwindCSS config

🚀 Part 1: Setup Project

Step 1: Create Vite Project

# Create project dengan Vite
npm create vite@latest simple-dex-ui -- --template react-ts

# Masuk ke folder project
cd simple-dex-ui

Step 2: Install Dependencies

# Install semua dependencies yang diperlukan
npm install @rainbow-me/rainbowkit@^2.2.8 wagmi@^2.15.6 viem@^2.31.3 @tanstack/react-query@^5.80.10
npm install react@^19.1.0 react-dom@^19.1.0 react-hot-toast@^2.5.2 lucide-react@^0.518.0 recharts@^2.15.3
npm install -D tailwindcss@^4.1.10 @tailwindcss/vite@^4.1.10 typescript@~5.8.3 vite@^6.3.5

Penjelasan Dependencies:

PackageFungsi
@rainbow-me/rainbowkitUI untuk wallet connection (MetaMask, WalletConnect, dll)
wagmiReact hooks untuk Ethereum (read/write contracts)
viemTypeScript Ethereum library (modern replacement untuk ethers.js)
@tanstack/react-queryState management & caching untuk data fetching
react-hot-toastNotifikasi untuk transaction success/error
lucide-reactIcon library (modern, lightweight)
rechartsLibrary untuk charts dan visualisasi data
tailwindcssUtility-first CSS framework

Step 3: Configure Vite

Buat file vite.config.ts:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
plugins: [react(), tailwindcss()],
})

Step 4: Configure TailwindCSS (Opsional)

Note: Dengan TailwindCSS v4, tidak perlu file tailwind.config.js. Semua konfigurasi ada di index.css menggunakan CSS variables.

Step 5: Setup Environment Variables (Opsional)

Note: Dalam implementasi ini, contract addresses sudah hardcoded di src/constants/index.tsx. Jika ingin menggunakan environment variables, buat file .env.local:

# WalletConnect Project ID (dapatkan dari https://cloud.walletconnect.com)
VITE_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id

Contract addresses yang digunakan (Lisk Sepolia Testnet):

  • SimpleDEX: 0x70bDD0f7e01DEe803147ead041dE23a531A71CBf
  • Campus Coin (CAMP): 0xEBAa841c5f97Ff097e61eea151dFA03640A6CC78
  • Mock USDC: 0x786Ca7D3a2E53f0d5F7bB6848E03b60Dae9a3719

💡 Cara Mendapatkan WalletConnect Project ID:

  1. Buka https://cloud.walletconnect.com
  2. Sign up / Login
  3. Create New Project → masukkan nama project
  4. Copy Project ID dan ganti YOUR_WALLETCONNECT_PROJECT_ID di App.tsx

🎨 Part 2: Setup Global Styles

Buat file src/index.css:

@import url("https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap");
@import "tailwindcss";

:root {
/* Black-White Gradient Colors */
--pure-black: #000000;
--dark-black: #0a0a0a;
--medium-black: #1a1a1a;
--light-black: #2a2a2a;
--dark-gray: #3a3a3a;
--medium-gray: #6a6a6a;
--light-gray: #9a9a9a;
--off-white: #f5f5f5;
--pure-white: #ffffff;

/* DeFi specific colors (subtle grayscale versions) */
--success-green: #10b981;
--success-green-light: rgba(16, 185, 129, 0.15);
--warning-yellow: #f59e0b;
--warning-yellow-light: rgba(245, 158, 11, 0.15);
--error-red: #ef4444;
--error-red-light: rgba(239, 68, 68, 0.15);

/* Derived colors for UI */
--white-light: rgba(255, 255, 255, 0.9);
--white-medium: rgba(255, 255, 255, 0.6);
--white-dark: rgba(255, 255, 255, 0.3);
--white-very-dark: rgba(255, 255, 255, 0.1);
--black-light: rgba(0, 0, 0, 0.3);
--black-medium: rgba(0, 0, 0, 0.5);
--black-dark: rgba(0, 0, 0, 0.7);

/* UI System Colors */
--bg-primary: var(--pure-black);
--bg-secondary: var(--dark-black);
--bg-tertiary: rgba(26, 26, 26, 0.8);
--text-primary: var(--pure-white);
--text-secondary: var(--off-white);
--text-muted: rgba(255, 255, 255, 0.6);
--border-primary: rgba(255, 255, 255, 0.2);
--border-secondary: rgba(255, 255, 255, 0.1);
}

* {
box-sizing: border-box;
}

body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, var(--pure-black) 0%, var(--dark-black) 25%, var(--medium-black) 50%, var(--dark-black) 75%, var(--pure-black) 100%);
min-height: 100vh;
margin: 0;
padding: 0;
color: var(--text-primary);
line-height: 1.6;
font-weight: 400;
}

/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}

::-webkit-scrollbar-track {
background: var(--pure-black);
}

::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--pure-white) 0%, var(--light-gray) 100%);
border-radius: 3px;
}

::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, var(--off-white) 0%, var(--medium-gray) 100%);
}

/* Glass morphism effect */
.glass {
background: rgba(26, 26, 26, 0.5);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}

.glass-dark {
background: rgba(10, 10, 10, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.15);
}

/* Black-White brand gradients */
.gradient-monad-primary {
background: linear-gradient(135deg, var(--pure-white) 0%, var(--light-gray) 50%, var(--medium-gray) 100%);
}

.gradient-monad-secondary {
background: linear-gradient(135deg, var(--dark-black) 0%, var(--medium-black) 50%, var(--pure-black) 100%);
}

.gradient-success {
background: linear-gradient(135deg, var(--success-green) 0%, #059669 100%);
}

.text-gradient-monad {
background: linear-gradient(135deg, var(--pure-white) 0%, var(--off-white) 50%, var(--light-gray) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}

/* Animations */
@keyframes shimmer {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}

.shimmer {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.03) 0px, rgba(255, 255, 255, 0.1) 40px, rgba(255, 255, 255, 0.03) 80px);
background-size: 400px;
animation: shimmer 1.5s ease-in-out infinite;
}

@keyframes pulse-success {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.02);
}
}

.pulse-success {
animation: pulse-success 2s ease-in-out infinite;
}

@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-4px);
}
}

.float-animation {
animation: float 3s ease-in-out infinite;
}

@keyframes glow-white {
0%, 100% {
box-shadow: 0 0 5px rgba(255, 255, 255, 0.2);
}
50% {
box-shadow: 0 0 20px rgba(255, 255, 255, 0.4), 0 0 30px rgba(255, 255, 255, 0.2);
}
}

@keyframes glow-gray {
0%, 100% {
box-shadow: 0 0 5px rgba(255, 255, 255, 0.15);
}
50% {
box-shadow: 0 0 20px rgba(255, 255, 255, 0.3), 0 0 30px rgba(255, 255, 255, 0.15);
}
}

@keyframes glow-success {
0%, 100% {
box-shadow: 0 0 5px rgba(16, 185, 129, 0.3);
}
50% {
box-shadow: 0 0 20px rgba(16, 185, 129, 0.6), 0 0 30px rgba(16, 185, 129, 0.4);
}
}

.glow-purple {
animation: glow-white 2s ease-in-out infinite;
}

.glow-berry {
animation: glow-gray 2s ease-in-out infinite;
}

.glow-success {
animation: glow-success 2s ease-in-out infinite;
}

/* Button hover effects */
.btn-primary {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}

.btn-primary:active {
transform: translateY(0);
}

/* Input styles */
.input-primary {
background: rgba(14, 16, 15, 0.5);
border: 1px solid rgba(251, 250, 249, 0.2);
color: var(--text-primary);
transition: all 0.3s ease;
}

.input-primary:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
}

/* Card hover effects */
.card-hover {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
}

/* Loading spinner */
.spinner {
border: 2px solid rgba(255, 255, 255, 0.2);
border-top: 2px solid var(--pure-white);
border-radius: 50%;
animation: spin 1s linear infinite;
}

@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

/* Custom number input */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}

input[type="number"] {
appearance: textfield;
-moz-appearance: textfield;
}

📦 Part 3: Constants & Types

Step 1: ERC20 ABI

Buat file src/constants/ERC20_ABI.json:

[
{
"inputs": [{"name": "account", "type": "address"}],
"name": "balanceOf",
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{"name": "owner", "type": "address"},
{"name": "spender", "type": "address"}
],
"name": "allowance",
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{"name": "spender", "type": "address"},
{"name": "amount", "type": "uint256"}
],
"name": "approve",
"outputs": [{"name": "", "type": "bool"}],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "decimals",
"outputs": [{"name": "", "type": "uint8"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "symbol",
"outputs": [{"name": "", "type": "string"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "name",
"outputs": [{"name": "", "type": "string"}],
"stateMutability": "view",
"type": "function"
}
]

Step 2: SimpleDEX ABI

Buat file src/constants/SIMPLE_DEX_ABI.json:

[
{
"inputs": [
{"name": "amountA", "type": "uint256"},
{"name": "amountB", "type": "uint256"}
],
"name": "addLiquidity",
"outputs": [{"name": "liquidity", "type": "uint256"}],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [{"name": "liquidity", "type": "uint256"}],
"name": "removeLiquidity",
"outputs": [
{"name": "amountA", "type": "uint256"},
{"name": "amountB", "type": "uint256"}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{"name": "amountAIn", "type": "uint256"},
{"name": "minAmountBOut", "type": "uint256"}
],
"name": "swapAforB",
"outputs": [{"name": "amountBOut", "type": "uint256"}],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{"name": "amountBIn", "type": "uint256"},
{"name": "minAmountAOut", "type": "uint256"}
],
"name": "swapBforA",
"outputs": [{"name": "amountAOut", "type": "uint256"}],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "getPoolInfo",
"outputs": [
{"name": "reserveA", "type": "uint256"},
{"name": "reserveB", "type": "uint256"},
{"name": "totalLiquidity", "type": "uint256"},
{"name": "price", "type": "uint256"}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "getPrice",
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{"name": "amountIn", "type": "uint256"},
{"name": "reserveIn", "type": "uint256"},
{"name": "reserveOut", "type": "uint256"}
],
"name": "getAmountOut",
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "pure",
"type": "function"
},
{
"inputs": [],
"name": "reserveA",
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "reserveB",
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "FEE_PERCENT",
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "FEE_DENOMINATOR",
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"anonymous": false,
"inputs": [
{"indexed": true, "name": "user", "type": "address"},
{"indexed": false, "name": "amountAIn", "type": "uint256"},
{"indexed": false, "name": "amountBOut", "type": "uint256"}
],
"name": "Swap",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{"indexed": true, "name": "provider", "type": "address"},
{"indexed": false, "name": "amountA", "type": "uint256"},
{"indexed": false, "name": "amountB", "type": "uint256"},
{"indexed": false, "name": "liquidity", "type": "uint256"}
],
"name": "LiquidityAdded",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{"indexed": true, "name": "provider", "type": "address"},
{"indexed": false, "name": "amountA", "type": "uint256"},
{"indexed": false, "name": "amountB", "type": "uint256"},
{"indexed": false, "name": "liquidity", "type": "uint256"}
],
"name": "LiquidityRemoved",
"type": "event"
}
]

Step 3: Constants Configuration

Buat file src/constants/index.tsx:

/* eslint-disable react-refresh/only-export-components */
import SIMPLE_DEX_ABI_JSON from "./SIMPLE_DEX_ABI.json"
import ERC20_ABI_JSON from "./ERC20_ABI.json"

export const SIMPLE_DEX_ABI = SIMPLE_DEX_ABI_JSON;
export const ERC20_ABI = ERC20_ABI_JSON;

// Contract addresses (update dengan alamat yang sudah deployed)
export const CONTRACTS = {
SIMPLE_DEX: "0x70bDD0f7e01DEe803147ead041dE23a531A71CBf",
CAMPUS_COIN: "0xEBAa841c5f97Ff097e61eea151dFA03640A6CC78",
MOCK_USDC: "0x786Ca7D3a2E53f0d5F7bB6848E03b60Dae9a3719",
} as const;

// Token configurations
export const TOKENS = {
CAMP: {
address: CONTRACTS.CAMPUS_COIN,
symbol: "CAMP",
name: "Campus Coin",
decimals: 18,
logo: "🏛️",
},
USDC: {
address: CONTRACTS.MOCK_USDC,
symbol: "USDC",
name: "Mock USDC",
decimals: 6,
logo: "💵",
},
} as const;

// DEX configuration
export const DEX_CONFIG = {
FEE_PERCENT: 0.3, // 0.3%
SLIPPAGE_TOLERANCE: 0.5, // 0.5%
DEADLINE: 20, // 20 minutes
} as const;

Step 4: TypeScript Types

Buat file src/types/defi.ts:

export interface Token {
address: string;
symbol: string;
name: string;
decimals: number;
logo: string;
}

export interface PoolInfo {
reserveA: bigint;
reserveB: bigint;
totalLiquidity: bigint;
price: bigint;
}

export interface SwapData {
tokenIn: Token;
tokenOut: Token;
amountIn: string;
amountOut: string;
priceImpact: number;
fee: string;
}

export interface LiquidityData {
tokenA: Token;
tokenB: Token;
amountA: string;
amountB: string;
lpTokens: string;
shareOfPool: number;
}

export interface TransactionHistory {
id: string;
type: 'swap' | 'add_liquidity' | 'remove_liquidity';
hash: string;
timestamp: number;
user: string;
tokenA?: {
symbol: string;
amount: string;
};
tokenB?: {
symbol: string;
amount: string;
};
lpTokens?: string;
status: 'pending' | 'success' | 'failed';
}

export interface PriceData {
timestamp: number;
price: number;
volume24h: number;
tvl: number;
}

export interface UserPosition {
lpTokenBalance: bigint;
shareOfPool: number;
tokenAAmount: bigint;
tokenBAmount: bigint;
estimatedValue: number;
}

⚙️ Part 4: Utility Functions

Step 1: Formatting Utilities

Buat file src/utils/formatters.ts:

import { TOKENS } from '../constants';

/**
* Check if amount is valid for transaction
*/
export const isValidAmount = (amount: string): boolean => {
if (!amount || amount === '0' || amount === '.') return false;
const num = parseFloat(amount);
return !isNaN(num) && num > 0 && isFinite(num);
};

/**
* Format angka dengan thousands separator
*/
export const formatNumber = (
value: number | string,
decimals: number = 2
): string => {
const num = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(num)) return '0';

return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
}).format(num);
};

/**
* Format token amount dengan symbol
*/
export const formatTokenAmount = (
amount: bigint | string,
tokenSymbol: keyof typeof TOKENS,
decimals?: number
): string => {
const token = TOKENS[tokenSymbol];
const tokenDecimals = decimals || token.decimals;

const value = typeof amount === 'string'
? parseFloat(amount)
: Number(amount) / Math.pow(10, tokenDecimals);

return `${formatNumber(value, 4)} ${token.symbol}`;
};

/**
* Format nilai USD
*/
export const formatUSD = (value: number | string): string => {
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num)) return '$0.00'

return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(num)
}

/**
* Format percentage
*/
export const formatPercentage = (
value: number,
decimals: number = 2
): string => {
return `${formatNumber(value, decimals)}%`
}

/**
* Format angka besar dengan K, M, B suffix
*/
export const formatLargeNumber = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toFixed(2)
}

/**
* Format address (0x1234...5678)
*/
export const formatAddress = (address: string): string => {
if (!address) return ''
return `${address.slice(0, 6)}...${address.slice(-4)}`
}

/**
* Format timestamp menjadi human-readable
*/
export const formatTime = (timestamp: number): string => {
const now = Date.now()
const diff = now - timestamp * 1000

const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)

if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return `${seconds}s ago`
}

/**
* Parse user input menjadi BigInt dengan decimals
*/
export const parseTokenAmount = (
amount: string,
decimals: number
): bigint => {
if (!isValidAmount(amount)) return 0n

// Split integer dan decimal parts
const [integer, decimal = ''] = amount.split('.')

// Pad atau truncate decimal part
const paddedDecimal = decimal.padEnd(decimals, '0').slice(0, decimals)

// Combine dan convert ke BigInt
const combined = integer + paddedDecimal
return BigInt(combined)
}

/**
* Format BigInt menjadi decimal string
*/
export const formatBigInt = (
value: bigint,
decimals: number,
displayDecimals: number = 4
): string => {
if (value === 0n) return '0'

const str = value.toString().padStart(decimals + 1, '0')
const integerPart = str.slice(0, -decimals) || '0'
const decimalPart = str.slice(-decimals).slice(0, displayDecimals)

// Remove trailing zeros
const trimmedDecimal = decimalPart.replace(/0+$/, '')

if (trimmedDecimal === '') return integerPart
return `${integerPart}.${trimmedDecimal}`
}

/**
* Cek apakah amount melebihi balance
*/
export const exceedsBalance = (
amount: string,
balance: bigint,
decimals: number
): boolean => {
if (!isValidAmount(amount)) return false
const amountBigInt = parseTokenAmount(amount, decimals)
return amountBigInt > balance
}

/**
* Format balance dengan decimals dan symbol
*/
export const formatBalance = (
balance: bigint,
decimals: number,
symbol: string,
maxDecimals: number = 4
): string => {
const formatted = formatBigInt(balance, decimals, maxDecimals)
return `${formatted} ${symbol}`
}

Step 2: AMM Calculation Utilities

Buat file src/utils/calculations.ts:

/**
* Calculate output amount untuk swap menggunakan constant product formula
* Formula: amountOut = (amountIn * reserveOut) / (reserveIn + amountIn)
* Dengan fee: amountInWithFee = amountIn * (1000 - fee) / 1000
*/
export const calculateSwapOutput = (
amountIn: bigint,
reserveIn: bigint,
reserveOut: bigint,
feePercent: number = 3 // 0.3% = 3/1000
): bigint => {
if (amountIn === 0n || reserveIn === 0n || reserveOut === 0n) {
return 0n
}

// Apply fee: 0.3% fee means user gets 99.7% of input
const FEE_DENOMINATOR = 1000n
const fee = BigInt(feePercent)
const amountInWithFee = (amountIn * (FEE_DENOMINATOR - fee)) / FEE_DENOMINATOR

// Constant product formula
const numerator = amountInWithFee * reserveOut
const denominator = reserveIn + amountInWithFee

return numerator / denominator
}

/**
* Calculate price impact dari swap
* Price impact = |newPrice - oldPrice| / oldPrice * 100
*/
export const calculatePriceImpact = (
amountIn: bigint,
reserveIn: bigint,
reserveOut: bigint
): number => {
if (reserveIn === 0n || reserveOut === 0n) return 0

// Old price (reserveOut / reserveIn)
const oldPrice = Number(reserveOut * 10000n / reserveIn) / 10000

// New reserves after swap
const newReserveIn = reserveIn + amountIn
const amountOut = calculateSwapOutput(amountIn, reserveIn, reserveOut)
const newReserveOut = reserveOut - amountOut

// New price
const newPrice = Number(newReserveOut * 10000n / newReserveIn) / 10000

// Price impact percentage
const impact = Math.abs((newPrice - oldPrice) / oldPrice) * 100

return impact
}

/**
* Calculate minimum amount out dengan slippage tolerance
*/
export const calculateMinAmountOut = (
amountOut: bigint,
slippageTolerance: number // e.g., 0.5 for 0.5%
): bigint => {
const slippage = BigInt(Math.floor(slippageTolerance * 100))
return (amountOut * (10000n - slippage)) / 10000n
}

/**
* Calculate optimal liquidity amounts berdasarkan pool ratio
*/
export const calculateOptimalLiquidityAmounts = (
amountADesired: bigint,
amountBDesired: bigint,
reserveA: bigint,
reserveB: bigint
): { amountA: bigint; amountB: bigint } => {
// Jika pool kosong, gunakan amounts yang diinginkan
if (reserveA === 0n || reserveB === 0n) {
return { amountA: amountADesired, amountB: amountBDesired }
}

// Calculate optimal amountB berdasarkan amountA
const optimalAmountB = (amountADesired * reserveB) / reserveA

if (optimalAmountB <= amountBDesired) {
return { amountA: amountADesired, amountB: optimalAmountB }
} else {
// Calculate optimal amountA berdasarkan amountB
const optimalAmountA = (amountBDesired * reserveA) / reserveB
return { amountA: optimalAmountA, amountB: amountBDesired }
}
}

/**
* Calculate LP tokens yang akan di-mint
* First liquidity: sqrt(amountA * amountB)
* Subsequent: min(amountA * totalSupply / reserveA, amountB * totalSupply / reserveB)
*/
export const calculateLPTokens = (
amountA: bigint,
amountB: bigint,
reserveA: bigint,
reserveB: bigint,
totalSupply: bigint
): bigint => {
if (totalSupply === 0n) {
// First liquidity provider
return sqrt(amountA * amountB)
}

// Subsequent liquidity providers
const liquidityA = (amountA * totalSupply) / reserveA
const liquidityB = (amountB * totalSupply) / reserveB

return liquidityA < liquidityB ? liquidityA : liquidityB
}

/**
* Calculate token amounts dari LP tokens saat remove liquidity
*/
export const calculateTokenAmountsFromLP = (
lpTokens: bigint,
reserveA: bigint,
reserveB: bigint,
totalSupply: bigint
): { amountA: bigint; amountB: bigint } => {
if (totalSupply === 0n) {
return { amountA: 0n, amountB: 0n }
}

const amountA = (lpTokens * reserveA) / totalSupply
const amountB = (lpTokens * reserveB) / totalSupply

return { amountA, amountB }
}

/**
* Calculate share of pool dari LP tokens
*/
export const calculatePoolShare = (
lpTokens: bigint,
totalSupply: bigint
): number => {
if (totalSupply === 0n) return 0
return Number((lpTokens * 10000n) / totalSupply) / 100
}

/**
* Calculate APR dari trading fees
* APR = (dailyVolume * feePercent * 365) / totalLiquidity * 100
*/
export const calculateAPR = (
totalLiquidity: number,
dailyVolume: number,
feePercent: number = 0.3
): number => {
if (totalLiquidity === 0) return 0

const dailyFees = dailyVolume * (feePercent / 100)
const annualFees = dailyFees * 365

return (annualFees / totalLiquidity) * 100
}

/**
* Integer square root untuk BigInt (untuk LP token calculation)
*/
export const sqrt = (value: bigint): bigint => {
if (value < 0n) {
throw new Error('Square root of negative numbers is not supported')
}

if (value < 2n) {
return value
}

// Newton's method
let x = value
let y = (x + 1n) / 2n

while (y < x) {
x = y
y = (x + value / x) / 2n
}

return x
}

/**
* Calculate USD value dari token amount
*/
export const calculateUSDValue = (
tokenAmount: bigint,
tokenDecimals: number,
pricePerToken: number
): number => {
const amount = Number(tokenAmount) / Math.pow(10, tokenDecimals)
return amount * pricePerToken
}

🧮 Penjelasan Fungsi Matematika:

1. calculateSwapOutput - Constant Product Formula

Input: 100 CAMP
Reserve CAMP: 1000
Reserve USDC: 2000

Step 1: Apply fee (0.3%)
amountInWithFee = 100 * (1000 - 3) / 1000 = 99.7 CAMP

Step 2: Calculate output
amountOut = (99.7 * 2000) / (1000 + 99.7)
= 199,400 / 1099.7
= 181.34 USDC

2. calculatePriceImpact

Old price = 2000 / 1000 = 2.0 USDC per CAMP
New reserves = 1099.7 CAMP, 1818.66 USDC
New price = 1818.66 / 1099.7 = 1.654 USDC per CAMP

Price impact = |1.654 - 2.0| / 2.0 * 100 = 17.3%

3. calculateLPTokens - First Liquidity

amountA = 1000 CAMP
amountB = 2000 USDC

LP tokens = sqrt(1000 * 2000) = sqrt(2,000,000) = 1414.21

🔌 Part 5: Custom React Hooks

Hook 1: useTokenBalance

Buat file src/hooks/useTokenBalance.ts:

import { useAccount, useReadContract } from 'wagmi'
import { ERC20_ABI, CONTRACTS } from '../constants'
import type { Token } from '../types/defi'

export const useTokenBalance = (token?: Token) => {
const { address } = useAccount()

// Get token balance
const {
data: balance,
isLoading: isLoadingBalance,
refetch: refetchBalance
} = useReadContract({
address: token?.address,
abi: ERC20_ABI,
functionName: 'balanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!token,
refetchInterval: 30000, // Refetch setiap 30 detik
}
})

// Get allowance untuk SimpleDEX contract
const {
data: allowance,
isLoading: isLoadingAllowance,
refetch: refetchAllowance
} = useReadContract({
address: token?.address,
abi: ERC20_ABI,
functionName: 'allowance',
args: address ? [address, CONTRACTS.SIMPLE_DEX] : undefined,
query: {
enabled: !!address && !!token,
refetchInterval: 30000,
}
})

return {
balance: (balance as bigint) ?? 0n,
allowance: (allowance as bigint) ?? 0n,
isLoading: isLoadingBalance || isLoadingAllowance,
refetch: () => {
refetchBalance()
refetchAllowance()
}
}
}

📝 Cara Kerja:

  • useReadContract membaca data dari blockchain tanpa gas fee
  • balanceOf: Mendapatkan balance token user
  • allowance: Cek berapa banyak SimpleDEX boleh menggunakan token user
  • refetchInterval: Auto-update setiap 30 detik
  • Return balance dan allowance sebagai BigInt

Hook 2: usePoolData

Buat file src/hooks/usePoolData.ts:

import { useReadContract } from 'wagmi'
import { SIMPLE_DEX_ABI, CONTRACTS } from '../constants'
import type { PoolInfo } from '../types/defi'

export const usePoolData = () => {
// Get pool info dari SimpleDEX contract
const {
data: poolData,
isLoading,
refetch
} = useReadContract({
address: CONTRACTS.SIMPLE_DEX,
abi: SIMPLE_DEX_ABI,
functionName: 'getPoolInfo',
query: {
refetchInterval: 15000, // Refetch setiap 15 detik untuk real-time data
}
})

// Parse data menjadi PoolInfo object
const poolInfo: PoolInfo | null = poolData ? {
reserveA: (poolData as any)[0] as bigint,
reserveB: (poolData as any)[1] as bigint,
totalLiquidity: (poolData as any)[2] as bigint,
price: (poolData as any)[3] as bigint,
} : null

// Calculate current price (USDC per CAMP)
const currentPrice = poolInfo && poolInfo.reserveA > 0n
? Number(poolInfo.reserveB * 1000000n / poolInfo.reserveA) / 1000000
: 0

return {
poolInfo,
currentPrice,
isLoading,
refetch
}
}

📊 Pool Info Structure:

{
reserveA: 1000000000000000000000n, // 1000 CAMP (18 decimals)
reserveB: 2000000000n, // 2000 USDC (6 decimals)
totalLiquidity: 1414213562373095048n, // 1414.21 LP tokens
price: 2000000n // 2.0 USDC per CAMP
}

Hook 3: useSwap

Buat file src/hooks/useSwap.ts:

import { useState } from 'react'
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { toast } from 'react-hot-toast'
import { SIMPLE_DEX_ABI, ERC20_ABI, CONTRACTS, TOKENS, DEX_CONFIG } from '../constants'
import { usePoolData } from './usePoolData'
import {
parseTokenAmount,
formatBigInt,
calculateSwapOutput,
calculatePriceImpact,
calculateMinAmountOut
} from '../utils'
import type { SwapData, Token } from '../types/defi'
import { config } from '../App'

export const useSwap = () => {
const { address } = useAccount()
const { poolInfo } = usePoolData()
const { writeContractAsync } = useWriteContract()
const [isSwapping, setIsSwapping] = useState(false)

/**
* Calculate swap details (output, price impact, fee)
*/
const calculateSwap = (
amountIn: string,
tokenIn: Token,
tokenOut: Token
): SwapData | null => {
if (!poolInfo || !amountIn || parseFloat(amountIn) === 0) {
return null
}

// Parse input amount
const amountInBigInt = parseTokenAmount(amountIn, tokenIn.decimals)

// Determine reserves berdasarkan swap direction
const isTokenAToB = tokenIn.symbol === TOKENS.CAMP.symbol
const reserveIn = isTokenAToB ? poolInfo.reserveA : poolInfo.reserveB
const reserveOut = isTokenAToB ? poolInfo.reserveB : poolInfo.reserveA

// Calculate output menggunakan AMM formula
const amountOutBigInt = calculateSwapOutput(
amountInBigInt,
reserveIn,
reserveOut,
3 // 0.3% fee
)

const amountOut = formatBigInt(amountOutBigInt, tokenOut.decimals, 6)

// Calculate price impact
const priceImpact = calculatePriceImpact(amountInBigInt, reserveIn, reserveOut)

// Calculate fee
const fee = (parseFloat(amountIn) * DEX_CONFIG.FEE_PERCENT / 100).toFixed(6)

return {
tokenIn,
tokenOut,
amountIn,
amountOut,
priceImpact,
fee: `${fee} ${tokenIn.symbol}`
}
}

/**
* Ensure token approval sebelum swap
*/
const ensureTokenApproval = async (
token: Token,
amount: bigint
): Promise<void> => {
try {
const hash = await writeContractAsync({
address: token.address,
abi: ERC20_ABI,
functionName: 'approve',
args: [CONTRACTS.SIMPLE_DEX, amount]
})

toast.loading('Approving token...', { id: 'approve' })

// Wait for approval confirmation
await waitForTransactionReceipt(config, { hash })

toast.success('Token approved!', { id: 'approve' })
} catch (error: any) {
toast.error(`Approval failed: ${error.message}`, { id: 'approve' })
throw error
}
}

/**
* Execute swap transaction
*/
const executeSwap = async (swapData: SwapData): Promise<void> => {
if (!address) {
toast.error('Please connect your wallet')
return
}

setIsSwapping(true)

try {
// Parse amounts
const amountIn = parseTokenAmount(swapData.amountIn, swapData.tokenIn.decimals)
const amountOut = parseTokenAmount(swapData.amountOut, swapData.tokenOut.decimals)

// Calculate minimum output dengan slippage protection
const minAmountOut = calculateMinAmountOut(
amountOut,
DEX_CONFIG.SLIPPAGE_TOLERANCE
)

// Step 1: Ensure token approval
toast.loading('Checking token approval...', { id: 'swap' })
await ensureTokenApproval(swapData.tokenIn, amountIn)

// Step 2: Execute swap
const isTokenAToB = swapData.tokenIn.symbol === TOKENS.CAMP.symbol
const functionName = isTokenAToB ? 'swapAforB' : 'swapBforA'

toast.loading('Executing swap...', { id: 'swap' })

const hash = await writeContractAsync({
address: CONTRACTS.SIMPLE_DEX,
abi: SIMPLE_DEX_ABI,
functionName,
args: [amountIn, minAmountOut]
})

// Step 3: Wait for confirmation
toast.loading('Waiting for confirmation...', { id: 'swap' })

await waitForTransactionReceipt(config, { hash })

toast.success(
`Swapped ${swapData.amountIn} ${swapData.tokenIn.symbol} for ${swapData.amountOut} ${swapData.tokenOut.symbol}!`,
{ id: 'swap', duration: 5000 }
)
} catch (error: any) {
console.error('Swap error:', error)
toast.error(
error.message.includes('rejected')
? 'Transaction rejected'
: `Swap failed: ${error.shortMessage || error.message}`,
{ id: 'swap' }
)
} finally {
setIsSwapping(false)
}
}

return {
calculateSwap,
executeSwap,
isSwapping
}
}

// Helper function (import dari wagmi/actions)
async function waitForTransactionReceipt(
config: any,
{ hash }: { hash: `0x${string}` }
) {
return new Promise((resolve, reject) => {
const checkReceipt = async () => {
try {
// Implementasi sederhana - gunakan wagmi's waitForTransactionReceipt
const receipt = await config.publicClient.waitForTransactionReceipt({ hash })
resolve(receipt)
} catch (error) {
reject(error)
}
}
checkReceipt()
})
}

🔄 Swap Flow:

1. User memasukkan amount → calculateSwap() dipanggil
2. Calculate output menggunakan x*y=k formula
3. Calculate price impact dan fee
4. User klik "Swap" → executeSwap() dipanggil
5. Check/request token approval
6. Execute swap transaction
7. Wait for confirmation
8. Show success/error notification

Hook 4: useLiquidity

Buat file src/hooks/useLiquidity.ts:

import { useState } from 'react'
import { useAccount, useWriteContract, useReadContract } from 'wagmi'
import { toast } from 'react-hot-toast'
import { SIMPLE_DEX_ABI, ERC20_ABI, CONTRACTS, TOKENS } from '../constants'
import { usePoolData } from './usePoolData'
import {
parseTokenAmount,
formatBigInt,
calculateOptimalLiquidityAmounts,
calculateLPTokens,
calculateTokenAmountsFromLP,
calculatePoolShare
} from '../utils'
import type { LiquidityData, UserPosition } from '../types/defi'
import { config } from '../App'

export const useLiquidity = () => {
const { address } = useAccount()
const { poolInfo } = usePoolData()
const { writeContractAsync } = useWriteContract()
const [isLoading, setIsLoading] = useState(false)

// Get user's LP token balance
const { data: lpBalance } = useReadContract({
address: CONTRACTS.SIMPLE_DEX,
abi: ERC20_ABI,
functionName: 'balanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address,
refetchInterval: 30000
}
})

/**
* Calculate add liquidity details
*/
const calculateAddLiquidity = (
amountA: string,
amountB: string
): LiquidityData | null => {
if (!poolInfo || !amountA || !amountB) {
return null
}

// Parse amounts
const amountABigInt = parseTokenAmount(amountA, TOKENS.CAMP.decimals)
const amountBBigInt = parseTokenAmount(amountB, TOKENS.USDC.decimals)

// Calculate optimal amounts berdasarkan pool ratio
const optimal = calculateOptimalLiquidityAmounts(
amountABigInt,
amountBBigInt,
poolInfo.reserveA,
poolInfo.reserveB
)

// Calculate LP tokens yang akan diterima
const lpTokens = calculateLPTokens(
optimal.amountA,
optimal.amountB,
poolInfo.reserveA,
poolInfo.reserveB,
poolInfo.totalLiquidity
)

// Calculate share of pool
const shareOfPool = calculatePoolShare(
lpTokens,
poolInfo.totalLiquidity + lpTokens
)

return {
tokenA: TOKENS.CAMP,
tokenB: TOKENS.USDC,
amountA: formatBigInt(optimal.amountA, TOKENS.CAMP.decimals, 6),
amountB: formatBigInt(optimal.amountB, TOKENS.USDC.decimals, 6),
lpTokens: formatBigInt(lpTokens, 18, 6),
shareOfPool
}
}

/**
* Execute add liquidity
*/
const executeAddLiquidity = async (liquidityData: LiquidityData): Promise<void> => {
if (!address) {
toast.error('Please connect your wallet')
return
}

setIsLoading(true)

try {
const amountA = parseTokenAmount(liquidityData.amountA, TOKENS.CAMP.decimals)
const amountB = parseTokenAmount(liquidityData.amountB, TOKENS.USDC.decimals)

// Step 1: Approve both tokens
toast.loading('Approving tokens...', { id: 'liquidity' })

const approveA = writeContractAsync({
address: TOKENS.CAMP.address,
abi: ERC20_ABI,
functionName: 'approve',
args: [CONTRACTS.SIMPLE_DEX, amountA]
})

const approveB = writeContractAsync({
address: TOKENS.USDC.address,
abi: ERC20_ABI,
functionName: 'approve',
args: [CONTRACTS.SIMPLE_DEX, amountB]
})

await Promise.all([approveA, approveB])

// Step 2: Add liquidity
toast.loading('Adding liquidity...', { id: 'liquidity' })

const hash = await writeContractAsync({
address: CONTRACTS.SIMPLE_DEX,
abi: SIMPLE_DEX_ABI,
functionName: 'addLiquidity',
args: [amountA, amountB]
})

// Step 3: Wait for confirmation
toast.loading('Waiting for confirmation...', { id: 'liquidity' })

await waitForTransactionReceipt(config, { hash })

toast.success(
`Added ${liquidityData.amountA} CAMP and ${liquidityData.amountB} USDC to pool!`,
{ id: 'liquidity', duration: 5000 }
)
} catch (error: any) {
console.error('Add liquidity error:', error)
toast.error(
error.message.includes('rejected')
? 'Transaction rejected'
: `Failed: ${error.shortMessage || error.message}`,
{ id: 'liquidity' }
)
} finally {
setIsLoading(false)
}
}

/**
* Execute remove liquidity
*/
const executeRemoveLiquidity = async (percentage: number): Promise<void> => {
if (!address || !lpBalance) {
toast.error('No liquidity position found')
return
}

setIsLoading(true)

try {
// Calculate LP amount to remove
const lpAmount = ((lpBalance as bigint) * BigInt(percentage)) / 100n

toast.loading('Removing liquidity...', { id: 'liquidity' })

const hash = await writeContractAsync({
address: CONTRACTS.SIMPLE_DEX,
abi: SIMPLE_DEX_ABI,
functionName: 'removeLiquidity',
args: [lpAmount]
})

toast.loading('Waiting for confirmation...', { id: 'liquidity' })

await waitForTransactionReceipt(config, { hash })

toast.success(
`Removed ${percentage}% of your liquidity!`,
{ id: 'liquidity', duration: 5000 }
)
} catch (error: any) {
console.error('Remove liquidity error:', error)
toast.error(
error.message.includes('rejected')
? 'Transaction rejected'
: `Failed: ${error.shortMessage || error.message}`,
{ id: 'liquidity' }
)
} finally {
setIsLoading(false)
}
}

/**
* Get user's liquidity position
*/
const getUserPosition = (): UserPosition | null => {
if (!poolInfo || !lpBalance || lpBalance === 0n) {
return null
}

const lpTokenBalance = lpBalance as bigint

// Calculate share of pool
const shareOfPool = calculatePoolShare(lpTokenBalance, poolInfo.totalLiquidity)

// Calculate token amounts
const { amountA, amountB } = calculateTokenAmountsFromLP(
lpTokenBalance,
poolInfo.reserveA,
poolInfo.reserveB,
poolInfo.totalLiquidity
)

// Estimate USD value (simplified - assume 1 USDC = $1)
const estimatedValue = Number(formatBigInt(amountB, TOKENS.USDC.decimals, 2)) * 2

return {
lpTokenBalance,
shareOfPool,
tokenAAmount: amountA,
tokenBAmount: amountB,
estimatedValue
}
}

return {
calculateAddLiquidity,
executeAddLiquidity,
executeRemoveLiquidity,
getUserPosition,
isLoading
}
}

// Helper function
async function waitForTransactionReceipt(
config: any,
{ hash }: { hash: `0x${string}` }
) {
return new Promise((resolve, reject) => {
const checkReceipt = async () => {
try {
const receipt = await config.publicClient.waitForTransactionReceipt({ hash })
resolve(receipt)
} catch (error) {
reject(error)
}
}
checkReceipt()
})
}

💧 Liquidity Flow:

Add Liquidity:
1. User masukkan amount CAMP → auto-calculate USDC (atau sebaliknya)
2. calculateAddLiquidity() → optimal amounts berdasarkan pool ratio
3. Calculate LP tokens yang akan diterima
4. User klik "Add" → approve kedua tokens
5. Execute addLiquidity transaction
6. Receive LP tokens

Remove Liquidity:
1. Show user's position (LP tokens, share %, token amounts)
2. User pilih percentage (25%, 50%, 75%, 100%)
3. Calculate tokens yang akan diterima
4. Execute removeLiquidity transaction
5. Burn LP tokens, receive CAMP + USDC

🎨 Part 6: Main App Setup

Step 1: Configure Wagmi & RainbowKit

Buat file src/App.tsx:

import Header from "./components/Header";
import '@rainbow-me/rainbowkit/styles.css';

import {
getDefaultConfig,
RainbowKitProvider,
} from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import type {
Chain
} from 'wagmi/chains';
import {
QueryClientProvider,
QueryClient,
} from "@tanstack/react-query";
import { Toaster } from "react-hot-toast";
import DEXContainer from "./components/DEXContainer";

// Konfigurasi Chain Lisk Sepolia Testnet
const liskSepoliaTestnet: Chain = {
id: 4202,
name: 'Lisk Sepolia Testnet',
nativeCurrency: {
decimals: 18,
name: 'ETH',
symbol: 'ETH',
},
rpcUrls: {
default: {
http: ['https://rpc.sepolia-api.lisk.com'],
},
public: {
http: ['https://rpc.sepolia-api.lisk.com'],
},
},
blockExplorers: {
default: {
name: 'Lisk Blockscout',
url: 'https://sepolia-blockscout.lisk.com',
},
},
testnet: true,
};

// eslint-disable-next-line react-refresh/only-export-components
export const config = getDefaultConfig({
appName: 'Simple DEX',
projectId: 'YOUR_WALLETCONNECT_PROJECT_ID', // Dapatkan dari https://cloud.walletconnect.com
chains: [liskSepoliaTestnet],
ssr: true,
});

function App() {
const queryClient = new QueryClient();
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider>
<div className="min-h-screen">
<Header />
<DEXContainer />
</div>
<Toaster position="top-center" />
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
)
}

export default App

🔧 Penjelasan Konfigurasi:

  1. Lisk Sepolia Chain Definition

  2. Wagmi Config

    • getDefaultConfig: Setup otomatis untuk RainbowKit + Wagmi
    • projectId: WalletConnect project ID dari environment variable
    • chains: Array of supported chains (hanya Lisk Sepolia)
  3. QueryClient

    • State management untuk blockchain data
    • refetchOnWindowFocus: false - Tidak auto-refetch saat tab aktif
    • retry: 1 - Retry 1x jika request gagal
  4. Providers Hierarchy

    WagmiProvider
    └── QueryClientProvider
    └── RainbowKitProvider
    └── App Content

Step 2: Entry Point

Buat file src/main.tsx:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

Step 3: Create Placeholder Components

Untuk sementara, buat placeholder components agar app bisa running:

src/components/Header.tsx:

import { ConnectButton } from "@rainbow-me/rainbowkit"

const Header = () => {
return (
<header className="glass-dark sticky top-0 z-50 py-4 border-b border-white/10">
<div className="container mx-auto px-6 flex justify-between items-center">
<div className="flex items-center space-x-4">
<div className="relative">
<div className="flex items-center space-x-1 p-2 rounded-xl bg-gradient-to-r from-white/10 to-white/5 border border-white/10">
<img src="/nad-trade-logo.png" alt="Lisk Trade Logo" className="w-8 h-8" />
</div>
</div>
<div>
<h1 className="text-2xl font-bold text-gradient-monad font-inter">LiskTrade</h1>
<p className="text-xs font-medium" style={{ color: "rgba(255, 255, 255, 0.7)" }}>
Decentralized Exchange on Lisk
</p>
</div>
</div>

<div className="flex items-center space-x-4">
<div className="hidden md:flex items-center space-x-6 text-sm" style={{ color: "rgba(255, 255, 255, 0.8)" }}>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 rounded-full animate-pulse" style={{ backgroundColor: "#10B981" }}></div>
<span>Live Markets</span>
</div>
<div className="flex items-center space-x-2">
<span>0.3% Fee</span>
</div>
</div>
<ConnectButton />
</div>
</div>
</header>
)
}

export default Header

src/components/DEXContainer.tsx:

import { useAccount } from 'wagmi'

export default function DEXContainer() {
const { isConnected } = useAccount()

if (!isConnected) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<div className="text-6xl mb-4">👛</div>
<h2 className="text-2xl font-bold mb-2">Connect Your Wallet</h2>
<p className="text-slate-400">
Connect your wallet to start trading on SimpleDEX
</p>
</div>
</div>
)
}

return (
<div className="glass rounded-2xl p-6 max-w-2xl mx-auto">
<h2 className="text-xl font-bold mb-4">Swap Interface</h2>
<p className="text-slate-400">
Swap interface akan ditambahkan di sini...
</p>
</div>
)
}

🚀 Part 7: Running the Application

Step 1: Run Development Server

# Jalankan dev server
npm run dev

Aplikasi akan berjalan di http://localhost:5173/

Step 2: Test Wallet Connection

  1. ✅ Click "Connect Wallet" di header
  2. ✅ Pilih MetaMask (atau wallet lain)
  3. ✅ Approve connection
  4. ✅ Switch network ke Lisk Sepolia jika diminta

Step 3: Verify Connection

Setelah terhubung, Anda harus melihat:

  • ✅ Address Anda di header (0x1234...5678)
  • ✅ Green "Live" indicator
  • ✅ Wallet balance (jika ada)
  • ✅ "Connect Your Wallet" screen hilang

📦 Part 8: Package.json

Buat file package.json dengan scripts yang diperlukan:

{
"name": "simple-defi-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@rainbow-me/rainbowkit": "^2.2.8",
"@tanstack/react-query": "^5.80.10",
"lucide-react": "^0.518.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hot-toast": "^2.5.2",
"recharts": "^2.15.3",
"viem": "^2.31.3",
"wagmi": "^2.15.6"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/vite": "^4.1.10",
"@types/node": "^24.0.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"tailwindcss": "^4.1.10",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

🎯 Part 9: Next Steps - Building Full UI Components

Sampai di sini, Anda sudah memiliki:

  • ✅ Project structure yang lengkap
  • ✅ All utilities (formatters, calculations, hooks)
  • ✅ Wagmi + RainbowKit configured untuk Lisk Sepolia
  • ✅ Basic app yang bisa connect wallet

Untuk melanjutkan membangun full UI, Anda perlu membuat:

1. SwapInterface Component (src/components/SwapInterface.tsx)

  • Input fields untuk token amounts
  • Token selector (CAMP ↔ USDC)
  • Swap direction button
  • Real-time price calculation
  • Slippage settings
  • Price impact warning
  • Execute swap button

2. LiquidityInterface Component (src/components/LiquidityInterface.tsx)

  • Add Liquidity tab:
    • Dual token input (auto-calculate ratio)
    • LP tokens preview
    • Pool share percentage
  • Remove Liquidity tab:
    • User position display
    • Percentage selector (25%, 50%, 75%, 100%)
    • Expected output preview

3. PoolStats Component (src/components/PoolStats.tsx)

  • Total Value Locked (TVL)
  • 24h Volume
  • Current Price
  • APR calculation
  • Pool composition (reserves)

4. PriceChart Component (src/components/PriceChart.tsx)

  • Real-time price chart menggunakan Recharts
  • Time frame selector (1H, 1D, 1W, 1M)
  • Historical data dari blockchain events

5. TransactionHistory Component (src/components/TransactionHistory.tsx)

  • Real-time transaction list
  • Filter by type (Swap, Add, Remove)
  • Links to block explorer

🔥 Quick Reference - Key Hooks Usage

1. Reading Token Balance

import { useTokenBalance } from './hooks/useTokenBalance'
import { TOKENS } from './constants'

function MyComponent() {
const { balance, allowance } = useTokenBalance(TOKENS.CAMP)

return <div>Balance: {formatBalance(balance, 18, 'CAMP')}</div>
}

2. Reading Pool Data

import { usePoolData } from './hooks/usePoolData'

function MyComponent() {
const { poolInfo, currentPrice } = usePoolData()

if (!poolInfo) return <div>Loading...</div>

return (
<div>
<p>Reserve CAMP: {formatBigInt(poolInfo.reserveA, 18)}</p>
<p>Reserve USDC: {formatBigInt(poolInfo.reserveB, 6)}</p>
<p>Price: ${currentPrice.toFixed(4)}</p>
</div>
)
}

3. Executing a Swap

import { useState } from 'react'
import { useSwap } from './hooks/useSwap'
import { TOKENS } from './constants'

function SwapComponent() {
const [amountIn, setAmountIn] = useState('')
const { calculateSwap, executeSwap, isSwapping } = useSwap()

const swapData = calculateSwap(amountIn, TOKENS.CAMP, TOKENS.USDC)

const handleSwap = () => {
if (swapData) {
executeSwap(swapData)
}
}

return (
<div>
<input
value={amountIn}
onChange={(e) => setAmountIn(e.target.value)}
placeholder="Amount"
/>
{swapData && (
<div>
<p>You receive: {swapData.amountOut} USDC</p>
<p>Price impact: {swapData.priceImpact.toFixed(2)}%</p>
</div>
)}
<button onClick={handleSwap} disabled={isSwapping}>
{isSwapping ? 'Swapping...' : 'Swap'}
</button>
</div>
)
}

4. Adding Liquidity

import { useState } from 'react'
import { useLiquidity } from './hooks/useLiquidity'

function LiquidityComponent() {
const [amountA, setAmountA] = useState('')
const [amountB, setAmountB] = useState('')
const { calculateAddLiquidity, executeAddLiquidity, isLoading } = useLiquidity()

const liquidityData = calculateAddLiquidity(amountA, amountB)

const handleAdd = () => {
if (liquidityData) {
executeAddLiquidity(liquidityData)
}
}

return (
<div>
<input
value={amountA}
onChange={(e) => setAmountA(e.target.value)}
placeholder="CAMP Amount"
/>
<input
value={amountB}
onChange={(e) => setAmountB(e.target.value)}
placeholder="USDC Amount"
/>
{liquidityData && (
<div>
<p>LP Tokens: {liquidityData.lpTokens}</p>
<p>Pool Share: {liquidityData.shareOfPool.toFixed(4)}%</p>
</div>
)}
<button onClick={handleAdd} disabled={isLoading}>
{isLoading ? 'Adding...' : 'Add Liquidity'}
</button>
</div>
)
}

🎓 Learning Outcomes

Setelah menyelesaikan Part 5 ini, Anda telah belajar:

Technical Skills:

  • ✅ Setup modern React project dengan Vite + TypeScript
  • ✅ Configure Wagmi untuk blockchain interactions
  • ✅ Implement wallet connection dengan RainbowKit
  • ✅ Create custom React hooks untuk DeFi operations
  • ✅ Handle BigInt untuk precise token calculations
  • ✅ Implement AMM formulas dalam TypeScript
  • ✅ Setup TailwindCSS untuk modern UI

DeFi Concepts:

  • ✅ Constant Product Formula (x × y = k) implementation
  • ✅ Price impact calculation
  • ✅ Slippage protection
  • ✅ LP token mechanics
  • ✅ Token approval flow
  • ✅ Transaction lifecycle (approval → execution → confirmation)

Best Practices:

  • ✅ Separation of concerns (hooks, utils, components)
  • ✅ Type safety dengan TypeScript
  • ✅ Error handling dengan try-catch dan user feedback
  • ✅ Loading states untuk better UX
  • ✅ Real-time data updates dengan refetchInterval
  • ✅ Responsive design dengan TailwindCSS

📚 Resources

Documentation:

Tools:

Next Tutorials:

Untuk implementasi lengkap semua UI components (SwapInterface, LiquidityInterface, PoolStats, dll), lihat repository example atau lanjutkan dengan membangun components sendiri menggunakan hooks yang sudah tersedia.


🐛 Troubleshooting

"Cannot read properties of undefined"

  • ✅ Pastikan wallet sudah connected
  • ✅ Check network adalah Lisk Sepolia (Chain ID 4202)
  • ✅ Verify contract addresses di .env.local benar

"User rejected transaction"

  • ✅ Normal jika user cancel di wallet
  • ✅ App akan show error toast

"Insufficient funds"

"Allowance too low"

  • ✅ Hook useSwap dan useLiquidity sudah handle approval otomatis
  • ✅ Jika masih error, coba manual approve dengan lebih banyak

RPC Errors

  • ✅ Lisk Sepolia RPC kadang slow, tunggu beberapa saat
  • ✅ Refresh page dan coba lagi

🎉 Summary

Congratulations! 🎊

Anda telah berhasil membangun foundation yang solid untuk SimpleDEX frontend:

  • Complete project structure dengan best practices
  • All utilities untuk formatting dan AMM calculations
  • Custom hooks untuk semua DeFi operations
  • Wagmi + RainbowKit configured untuk Lisk Sepolia
  • Type-safe dengan TypeScript
  • Ready to build full UI components

Next Step: Build UI components (SwapInterface, LiquidityInterface, dll) menggunakan hooks yang sudah tersedia, atau deploy basic version ini dan iterate!

Happy Building! 🚀


Prepared by: Ethereum Jakarta x Lisk Part 5 - Frontend Integration Version: 2.0 (Updated to match actual implementation)


📝 Documentation Update Summary

Documentation telah diupdate untuk match 1:1 dengan actual code implementation. Berikut adalah perubahan utama:

1. Package Versions (Updated to Exact Versions)

  • React: 18.2.0 → 19.1.0
  • Wagmi: 2.5.0 → 2.15.6
  • Viem: 2.9.0 → 2.31.3
  • RainbowKit: 2.1.0 → 2.2.8
  • TailwindCSS: 4.0.0-alpha.25 → 4.1.10
  • Vite: 5.2.0 → 6.3.5
  • TypeScript: 5.2.2 → 5.8.3
  • React Query: 5.28.0 → 5.80.10
  • Recharts: 2.12.0 → 2.15.3

2. Contract Addresses (Hardcoded)

Contract addresses sekarang hardcoded di constants/index.tsx:

  • SimpleDEX: 0x70bDD0f7e01DEe803147ead041dE23a531A71CBf
  • Campus Coin: 0xEBAa841c5f97Ff097e61eea151dFA03640A6CC78
  • Mock USDC: 0x786Ca7D3a2E53f0d5F7bB6848E03b60Dae9a3719

3. Styling Theme (Black-White Gradient)

Theme telah diubah dari Lisk brand colors ke black-white gradient theme:

  • Background: Pure black → dark black → medium black gradient
  • Glass morphism dengan white/10 borders
  • Text gradient: Pure white → off-white → light gray
  • Animations: glow-white, glow-gray, glow-success

4. App Configuration

  • Wagmi config menggunakan getDefaultConfig with ssr: true
  • QueryClient dibuat inside App component (bukan global)
  • Toaster position: bottom-right → top-center
  • Chain name: "Lisk Sepolia Testnet" dengan public RPC URLs

5. Component Implementation

  • Header: Menggunakan "LiskTrade" branding dengan logo image
  • DEXContainer: "use client" directive untuk tab state management
  • Styling: Inline styles dengan rgba colors (black-white theme)

6. Utility Functions

  • formatNumber: minimumFractionDigits set to 0 (bukan decimals)
  • formatTokenAmount: Support untuk bigint | string dengan TOKENS keyof
  • isValidAmount: Validasi tambahan untuk '0' dan '.'
  • calculations.ts: Menggunakan DEX_CONFIG.FEE_PERCENT as default

7. Type Definitions

  • Token address: 0x${string}string
  • TransactionHistory: Enhanced dengan tokenA/tokenB objects
  • Added PriceData interface untuk analytics

8. Hook Implementations

  • useTokenBalance: Return type includes refetchAllowance
  • usePoolData: Separate getPrice call dengan currentPrice
  • useSwap: Import from '@wagmi/core' untuk waitForTransactionReceipt
  • useLiquidity: Enhanced dengan approveToken helper function

9. Build Configuration

  • Vite config: Simplified plugins array [react(), tailwindcss()]
  • No tailwind.config.js needed (TailwindCSS v4 uses CSS variables)
  • TypeScript build: tsc -b (incremental build)

10. Naming Conventions

  • Project name: simple-dex-ui → simple-defi-ui
  • App name: SimpleDEX → Simple DEX / LiskTrade
  • Component styling: Lebih banyak inline styles untuk precise control

Semua code examples sekarang match EXACTLY dengan actual implementation di /simple-defi-ui directory.