Build a full-stack Dex on Rootstock : Step-by-step comprehensive guide - Part 2.
Welcome back, builders, to part 2 of our building full-stack Dex on Rootstock tutorial. In the previous part, we built, tested and deployed our contracts using Foundry on Rootstock testnet. We also verified on smart contracts on the chain, and in this part, we are going to connect these smart contracts to the frontend. We will build a UI to interact with this, and by the end of this guide, we will have our full-stack production-ready Dapp fully on the Rootstock chain.
Before going ahead, just quick recap of the previous part.
We initialised the Foundry project
Installed OpenZeppelin contracts library
Built ERC 20 Test contract and main Dex contract
We tested both contracts and deployed them on the Rootstock testnet.
We verified our smart contracts on the chain.
This is part 2 of this comprehensive guide to building a full-stack Dex dapp on Rootstock. I would recommend going back and following part 1 first for a better understanding of this tutorial. Now, without wasting time, let’s jump right into code and start building!
I hope you remember that in the previous part, we created 2 main directories contracts and frontend and in contracts directory, we worked in the previous part, and we will work in frontend directory in this part. We will use the latest version of Next.js for frontend.
Go back to your main directory and then go into the frontend directory in your terminal and initialise our NextJs project.
cd ..
cd frontend
npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
It will prompt ✔ Would you like to use Turbopack for next dev? … No / Yes — check NO and hit ENTER.
And wait, your project will be initialised and your frontend folder structure look like this ( see the image below )

We also need to install more external dependencies to build this DEX. What dependencies? Wagmi (React hooks for Ethereum applications) - https://wagmi.sh/
Viem ( interacting with the blockchain ) - https://viem.sh/
RainbowKit(for wallet managment) - https://rainbowkit.com/docs/introduction
Install these by running the following command.
npm install @rainbow-me/rainbowkit wagmi viem @tanstack/react-query
Create a new directory named lib inside src directory and inside lib create a new file contracts.ts and paste the following code in this file. ( Assign the actual address of the contracts you deployed )
// Contract addresses (deployed on Rootstock testnet)
export const CONTRACTS = {
DEX: "YOUR_DEPLOYED_CONTRACT_ADDRESS", // Update this with your deployed contract address
TOKEN_A: "YOUR_DEPLOYED_CONTRACT_ADDRESS", // Update this with your deployed contract address
TOKEN_B: "YOUR_DEPLOYED_CONTRACT_ADDRESS", // Update this with your deployed contract address
} as const;
// ERC20 ABI (for token interactions)
export const ERC20_ABI = [
{
constant: true,
inputs: [],
name: "name",
outputs: [{ name: "", type: "string" }],
type: "function",
},
{
constant: true,
inputs: [],
name: "symbol",
outputs: [{ name: "", type: "string" }],
type: "function",
},
{
constant: true,
inputs: [],
name: "decimals",
outputs: [{ name: "", type: "uint8" }],
type: "function",
},
{
constant: true,
inputs: [],
name: "totalSupply",
outputs: [{ name: "", type: "uint256" }],
type: "function",
},
{
constant: true,
inputs: [{ name: "_owner", type: "address" }],
name: "balanceOf",
outputs: [{ name: "balance", type: "uint256" }],
type: "function",
},
{
constant: false,
inputs: [
{ name: "_to", type: "address" },
{ name: "_value", type: "uint256" },
],
name: "transfer",
outputs: [{ name: "", type: "bool" }],
type: "function",
},
{
constant: false,
inputs: [
{ name: "_spender", type: "address" },
{ name: "_value", type: "uint256" },
],
name: "approve",
outputs: [{ name: "", type: "bool" }],
type: "function",
},
{
constant: true,
inputs: [
{ name: "_owner", type: "address" },
{ name: "_spender", type: "address" },
],
name: "allowance",
outputs: [{ name: "", type: "uint256" }],
type: "function",
},
] as const;
// DEX ABI (core functions)
export const DEX_ABI = [
{
inputs: [
{ name: "tokenA", type: "address" },
{ name: "tokenB", type: "address" },
{ name: "amountADesired", type: "uint256" },
{ name: "amountBDesired", type: "uint256" },
{ name: "amountAMin", type: "uint256" },
{ name: "amountBMin", type: "uint256" },
],
name: "addLiquidity",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [
{ name: "tokenA", type: "address" },
{ name: "tokenB", type: "address" },
{ name: "liquidityAmount", type: "uint256" },
{ name: "amountAMin", type: "uint256" },
{ name: "amountBMin", type: "uint256" },
],
name: "removeLiquidity",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [
{ name: "tokenIn", type: "address" },
{ name: "tokenOut", type: "address" },
{ name: "amountIn", type: "uint256" },
{ name: "amountOutMin", type: "uint256" },
],
name: "swap",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [
{ name: "tokenA", type: "address" },
{ name: "tokenB", type: "address" },
],
name: "getReserves",
outputs: [
{ name: "reserveA", type: "uint256" },
{ name: "reserveB", 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: "view",
type: "function",
},
{
inputs: [
{ name: "user", type: "address" },
{ name: "tokenA", type: "address" },
{ name: "tokenB", type: "address" },
],
name: "getUserLiquidity",
outputs: [{ name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [],
name: "fee",
outputs: [{ name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
] as const;
Here, we declared contract addresses and ABIs to interact with frontend.
Now lets create another file inside same lib directory and name it wagmi.ts and paste the following code in this file.
import { getDefaultConfig } from '@rainbow-me/rainbowkit';
import { rootstockTestnet } from 'wagmi/chains';
// Configure chains for the app
const chains = [rootstockTestnet] as const;
// Set up wagmi config
export const config = getDefaultConfig({
appName: 'SimpleDEX',
projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!, // Get one at https://cloud.reown.com
chains,
ssr: true,
});
Great, now let’s create a new file at the root of your frontend directory and name it .env.local and paste the following variable.
NEXT_PUBLIC_REOWN_PROJECT_ID="YOUR_PROJECT_ID" # Get from https://cloud.reown.com/
Get your project ID from https://cloud.reown.com/ by signing in with your Email or wallet. Now, go back to your app directory and in this directory create a new file providers.tsx and paste the following code in this file.
'use client';
import { RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider } from 'wagmi';
import { config } from '@/lib/wagmi';
import '@rainbow-me/rainbowkit/styles.css';
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider>
{children}
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
}
This is amazing! We have finished the configuration and now go to src/app/layout.tsx file and remove all code which came already and update this file with the following code.
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "SimpleDEX - Rootstock Testnet",
description: "A decentralized exchange on Rootstock testnet",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}
Great! We just updated the layout of our DEX dapp and now lets update our main page UI. Go to src/app/page.tsx and update page.tsx file with the following code.
'use client';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { useState, useEffect } from 'react';
import { CONTRACTS, ERC20_ABI, DEX_ABI } from '@/lib/contracts';
import { parseEther, formatEther } from 'viem';
export default function Home() {
const { address, isConnected } = useAccount();
const [activeTab, setActiveTab] = useState<'swap' | 'liquidity'>('swap');
const [amountIn, setAmountIn] = useState('');
const [amountOut, setAmountOut] = useState('');
const [slippage, setSlippage] = useState(0.5);
// Read contract data
const { data: tokenABalance } = useReadContract({
address: CONTRACTS.TOKEN_A as `0x${string}`,
abi: ERC20_ABI,
functionName: 'balanceOf',
args: [address!],
query: { enabled: !!address },
}) as { data: bigint | undefined };
const { data: tokenBBalance } = useReadContract({
address: CONTRACTS.TOKEN_B as `0x${string}`,
abi: ERC20_ABI,
functionName: 'balanceOf',
args: [address!],
query: { enabled: !!address },
}) as { data: bigint | undefined };
const { data: reserves } = useReadContract({
address: CONTRACTS.DEX as `0x${string}`,
abi: DEX_ABI,
functionName: 'getReserves',
args: [CONTRACTS.TOKEN_A as `0x${string}`, CONTRACTS.TOKEN_B as `0x${string}`],
});
const { data: userLiquidity } = useReadContract({
address: CONTRACTS.DEX as `0x${string}`,
abi: DEX_ABI,
functionName: 'getUserLiquidity',
args: [address!, CONTRACTS.TOKEN_A as `0x${string}`, CONTRACTS.TOKEN_B as `0x${string}`],
query: { enabled: !!address },
}) as { data: bigint | undefined };
// Write contract functions
const { writeContract, data: hash } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash });
// Calculate swap output
useEffect(() => {
if (amountIn && reserves && parseFloat(amountIn) > 0) {
const amountInWei = parseEther(amountIn);
const [reserveA, reserveB] = reserves;
if (reserveA > BigInt(0) && reserveB > BigInt(0)) {
const amountOutWei = (amountInWei * reserveB) / (reserveA + amountInWei);
setAmountOut(formatEther(amountOutWei));
}
} else {
setAmountOut('');
}
}, [amountIn, reserves]);
const handleSwap = () => {
if (!amountIn || !amountOut) return;
const amountOutMin = parseFloat(amountOut) * (1 - slippage / 100);
writeContract({
address: CONTRACTS.DEX as `0x${string}`,
abi: DEX_ABI,
functionName: 'swap',
args: [
CONTRACTS.TOKEN_A as `0x${string}`,
CONTRACTS.TOKEN_B as `0x${string}`,
parseEther(amountIn),
parseEther(amountOutMin.toString()),
],
});
};
const handleAddLiquidity = () => {
if (!amountIn || !amountOut) return;
const amountAMin = parseFloat(amountIn) * (1 - slippage / 100);
const amountBMin = parseFloat(amountOut) * (1 - slippage / 100);
writeContract({
address: CONTRACTS.DEX as `0x${string}`,
abi: DEX_ABI,
functionName: 'addLiquidity',
args: [
CONTRACTS.TOKEN_A as `0x${string}`,
CONTRACTS.TOKEN_B as `0x${string}`,
parseEther(amountIn),
parseEther(amountOut),
parseEther(amountAMin.toString()),
parseEther(amountBMin.toString()),
],
});
};
if (!isConnected) {
return (
<div className="min-h-screen bg-gradient-to-br from-[#FFE5B4] via-[#FFB300] to-[#FFD580] flex items-center justify-center">
<div className="bg-white/90 rounded-2xl p-8 text-center shadow-lg">
<h1 className="text-4xl font-bold text-[#4B2E05] mb-6">SimpleDEX</h1>
<p className="text-[#4B2E05] mb-8">Connect your wallet to start trading on Rootstock testnet</p>
<ConnectButton />
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-[#FFE5B4] via-[#FFB300] to-[#FFD580]">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-[#4B2E05]">SimpleDEX</h1>
<ConnectButton />
</div>
{/* Main Interface */}
<div className="max-w-2xl mx-auto">
{/* Tabs */}
<div className="flex bg-[#FFE5B4] rounded-t-xl overflow-hidden">
<button
onClick={() => setActiveTab('swap')}
className={`flex-1 py-3 px-6 font-medium transition-colors ${
activeTab === 'swap' ? 'bg-[#4B2E05] text-white' : 'text-[#4B2E05] hover:bg-[#FFD580]'
}`}
>
Swap
</button>
<button
onClick={() => setActiveTab('liquidity')}
className={`flex-1 py-3 px-6 font-medium transition-colors ${
activeTab === 'liquidity' ? 'bg-[#4B2E05] text-white' : 'text-[#4B2E05] hover:bg-[#FFD580]'
}`}
>
Liquidity
</button>
</div>
{/* Content */}
<div className="bg-white/90 rounded-b-xl p-6 shadow-lg">
{activeTab === 'swap' ? (
<SwapInterface
amountIn={amountIn}
amountOut={amountOut}
setAmountIn={setAmountIn}
setAmountOut={setAmountOut}
slippage={slippage}
setSlippage={setSlippage}
onSwap={handleSwap}
isConfirming={isConfirming}
isSuccess={isSuccess}
tokenABalance={tokenABalance}
tokenBBalance={tokenBBalance}
reserves={reserves}
/>
) : (
<LiquidityInterface
amountIn={amountIn}
amountOut={amountOut}
setAmountIn={setAmountIn}
setAmountOut={setAmountOut}
slippage={slippage}
setSlippage={setSlippage}
onAddLiquidity={handleAddLiquidity}
isConfirming={isConfirming}
isSuccess={isSuccess}
userLiquidity={userLiquidity}
/>
)}
</div>
</div>
</div>
</div>
);
}
// Swap Interface Component
function SwapInterface({
amountIn,
amountOut,
setAmountIn,
setAmountOut,
slippage,
setSlippage,
onSwap,
isConfirming,
isSuccess,
tokenABalance,
tokenBBalance,
reserves,
}: {
amountIn: string;
amountOut: string;
setAmountIn: (value: string) => void;
setAmountOut: (value: string) => void;
slippage: number;
setSlippage: (value: number) => void;
onSwap: () => void;
isConfirming: boolean;
isSuccess: boolean;
tokenABalance: bigint | undefined;
tokenBBalance: bigint | undefined;
reserves: readonly [bigint, bigint] | undefined;
}) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-[#4B2E05] mb-2">You Pay</label>
<div className="bg-[#FFF8E1] rounded-lg p-4">
<input
type="number"
value={amountIn}
onChange={(e) => setAmountIn(e.target.value)}
placeholder="0.0"
className="w-full bg-transparent text-[#4B2E05] text-2xl outline-none"
/>
<div className="flex justify-between text-sm text-[#B8860B] mt-2">
<span>Token A</span>
<span>Balance: {tokenABalance ? formatEther(tokenABalance) : '0'}</span>
</div>
</div>
</div>
<div className="flex justify-center">
<button className="bg-[#FFB300] p-2 rounded-full">
<svg className="w-5 h-5 text-[#4B2E05]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
</button>
</div>
<div>
<label className="block text-sm font-medium text-[#4B2E05] mb-2">You Receive</label>
<div className="bg-[#FFF8E1] rounded-lg p-4">
<input
type="number"
value={amountOut}
onChange={(e) => setAmountOut(e.target.value)}
placeholder="0.0"
className="w-full bg-transparent text-[#4B2E05] text-2xl outline-none"
readOnly
/>
<div className="flex justify-between text-sm text-[#B8860B] mt-2">
<span>Token B</span>
<span>Balance: {tokenBBalance ? formatEther(tokenBBalance) : '0'}</span>
</div>
</div>
</div>
{/* Slippage */}
<div>
<label className="block text-sm font-medium text-[#4B2E05] mb-2">Slippage Tolerance</label>
<input
type="number"
value={slippage}
onChange={(e) => setSlippage(parseFloat(e.target.value))}
step="0.1"
className="w-full bg-[#FFF8E1] rounded-lg p-3 text-[#4B2E05] outline-none"
/>
</div>
{/* Swap Button */}
<button
onClick={onSwap}
disabled={!amountIn || !amountOut || isConfirming}
className="w-full bg-[#4B2E05] hover:bg-[#B8860B] disabled:bg-gray-400 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
{isConfirming ? 'Confirming...' : 'Swap'}
</button>
{isSuccess && (
<div className="bg-green-500/20 border border-green-500 rounded-lg p-4 text-green-700">
Swap completed successfully!
</div>
)}
{/* Pool Info */}
{reserves && (
<div className="bg-[#FFF8E1] rounded-lg p-4">
<h3 className="text-sm font-medium text-[#4B2E05] mb-2">Pool Information</h3>
<div className="text-sm text-[#B8860B] space-y-1">
<div>Token A Reserve: {formatEther(reserves[0])}</div>
<div>Token B Reserve: {formatEther(reserves[1])}</div>
</div>
</div>
)}
</div>
);
}
// Liquidity Interface Component
function LiquidityInterface({
amountIn,
amountOut,
setAmountIn,
setAmountOut,
slippage,
setSlippage,
onAddLiquidity,
isConfirming,
isSuccess,
userLiquidity,
}: {
amountIn: string;
amountOut: string;
setAmountIn: (value: string) => void;
setAmountOut: (value: string) => void;
slippage: number;
setSlippage: (value: number) => void;
onAddLiquidity: () => void;
isConfirming: boolean;
isSuccess: boolean;
userLiquidity: bigint | undefined;
}) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-[#4B2E05] mb-2">Token A Amount</label>
<div className="bg-[#FFF8E1] rounded-lg p-4">
<input
type="number"
value={amountIn}
onChange={(e) => setAmountIn(e.target.value)}
placeholder="0.0"
className="w-full bg-transparent text-[#4B2E05] text-2xl outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[#4B2E05] mb-2">Token B Amount</label>
<div className="bg-[#FFF8E1] rounded-lg p-4">
<input
type="number"
value={amountOut}
onChange={(e) => setAmountOut(e.target.value)}
placeholder="0.0"
className="w-full bg-transparent text-[#4B2E05] text-2xl outline-none"
/>
</div>
</div>
{/* Slippage */}
<div>
<label className="block text-sm font-medium text-[#4B2E05] mb-2">Slippage Tolerance</label>
<input
type="number"
value={slippage}
onChange={(e) => setSlippage(parseFloat(e.target.value))}
step="0.1"
className="w-full bg-[#FFF8E1] rounded-lg p-3 text-[#4B2E05] outline-none"
/>
</div>
{/* Add Liquidity Button */}
<button
onClick={onAddLiquidity}
disabled={!amountIn || !amountOut || isConfirming}
className="w-full bg-[#4B2E05] hover:bg-[#B8860B] disabled:bg-gray-400 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
{isConfirming ? 'Confirming...' : 'Add Liquidity'}
</button>
{isSuccess && (
<div className="bg-green-500/20 border border-green-500 rounded-lg p-4 text-green-700">
Liquidity added successfully!
</div>
)}
{/* User Liquidity Info */}
{userLiquidity && (
<div className="bg-[#FFF8E1] rounded-lg p-4">
<h3 className="text-sm font-medium text-[#4B2E05] mb-2">Your Liquidity</h3>
<div className="text-sm text-[#B8860B]">
<div>LP Tokens: {formatEther(userLiquidity)}</div>
</div>
</div>
)}
</div>
);
}
At this point your folder structure look like this… ( see the image below )

Amazing! We are ready. Now open your terminal ( ensure you are in frontend directory ) and run the following command to run our app.
npm run dev
And, there we go… boom!! you will be asked to visit http://localhost:3000/ to see your app running. Go click on this link and see if your DEX app is running and your initial UI look like this ( see the image below )

Connect your wallet. Make sure you are on the Rootstock test network in your wallet; otherwise, it will warn you you are on the wrong network, and you will see UI like this ( see the image below )

Yayyyy! We made it! Now, let’s try to swap 5 tokens. And there we go… we successfully did this.

You can easily switch between the Swap and Liquidity tab, and you can try adding liquidity and swap between TokenA and TokenB.
Wrapping this up! HUGE congratulations on building full stack DEX dapp on Rootstock network and now you have a pretty good understanding of how you can build Defi dapps on RSK network using same EVM stack.
If you stuck anywhere follow the GitHub repo (link below) for the entire source code, and feel free to ask for help in Rootstock communities. Join the Discord and Telegram communities using the following links:
Source code - https://github.com/panditdhamdhere/Simple_Dex
Rootstock Discord: https://discord.gg/bFEBKQN2
Rootstock Telegram: @rskofficialcommunity
Rootstock Docs: https://dev.rootstock.io/
