Build a full-stack Dex on Rootstock : Step-by-step comprehensive guide - Part 1

Hi builders, welcome to this comprehensive tutorial where we'll build a full-stack decentralised exchange (DEX) application on the Rootstock blockchain. Rootstock is a powerful Bitcoin sidechain that combines Bitcoin's security with Ethereum's smart contract capabilities, making it an ideal platform for building DeFi applications. By the end of this guide, you'll have a fully functional DEX where users can swap tokens, provide liquidity, and we will add some advanced functionalities, all while leveraging Bitcoin's unparalleled security. This will be a comprehensive guide of Dex, so we will split it into 2 parts, and in part 1, we're gonna build smart contracts for this guide.
Understanding Rootstock
Rootstock is a smart contract platform that brings Ethereum's capabilities to the Bitcoin network. It allows developers to create decentralised applications while benefiting from Bitcoin's security. Rootstock uses a two-way peg mechanism to enable Bitcoin holders to use their assets on the Rootstock network, making it an attractive option for projects looking to leverage Bitcoin's liquidity and security.
Why Build on Rootstock?
Before we dive into coding, let's understand why Rootstock is such an exciting platform.
Bitcoin's Security with Smart Contracts: Rootstock uses merged mining with Bitcoin, meaning it's secured by Bitcoin's massive hash power while offering EVM compatibility.
Native BTC Integration: Through Rootstock's two-way peg system, users can bring real Bitcoin (as RBTC) into your DEX without relying on wrapped tokens.
Low Fees & High Speed: Rootstock offers significantly lower transaction fees than Ethereum mainnet while maintaining fast block times.
EVM Compatibility: If you know Solidity and Ethereum tooling, you can immediately start building on Rootstock.
Growing Ecosystem: With active dApps development, Rootstock, you're joining a vibrant developer community.
Prerequisites
To follow along with this tutorial, you'll need:
- Foundry installed (
Run curl -L https://foundry.paradigm.xyz | bash & foundryupon Unix systems)
Follow this installation guide https://getfoundry.sh/introduction/installation
Node.js (v16 or later) and npm/yarn ( we will use NPM ) https://www.npmjs.com/
MetaMask or a compatible Wallet Extension installed and configured for Rootstock networks
Basic understanding of Solidity smart contracts and DeFi.
Architecture Overview
Our DEX will consist of
Smart Contracts: Handling token swaps, liquidity pools, and fees
Frontend: A Next.js application for users to interact with the DEX
Now, without wasting time, let’s start building our Dex’s smart contract.
Let’s create a new directory Dex, and open this in your VS Code. Inside that Dex directory, create 2 new directories contracts and frontend. As I said, we are going to build smart contracts in this part 1, and in the next part, we will build a frontend UI to interact with smart contracts.
Let’s go ahead and run the following commands to initialise our Foundry project.
cd contracts
forge init
Great!! We initialised our foundry project in our contracts directory (see the image below)
Go inside your foundry project’s src directory and delete that counter.sol file. Also, go inside test directory and script directory and delete counter.t.sol and counter.s.sol files. Before going ahead, we need to install the OpenZeppelin contract library in our Foundry Dex project because we will need that as an external dependency.
What is the OpenZeppelin Library?
OpenZeppelin Contracts library is a widely used library of secure, battle-tested smart contracts for Ethereum and other EVM-compatible blockchains. It provides developers with standardised implementations of common blockchain functionality like ERC20 and ERC721 tokens, access control mechanisms, and governance tools, all thoroughly audited and maintained by blockchain security experts. By offering these reusable components, OpenZeppelin allows developers to build on established security patterns rather than writing everything from scratch, significantly reducing the risk of vulnerabilities that could lead to costly hacks or exploits.
Here are the docs if you want to explore more - https://docs.openzeppelin.com/contracts/5.x/
GitHub - https://github.com/OpenZeppelin/openzeppelin-contracts
Now, let’s go ahead and install OpenZeppelin’s contracts library in our project. To install, run the following command.
forge install Openzeppelin/openzeppelin-contracts
You will see our Openzeppelin is installed, and we are good to go.
And now set our remappings to avoid some red lines and successfully compile our contracts.
Run the following command to set remappings.
forge remappings >> remappings.txt
We need to create an ERC20 smart contract in order to build our Dex Contract, so let’s create one. Now, go to src directory and create a new file TestToken.sol and paste the following code in this file (I have explained the code in comments).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
// ⚠️ Don't use this code in production
/**
* @title TestToken
* @author Your Name (e.g., "pandit dhamdhere")
* @notice A simple ERC20 token implementation for testing purposes
* @dev This contract extends OpenZeppelin's ERC20 and Ownable contracts to provide:
* - Standard ERC20 functionality (transfer, approve, etc.)
* - Minting capability restricted to the owner
* - Ownership management for administrative functions
*
* This token is primarily used for testing the SimpleDEX functionality and
* can be used to create trading pairs for demonstration purposes.
*/
contract TestToken is ERC20, Ownable {
// ============ CONSTRUCTOR ============
/**
* @notice Initializes the test token with basic ERC20 properties
* @dev Mints the initial supply to the contract deployer
* @param name The name of the token (e.g., "Test Token")
* @param symbol The symbol of the token (e.g., "TEST")
* @param initialSupply The initial supply of tokens (will be multiplied by 10^decimals)
*/
constructor(
string memory name,
string memory symbol,
uint256 initialSupply
) ERC20(name, symbol) Ownable(msg.sender) {
// Mint initial supply to the contract deployer
// Note: initialSupply is multiplied by 10^decimals to account for token decimals
_mint(msg.sender, initialSupply * 10 ** decimals());
}
// ============ ADMIN FUNCTIONS ============
/**
* @notice Mints new tokens to a specified address (only owner)
* @dev This function allows the contract owner to create new tokens
* @param to The address to mint tokens to
* @param amount The amount of tokens to mint
*/
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
Great! We just built an ERC-20 smart contract for a better understanding of ERC-20 contracts. Watch my video tutorial - https://youtu.be/69wr_rTlDRA?si=ndyBG5DM6ckVVnvJ
Now we are going to build our main contract. Create another new file SimpleDex.sol in src directory as we are going to build a simple dex contract and paste the following code in this file ( I have explained all code in comments )
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
// ⚠️ Don't use this code in production
/**
* @title SimpleDEX
* @author Your Name (e.g., "pandit dhamdhere")
* @notice A simple decentralized exchange (DEX) implementation with automated market maker (AMM) functionality
* @dev This contract implements a constant product AMM similar to Uniswap V2, allowing users to:
* - Add and remove liquidity to trading pairs
* - Swap tokens using the constant product formula
* - Earn fees from trading activity
*
* Key Features:
* - Constant product AMM (x * y = k)
* - Configurable trading fees
* - Liquidity provider rewards
* - Emergency pause functionality
* - Reentrancy protection
*/
contract SimpleDEX is ReentrancyGuard, Ownable {
using SafeERC20 for IERC20;
using Math for uint256;
// ============ EVENTS ============
/**
* @notice Emitted when liquidity is added to a trading pair
* @param provider The address of the liquidity provider
* @param tokenA The first token in the trading pair
* @param tokenB The second token in the trading pair
* @param amountA The amount of tokenA added
* @param amountB The amount of tokenB added
*/
event LiquidityAdded(
address indexed provider,
address indexed tokenA,
address indexed tokenB,
uint256 amountA,
uint256 amountB
);
/**
* @notice Emitted when liquidity is removed from a trading pair
* @param provider The address of the liquidity provider
* @param tokenA The first token in the trading pair
* @param tokenB The second token in the trading pair
* @param amountA The amount of tokenA removed
* @param amountB The amount of tokenB removed
*/
event LiquidityRemoved(
address indexed provider,
address indexed tokenA,
address indexed tokenB,
uint256 amountA,
uint256 amountB
);
/**
* @notice Emitted when a token swap occurs
* @param user The address of the user performing the swap
* @param tokenIn The token being sold
* @param tokenOut The token being bought
* @param amountIn The amount of tokenIn being sold
* @param amountOut The amount of tokenOut being bought
*/
event Swap(
address indexed user,
address indexed tokenIn,
address indexed tokenOut,
uint256 amountIn,
uint256 amountOut
);
// ============ STATE VARIABLES ============
/// @notice Mapping of token pairs to their reserves: reserves[token0][token1] = reserve0
mapping(address => mapping(address => uint256)) public reserves;
/// @notice Mapping of user liquidity positions: liquidity[user][token0][token1] = userLiquidity
mapping(address => mapping(address => mapping(address => uint256)))
public liquidity;
/// @notice Minimum liquidity that must be locked in the contract (prevents division by zero)
uint256 public constant MINIMUM_LIQUIDITY = 1000;
/// @notice Denominator for fee calculations (basis points)
uint256 public constant FEE_DENOMINATOR = 10000;
/// @notice Trading fee in basis points (30 = 0.3%)
uint256 public fee = 30; // 0.3% fee (30/10000)
// ============ MODIFIERS ============
/**
* @notice Validates that both tokens are valid addresses and not the same
* @param tokenA The first token address
* @param tokenB The second token address
*/
modifier validTokens(address tokenA, address tokenB) {
require(
tokenA != address(0) && tokenB != address(0),
"Invalid token address"
);
require(tokenA != tokenB, "Same token");
_;
}
/**
* @notice Validates that the amount is greater than zero
* @param amount The amount to validate
*/
modifier validAmount(uint256 amount) {
require(amount > 0, "Amount must be greater than 0");
_;
}
/**
* @notice Ensures the contract is not paused
*/
modifier whenNotPaused() {
require(!paused, "Contract is paused");
_;
}
// ============ CONSTRUCTOR ============
/**
* @notice Initializes the DEX contract
* @dev Sets the contract deployer as the owner
*/
constructor() Ownable(msg.sender) {}
// ============ CORE DEX FUNCTIONS ============
/**
* @notice Adds liquidity to a trading pair
* @dev Implements the constant product AMM formula for liquidity provision
* @param tokenA The first token in the trading pair
* @param tokenB The second token in the trading pair
* @param amountADesired The desired amount of tokenA to add
* @param amountBDesired The desired amount of tokenB to add
* @param amountAMin The minimum amount of tokenA to add (slippage protection)
* @param amountBMin The minimum amount of tokenB to add (slippage protection)
* @return liquidityMinted The amount of liquidity tokens minted to the provider
*/
function addLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin
)
external
validTokens(tokenA, tokenB)
validAmount(amountADesired)
validAmount(amountBDesired)
whenNotPaused
nonReentrant
returns (uint256 liquidityMinted)
{
// Sort tokens to ensure consistent ordering (token0 < token1)
bool isTokenASmaller = tokenA < tokenB;
address token0 = isTokenASmaller ? tokenA : tokenB;
address token1 = isTokenASmaller ? tokenB : tokenA;
// Map amounts to sorted token order
uint256 amount0 = isTokenASmaller ? amountADesired : amountBDesired;
uint256 amount1 = isTokenASmaller ? amountBDesired : amountADesired;
uint256 amount0Min = isTokenASmaller ? amountAMin : amountBMin;
uint256 amount1Min = isTokenASmaller ? amountBMin : amountAMin;
// Get current reserves
uint256 reserve0 = reserves[token0][token1];
uint256 reserve1 = reserves[token1][token0];
// Calculate optimal amounts based on current reserves
uint256 amount0Optimal = reserve0 == 0 && reserve1 == 0
? amount0
: quote(amount1, reserve1, reserve0);
uint256 amount1Optimal = reserve0 == 0 && reserve1 == 0
? amount1
: quote(amount0, reserve0, reserve1);
// Adjust amounts to maintain price ratio and respect slippage limits
if (amount0Optimal <= amount0) {
require(amount0Optimal >= amount0Min, "Insufficient amount0");
amount0 = amount0Optimal;
} else {
require(amount1Optimal >= amount1Min, "Insufficient amount1");
amount1 = amount1Optimal;
}
// Transfer tokens from user to contract
IERC20(token0).safeTransferFrom(msg.sender, address(this), amount0);
IERC20(token1).safeTransferFrom(msg.sender, address(this), amount1);
// Calculate liquidity tokens to mint
if (reserve0 == 0 && reserve1 == 0) {
// First liquidity provider - mint sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY
liquidityMinted = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
liquidity[address(0)][token0][token1] = MINIMUM_LIQUIDITY;
} else {
// Subsequent providers - mint proportional to their contribution
liquidityMinted = Math.min(
(amount0 * liquidity[address(0)][token0][token1]) / reserve0,
(amount1 * liquidity[address(0)][token0][token1]) / reserve1
);
}
require(liquidityMinted > 0, "Insufficient liquidity minted");
// Update reserves and liquidity
reserves[token0][token1] = reserve0 + amount0;
reserves[token1][token0] = reserve1 + amount1;
liquidity[msg.sender][token0][token1] += liquidityMinted;
liquidity[address(0)][token0][token1] += liquidityMinted;
emit LiquidityAdded(msg.sender, token0, token1, amount0, amount1);
}
/**
* @notice Removes liquidity from a trading pair
* @dev Burns liquidity tokens and returns proportional amounts of both tokens
* @param tokenA The first token in the trading pair
* @param tokenB The second token in the trading pair
* @param liquidityAmount The amount of liquidity tokens to burn
* @param amountAMin The minimum amount of tokenA to receive (slippage protection)
* @param amountBMin The minimum amount of tokenB to receive (slippage protection)
* @return amount0 The amount of token0 returned
* @return amount1 The amount of token1 returned
*/
function removeLiquidity(
address tokenA,
address tokenB,
uint256 liquidityAmount,
uint256 amountAMin,
uint256 amountBMin
)
external
validTokens(tokenA, tokenB)
validAmount(liquidityAmount)
whenNotPaused
nonReentrant
returns (uint256 amount0, uint256 amount1)
{
// Sort tokens to ensure consistent ordering
bool isTokenASmaller = tokenA < tokenB;
address token0 = isTokenASmaller ? tokenA : tokenB;
address token1 = isTokenASmaller ? tokenB : tokenA;
uint256 amount0Min = isTokenASmaller ? amountAMin : amountBMin;
uint256 amount1Min = isTokenASmaller ? amountBMin : amountAMin;
// Check user has sufficient liquidity
uint256 totalLiquidity = liquidity[address(0)][token0][token1];
require(
liquidity[msg.sender][token0][token1] >= liquidityAmount,
"Insufficient liquidity"
);
// Get current reserves
uint256 reserve0 = reserves[token0][token1];
uint256 reserve1 = reserves[token1][token0];
// Calculate proportional amounts to return
amount0 = (liquidityAmount * reserve0) / totalLiquidity;
amount1 = (liquidityAmount * reserve1) / totalLiquidity;
// Validate minimum output amounts
require(
amount0 >= amount0Min && amount1 >= amount1Min,
"Insufficient output amount"
);
// Update reserves and liquidity
reserves[token0][token1] = reserve0 - amount0;
reserves[token1][token0] = reserve1 - amount1;
liquidity[msg.sender][token0][token1] -= liquidityAmount;
liquidity[address(0)][token0][token1] -= liquidityAmount;
// Transfer tokens to user
IERC20(token0).safeTransfer(msg.sender, amount0);
IERC20(token1).safeTransfer(msg.sender, amount1);
emit LiquidityRemoved(msg.sender, token0, token1, amount0, amount1);
}
/**
* @notice Swaps tokens using the constant product formula
* @dev Implements the x * y = k formula with fee calculation
* @param tokenIn The token being sold
* @param tokenOut The token being bought
* @param amountIn The amount of tokenIn to sell
* @param amountOutMin The minimum amount of tokenOut to receive (slippage protection)
* @return amountOut The amount of tokenOut received
*/
function swap(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 amountOutMin
)
external
validTokens(tokenIn, tokenOut)
validAmount(amountIn)
validAmount(amountOutMin)
whenNotPaused
nonReentrant
returns (uint256 amountOut)
{
// Determine token ordering for reserve lookup
bool isTokenInSmaller = tokenIn < tokenOut;
// Get current reserves
uint256 reserve0 = isTokenInSmaller
? reserves[tokenIn][tokenOut]
: reserves[tokenOut][tokenIn];
uint256 reserve1 = isTokenInSmaller
? reserves[tokenOut][tokenIn]
: reserves[tokenIn][tokenOut];
// Calculate output amount
amountOut = getAmountOut(amountIn, reserve0, reserve1);
require(amountOut >= amountOutMin, "Insufficient output amount");
// Transfer input tokens from user
IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);
// Update reserves (constant product formula with fees)
uint256 amountInWithFee = amountIn * (FEE_DENOMINATOR - fee);
uint256 numerator = amountInWithFee * reserve1;
uint256 denominator = (reserve0 * FEE_DENOMINATOR) + amountInWithFee;
amountOut = numerator / denominator;
// Update reserves
reserves[tokenIn][tokenOut] = reserve0 + amountIn;
reserves[tokenOut][tokenIn] = reserve1 - amountOut;
// Transfer output tokens to user
IERC20(tokenOut).safeTransfer(msg.sender, amountOut);
emit Swap(msg.sender, tokenIn, tokenOut, amountIn, amountOut);
}
// ============ VIEW FUNCTIONS ============
/**
* @notice Gets the current reserves for a trading pair
* @param tokenA The first token in the pair
* @param tokenB The second token in the pair
* @return reserveA The reserve of tokenA
* @return reserveB The reserve of tokenB
*/
function getReserves(
address tokenA,
address tokenB
) external view returns (uint256 reserveA, uint256 reserveB) {
(address token0, address token1) = tokenA < tokenB
? (tokenA, tokenB)
: (tokenB, tokenA);
reserveA = reserves[token0][token1];
reserveB = reserves[token1][token0];
(reserveA, reserveB) = tokenA < tokenB
? (reserveA, reserveB)
: (reserveB, reserveA);
}
/**
* @notice Calculates the output amount for a given input amount
* @dev Uses the constant product formula: amountOut = (amountIn * reserveOut * (10000 - fee)) / (reserveIn * 10000 + amountIn * (10000 - fee))
* @param amountIn The input amount
* @param reserveIn The input token reserve
* @param reserveOut The output token reserve
* @return The output amount
*/
function getAmountOut(
uint256 amountIn,
uint256 reserveIn,
uint256 reserveOut
) public view returns (uint256) {
require(amountIn > 0, "Insufficient input amount");
require(reserveIn > 0 && reserveOut > 0, "Insufficient liquidity");
uint256 amountInWithFee = amountIn * (FEE_DENOMINATOR - fee);
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = (reserveIn * FEE_DENOMINATOR) + amountInWithFee;
return numerator / denominator;
}
/**
* @notice Calculates the input amount needed for a given output amount
* @dev Uses the inverse of the constant product formula
* @param amountOut The desired output amount
* @param reserveIn The input token reserve
* @param reserveOut The output token reserve
* @return The required input amount
*/
function getAmountIn(
uint256 amountOut,
uint256 reserveIn,
uint256 reserveOut
) public view returns (uint256) {
require(amountOut > 0, "Insufficient output amount");
require(reserveIn > 0 && reserveOut > 0, "Insufficient liquidity");
require(amountOut < reserveOut, "Insufficient liquidity");
uint256 numerator = reserveIn * amountOut * FEE_DENOMINATOR;
uint256 denominator = (reserveOut - amountOut) *
(FEE_DENOMINATOR - fee);
return (numerator / denominator) + 1;
}
/**
* @notice Calculates the optimal amount of tokenB for a given amount of tokenA
* @dev Uses the simple ratio: amountB = (amountA * reserveB) / reserveA
* @param amountA The amount of tokenA
* @param reserveA The reserve of tokenA
* @param reserveB The reserve of tokenB
* @return amountB The optimal amount of tokenB
*/
function quote(
uint256 amountA,
uint256 reserveA,
uint256 reserveB
) public pure returns (uint256 amountB) {
require(amountA > 0, "Insufficient amount");
require(reserveA > 0 && reserveB > 0, "Insufficient liquidity");
amountB = (amountA * reserveB) / reserveA;
}
/**
* @notice Gets the liquidity position of a specific user for a trading pair
* @param user The user address
* @param tokenA The first token in the pair
* @param tokenB The second token in the pair
* @return The user's liquidity position
*/
function getUserLiquidity(
address user,
address tokenA,
address tokenB
) external view returns (uint256) {
(address token0, address token1) = tokenA < tokenB
? (tokenA, tokenB)
: (tokenB, tokenA);
return liquidity[user][token0][token1];
}
// ============ ADMIN FUNCTIONS ============
/**
* @notice Sets the trading fee (only owner)
* @param newFee The new fee in basis points (max 1000 = 10%)
*/
function setFee(uint256 newFee) external onlyOwner {
require(newFee <= 1000, "Fee too high"); // Max 10%
fee = newFee;
}
/**
* @notice Emergency withdrawal function (only owner)
* @param token The token to withdraw
* @param amount The amount to withdraw
*/
function emergencyWithdraw(
address token,
uint256 amount
) external onlyOwner {
IERC20(token).safeTransfer(owner(), amount);
}
// ============ EMERGENCY FUNCTIONS ============
/// @notice Whether the contract is paused
bool public paused;
/**
* @notice Sets the pause state of the contract (only owner)
* @param _paused True to pause, false to unpause
*/
function setPaused(bool _paused) external onlyOwner {
paused = _paused;
}
}
Amazing, we built an ERC 20 smart contract and our main SimpleDex contract, let’s check both contracts are compiling correctly without any errors. Run the following command to check whether our smart contracts are compiling correctly. But before compiling, we need to update our foundry.toml file.
Go to foundry.toml file and update this file with the following code.
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
optimizer = true
optimizer_runs = 200
via_ir = true
evm_version = "london"
solc_version = "0.8.20"
[rpc_endpoints]
rootstock_testnet = "https://public-node.testnet.rsk.co"
[etherscan]
rootstock_testnet = { key = "YOUR_API_KEY", url = "https://rootstock-testnet.blockscout.com/api" }
#add your api key - get this from Rootstock Blockscout explorer ( you can store it in env file.
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
Amazing, now run the following command to compile our contract.
forge build
And you will see both our contracts compiled successfully, without any errors or warnings.
Great! So far… We built an ERC 20 smart contract and our main SimpleDex contract, now it’s time to test our smart contracts to see if our contracts are working correctly as we expect.
We will first test our ERC20 smart contract and then the main contract. Let’s create a new file in test directory TestToken.t.sol and paste the following solidity code in this file.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {TestToken} from "../src/TestToken.sol";
import {Ownable) from "openzeppelin-contracts/contracts/access/Ownable.sol";
contract TestTokenTest is Test {
TestToken token;
address owner = address(0xABCD);
function setUp() public {
vm.prank(owner);
token = new TestToken("TestToken", "TTK", 1000);
}
function testInitialSupply() public {
assertEq(token.totalSupply(), 1000 * 1e18);
assertEq(token.balanceOf(owner), 1000 * 1e18);
}
function testMint() public {
vm.prank(owner);
token.mint(address(0x1234), 500 * 1e18);
assertEq(token.balanceOf(address(0x1234)), 500 * 1e18);
}
function testOnlyOwnerCanMint() public {
vm.prank(address(0x1234));
bytes memory revertData = abi.encodeWithSelector(
Ownable.OwnableUnauthorizedAccount.selector,
address(0x1234)
);
vm.expectRevert(revertData);
token.mint(address(0x1234), 1);
}
}
Awesome! We write some tests for our ERC 20 contract, and now let’s test our main SimpleDex contract. Create a new file inside test directory SimpleDex.t.sol and paste the following code in this file.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {SimpleDEX} from "../src/SimpleDEX.sol";
import {TestToken} from "../src/TestToken.sol";
contract SimpleDEXTest is Test {
SimpleDEX dex;
TestToken tokenA;
TestToken tokenB;
address owner = address(this);
address user = address(0x1234);
function setUp() public {
dex = new SimpleDEX();
tokenA = new TestToken("TokenA", "TKA", 10000);
tokenB = new TestToken("TokenB", "TKB", 10000);
tokenA.mint(user, 1000 ether);
tokenB.mint(user, 1000 ether);
tokenA.approve(address(dex), type(uint256).max);
tokenB.approve(address(dex), type(uint256).max);
vm.prank(user);
tokenA.approve(address(dex), type(uint256).max);
vm.prank(user);
tokenB.approve(address(dex), type(uint256).max);
}
function testAddLiquidity() public {
dex.addLiquidity(address(tokenA), address(tokenB), 1000 ether, 1000 ether, 900 ether, 900 ether);
(uint256 reserveA, uint256 reserveB) = dex.getReserves(address(tokenA), address(tokenB));
assertEq(reserveA, 1000 ether);
assertEq(reserveB, 1000 ether);
}
function testSwap() public {
dex.addLiquidity(address(tokenA), address(tokenB), 1000 ether, 1000 ether, 900 ether, 900 ether);
vm.startPrank(user);
dex.swap(address(tokenA), address(tokenB), 100 ether, 1);
vm.stopPrank();
assertGt(tokenB.balanceOf(user), 1000 ether); // user received some tokenB
}
function testSetFee() public {
dex.setFee(100);
assertEq(dex.fee(), 100);
}
function testEmergencyWithdraw() public {
dex.addLiquidity(address(tokenA), address(tokenB), 1000 ether, 1000 ether, 900 ether, 900 ether);
uint256 before = tokenA.balanceOf(owner);
dex.emergencyWithdraw(address(tokenA), 100 ether);
assertEq(tokenA.balanceOf(owner), before + 100 ether);
}
}
We have written some tests here; feel free to add more tests and test this contract. Now we have written tests, let’s see whether our tests pass or not. Run the following command to check this.
forge test
And there we go… All our contract tests are passed ✅
Congratulations on successfully building and testing Dex smart contracts. We are ready to deploy our contracts on the Rootstock network. Let’s write a deployment script and deploy our smart contract on the Rootstock testnet.
Go ahead and create a new file Deploy.s.sol file inside script directory and paste the following code in this file.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console} from "forge-std/Script.sol";
import {SimpleDEX} from "../src/SimpleDEX.sol";
import {TestToken} from "../src/TestToken.sol";
contract DeployScript is Script {
function run() external {
address deployer = msg.sender;
console.log("Deploying contracts with address:", deployer);
vm.startBroadcast();
// Deploy DEX contract
console.log("Deploying SimpleDEX...");
SimpleDEX dex = new SimpleDEX();
console.log("SimpleDEX deployed at:", address(dex));
// Deploy test tokens
console.log("Deploying TestToken A...");
TestToken tokenA = new TestToken("TestToken A", "TKA", 1000000); // 1M tokens
console.log("TestToken A deployed at:", address(tokenA));
console.log("Deploying TestToken B...");
TestToken tokenB = new TestToken("TestToken B", "TKB", 1000000); // 1M tokens
console.log("TestToken B deployed at:", address(tokenB));
// Mint some tokens to the deployer for initial liquidity
console.log("Minting tokens to deployer for initial liquidity...");
tokenA.mint(deployer, 100000 * 10 ** 18); // 100k tokens
tokenB.mint(deployer, 100000 * 10 ** 18); // 100k tokens
// Add initial liquidity to the DEX
console.log("Adding initial liquidity to DEX...");
tokenA.approve(address(dex), type(uint256).max);
tokenB.approve(address(dex), type(uint256).max);
dex.addLiquidity(
address(tokenA),
address(tokenB),
10000 * 10 ** 18, // 10k tokenA
10000 * 10 ** 18, // 10k tokenB
9000 * 10 ** 18, // 9k min tokenA
9000 * 10 ** 18 // 9k min tokenB
);
console.log("Initial liquidity added successfully!");
vm.stopBroadcast();
// Save deployment addresses
string memory deploymentInfo = string.concat(
"Deployment completed successfully!\n",
"Network: Rootstock Testnet\n",
"Deployer: ",
vm.toString(deployer),
"\n",
"SimpleDEX: ",
vm.toString(address(dex)),
"\n",
"TestToken A: ",
vm.toString(address(tokenA)),
"\n",
"TestToken B: ",
vm.toString(address(tokenB)),
"\n"
);
console.log(deploymentInfo);
}
}
Great! Our deployment script is ready, and we are fully ready to deploy our smart contracts onthe Rootstock testnet.
Ensure you have a Rootstock wallet set up and funded with Rootstock tokens for transaction fees.
To add Rootstock network to your wallet, follow the link: https://dev.rootstock.io/dev-tools/wallets/metamask/
To get Rootstock faucets, follow the link: https://faucet.rootstock.io/
Make sure you set up and imported your wallet in foundry key store: read here - https://book.getfoundry.sh/reference/cast/cast-wallet-import
To deploy our contracts, go ahead and open your terminal (make sure you are in the contracts directory) and run the following command in your terminal.
forge script script/Deploy.s.sol:DeployScript --rpc-url rootstock_testnet --account YOUR_WALLET_NAME --sender YOUR_WALLET_ADDRESS --broadcast --legacy
Example:
forge script script/Deploy.s.sol:DeployScript --rpc-url rootstock_testnet --account pandit --sender 0x339abb297eB21A0ee52E22e07DDe496c0fe98fB9 --broadcast --legacy
And wait for deployment, and boooooom!!! It will ask you for the password you set when setting up your key store. Enter the password, and fingers crosssed, there you go.
You can see a comprehensive report of the deployment of both our contracts. You will see our contracts successfully deployed on Rootstock testnet, and we added some initial liquidity in our contracts. ( see the image below )
Great! We have successfully built, tested, and deployed both TestToken and SimpleDex contracts on the Rootstock test Network.
Now you can visit the newly upgraded Rootstock testnet block explorer https://explorer.testnet.rootstock.io/ or Blockscout https://rootstock-testnet.blockscout.com/ and check whether our smart contract is indeed deployed or not.
Copy the contract address from your terminal and paste it in the search bar on the testnet blockscout explorer. ( see the image below )
Great! We checked that our smart contracts are indeed deployed, but they are not verified yet. So let’s verify them for our contracts’ integrity, and our code will be publicly available for community trust.
Go to https://rootstock-testnet.blockscout.com/account/api-key and log in with your wallet or email and get an api key, paste it right in front of the rootstock_testnet variable we declared in foundry.toml file. It should look like this.
[etherscan]
rootstock_testnet = { key = "YOUR_API_KEY", url = "https://rootstock-testnet.blockscout.com/api" }
Run the following command to verify our TestToken contract.
forge verify-contract --chain-id 31 --verifier blockscout --verifier-url https://rootstock-testnet.blockscout.com/api "YOUR_DEPLOYED_CONTRACT_ADDRESS" src/TestToken.sol:TestToken
Example:
forge verify-contract --chain-id 31 --verifier blockscout --verifier-url https://rootstock-testnet.blockscout.com/api "0xACd6DF74ed1D0055d6B57DD631C76779c0118412" src/TestToken.sol:TestToken
And boom… You will see a response OK, and your contract is verified on chain (see the image below)
Following this, let’s verify our main SimpleDEX contract
Run the following command to verify our main contract.
forge verify-contract --chain-id 31 --verifier blockscout --verifier-url https://rootstock-testnet.blockscout.com/api "YOUR_DEPLOYED_CONTRACT_ADDRESS" src/SimpleDex.sol:SimpleDEX
And there you go… You will see a response OK, our other contract is verified on chain.
Now go back to Blockscout Rootstock testnet block explorer and check the same contract address you verified, you will see contract details with a green checkmark, and our codeis also available there.
You will see the Read/Write contract option there; you can connect your wallet there and try interacting with our smart contract functions and play around with it!
Wrapping this up! Massive congratulations on building, testing, and deploying Dex’s smart contracts on the Rootstock network and verifying it! And now you have a pretty good understanding of how you can develop contracts on the Rootstock network using the same EVM stack.
If you're 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:
Full Dapp source code - https://github.com/panditdhamdhere/Simple_Dex
Roostock Discord: http://discord.gg/rootstock
Rootstock Telegram: @rskofficialcommunity
Rootstock Docs: https://dev.rootstock.io/
In the next part, we will build the frontend, connect these smart contracts to the frontend, and build a fully ready full-stack DEX dapp! Stay tuned for part 2!





