Project 3: Dex
Project 3: Decentralized Exchange (DEX) with Automated Market Maker
Overview
In this project, you'll build a decentralized exchange (DEX) with an automated market maker (AMM) based on the constant product formula (x * y = k). This project combines concepts from multiple modules, including ERC20 tokens, liquidity pools, security considerations, gas optimization, and advanced smart contract development.
Learning Objectives
- Implement a constant product automated market maker
- Create liquidity pools for token pairs
- Implement token swapping functionality
- Apply security best practices and design patterns
- Test and deploy the contracts to a testnet
Project Structure
dex-project/
├── contracts/
│ ├── DEXFactory.sol
│ ├── DEXPair.sol
│ ├── DEXRouter.sol
│ └── interfaces/
│ ├── IDEX.sol
│ └── IERC20.sol
├── test/
│ ├── DEXFactory.test.js
│ ├── DEXPair.test.js
│ ├── DEXRouter.test.js
│ └── integration.test.js
├── scripts/
│ ├── deploy.js
│ └── setup.js
└── README.md
Step 1: Setting Up the Development Environment
- Install Node.js and npm if you haven't already
- Create a new directory for your project
- Initialize a new Hardhat project:
mkdir dex-project
cd dex-project
npm init -y
npm install --save-dev hardhat @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers @openzeppelin/contracts
npx hardhat
- Select "Create a basic sample project" when prompted
- Configure your
hardhat.config.jsfile:
require("@nomiclabs/hardhat-waffle");
module.exports = {
solidity: {
version: "0.8.17",
settings: {
optimizer: {
enabled: true,
runs: 200
}
}
},
networks: {
hardhat: {
},
goerli: {
url: "https://goerli.infura.io/v3/YOUR_INFURA_KEY",
accounts: ["YOUR_PRIVATE_KEY"]
}
}
};
Step 2: Creating the Interfaces
First, let's create the interfaces for our DEX. Create a directory called interfaces in the contracts directory.
Create a file named IDEX.sol in the contracts/interfaces directory:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
interface IDEXFactory {
event PairCreated(address indexed token0, address indexed token1, address pair, uint256);
function feeTo() external view returns (address);
function feeToSetter() external view returns (address);
function getPair(address tokenA, address tokenB) external view returns (address pair);
function allPairs(uint256) external view returns (address pair);
function allPairsLength() external view returns (uint256);
function createPair(address tokenA, address tokenB) external returns (address pair);
function setFeeTo(address) external;
function setFeeToSetter(address) external;
}
interface IDEXPair {
event Approval(address indexed owner, address indexed spender, uint256 value);
event Transfer(address indexed from, address indexed to, uint256 value);
event Mint(address indexed sender, uint256 amount0, uint256 amount1);
event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to);
event Swap(
address indexed sender,
uint256 amount0In,
uint256 amount1In,
uint256 amount0Out,
uint256 amount1Out,
address indexed to
);
event Sync(uint112 reserve0, uint112 reserve1);
function MINIMUM_LIQUIDITY() external pure returns (uint256);
function factory() external view returns (address);
function token0() external view returns (address);
function token1() external view returns (address);
function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
function price0CumulativeLast() external view returns (uint256);
function price1CumulativeLast() external view returns (uint256);
function kLast() external view returns (uint256);
function mint(address to) external returns (uint256 liquidity);
function burn(address to) external returns (uint256 amount0, uint256 amount1);
function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;
function skim(address to) external;
function sync() external;
function initialize(address, address) external;
}
interface IDEXRouter {
function factory() external view returns (address);
function addLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin,
address to,
uint256 deadline
) external returns (uint256 amountA, uint256 amountB, uint256 liquidity);
function removeLiquidity(
address tokenA,
address tokenB,
uint256 liquidity,
uint256 amountAMin,
uint256 amountBMin,
address to,
uint256 deadline
) external returns (uint256 amountA, uint256 amountB);
function swapExactTokensForTokens(
uint256 amountIn,
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) external returns (uint256[] memory amounts);
function swapTokensForExactTokens(
uint256 amountOut,
uint256 amountInMax,
address[] calldata path,
address to,
uint256 deadline
) external returns (uint256[] memory amounts);
function quote(uint256 amountA, uint256 reserveA, uint256 reserveB) external pure returns (uint256 amountB);
function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) external pure returns (uint256 amountOut);
function getAmountIn(uint256 amountOut, uint256 reserveIn, uint256 reserveOut) external pure returns (uint256 amountIn);
function getAmountsOut(uint256 amountIn, address[] calldata path) external view returns (uint256[] memory amounts);
function getAmountsIn(uint256 amountOut, address[] calldata path) external view returns (uint256[] memory amounts);
}
Create a file named IERC20.sol in the contracts/interfaces directory:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
interface IERC20 {
event Approval(address indexed owner, address indexed spender, uint256 value);
event Transfer(address indexed from, address indexed to, uint256 value);
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function decimals() external view returns (uint8);
function totalSupply() external view returns (uint256);
function balanceOf(address owner) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transfer(address to, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
Step 3: Implementing the DEX Factory
Create a file named DEXFactory.sol in the contracts directory:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
import "./interfaces/IDEX.sol";
import "./DEXPair.sol";
/**
* @title DEXFactory
* @dev Factory contract for creating and managing DEX pairs
*/
contract DEXFactory is IDEXFactory {
address public override feeTo;
address public override feeToSetter;
mapping(address => mapping(address => address)) public override getPair;
address[] public override allPairs;
/**
* @dev Constructor
* @param _feeToSetter Address that can change the fee recipient
*/
constructor(address _feeToSetter) {
feeToSetter = _feeToSetter;
}
/**
* @dev Get the number of pairs
* @return The number of pairs created
*/
function allPairsLength() external view override returns (uint256) {
return allPairs.length;
}
/**
* @dev Create a new pair
* @param tokenA First token address
* @param tokenB Second token address
* @return pair Address of the created pair
*/
function createPair(address tokenA, address tokenB) external override returns (address pair) {
require(tokenA != tokenB, "DEX: IDENTICAL_ADDRESSES");
// Sort tokens to ensure deterministic pair addresses
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), "DEX: ZERO_ADDRESS");
require(getPair[token0][token1] == address(0), "DEX: PAIR_EXISTS");
// Create pair contract
bytes memory bytecode = type(DEXPair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
// Initialize pair
IDEXPair(pair).initialize(token0, token1);
// Store pair mappings
getPair[token0][token1] = pair;
getPair[token1][token0] = pair;
allPairs.push(pair);
emit PairCreated(token0, token1, pair, allPairs.length);
}
/**
* @dev Set the fee recipient
* @param _feeTo Address to receive fees
*/
function setFeeTo(address _feeTo) external override {
require(msg.sender == feeToSetter, "DEX: FORBIDDEN");
feeTo = _feeTo;
}
/**
* @dev Set the fee setter
* @param _feeToSetter Address that can change the fee recipient
*/
function setFeeToSetter(address _feeToSetter) external override {
require(msg.sender == feeToSetter, "DEX: FORBIDDEN");
feeToSetter = _feeToSetter;
}
}
Step 4: Implementing the DEX Pair
Create a file named DEXPair.sol in the contracts directory:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./interfaces/IDEX.sol";
import "./interfaces/IERC20.sol";
/**
* @title DEXPair
* @dev Liquidity pool for a pair of tokens
*/
contract DEXPair is IDEXPair, ERC20, ReentrancyGuard {
uint256 public constant override MINIMUM_LIQUIDITY = 10**3;
address public override factory;
address public override token0;
address public override token1;
uint112 private reserve0;
uint112 private reserve1;
uint32 private blockTimestampLast;
uint256 public override price0CumulativeLast;
uint256 public override price1CumulativeLast;
uint256 public override kLast; // reserve0 * reserve1, as of immediately after the most recent liquidity event
uint256 private unlocked = 1;
/**
* @dev Modifier to prevent reentrancy
*/
modifier lock() {
require(unlocked == 1, "DEX: LOCKED");
unlocked = 0;
_;
unlocked = 1;
}
/**
* @dev Constructor
*/
constructor() ERC20("DEX-LP", "DEX-LP") {
factory = msg.sender;
}
/**
* @dev Initialize the pair with tokens
* @param _token0 First token address
* @param _token1 Second token address
*/
function initialize(address _token0, address _token1) external override {
require(msg.sender == factory, "DEX: FORBIDDEN");
token0 = _token0;
token1 = _token1;
}
/**
* @dev Get the reserves of the pair
* @return _reserve0 Reserve of token0
* @return _reserve1 Reserve of token1
* @return _blockTimestampLast Timestamp of the last update
*/
function getReserves() public view override returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
_reserve0 = reserve0;
_reserve1 = reserve1;
_blockTimestampLast = blockTimestampLast;
}
/**
* @dev Update reserves and, on the first call per block, price accumulators
* @param balance0 Current balance of token0
* @param balance1 Current balance of token1
*/
function _update(uint256 balance0, uint256 balance1) private {
require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, "DEX: OVERFLOW");
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
if (timeElapsed > 0 && reserve0 != 0 && reserve1 != 0) {
// Update price accumulators
price0CumulativeLast += uint256(reserve1) * timeElapsed / reserve0;
price1CumulativeLast += uint256(reserve0) * timeElapsed / reserve1;
}
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = blockTimestamp;
emit Sync(reserve0, reserve1);
}
/**
* @dev Mint liquidity tokens
* @param to Address to receive liquidity tokens
* @return liquidity Amount of liquidity tokens minted
*/
function mint(address to) external override lock nonReentrant returns (uint256 liquidity) {
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
uint256 balance0 = IERC20(token0).balanceOf(address(this));
uint256 balance1 = IERC20(token1).balanceOf(address(this));
uint256 amount0 = balance0 - _reserve0;
uint256 amount1 = balance1 - _reserve1;
// Calculate protocol fee
bool feeOn = _mintFee(_reserve0, _reserve1);
// Calculate liquidity amount
uint256 _totalSupply = totalSupply();
if (_totalSupply == 0) {
// Initial liquidity
liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
_mint(address(0), MINIMUM_LIQUIDITY); // Permanently lock the first MINIMUM_LIQUIDITY tokens
} else {
// Subsequent liquidity
liquidity = Math.min(
(amount0 * _totalSupply) / _reserve0,
(amount1 * _totalSupply) / _reserve1
);
}
require(liquidity > 0, "DEX: INSUFFICIENT_LIQUIDITY_MINTED");
_mint(to, liquidity);
_update(balance0, balance1);
if (feeOn) kLast = uint256(reserve0) * reserve1; // Update kLast if fee is on
emit Mint(msg.sender, amount0, amount1);
}
/**
* @dev Burn liquidity tokens
* @param to Address to receive tokens
* @return amount0 Amount of token0 returned
* @return amount1 Amount of token1 returned
*/
function burn(address to) external override lock nonReentrant returns (uint256 amount0, uint256 amount1) {
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
address _token0 = token0;
address _token1 = token1;
uint256 balance0 = IERC20(_token0).balanceOf(address(this));
uint256 balance1 = IERC20(_token1).balanceOf(address(this));
uint256 liquidity = balanceOf(address(this));
// Calculate protocol fee
bool feeOn = _mintFee(_reserve0, _reserve1);
// Calculate token amounts
uint256 _totalSupply = totalSupply();
amount0 = (liquidity * balance0) / _totalSupply;
amount1 = (liquidity * balance1) / _totalSupply;
require(amount0 > 0 && amount1 > 0, "DEX: INSUFFICIENT_LIQUIDITY_BURNED");
_burn(address(this), liquidity);
// Transfer tokens to recipient
IERC20(_token0).transfer(to, amount0);
IERC20(_token1).transfer(to, amount1);
// Update reserves
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
_update(balance0, balance1);
if (feeOn) kLast = uint256(reserve0) * reserve1; // Update kLast if fee is on
emit Burn(msg.sender, amount0, amount1, to);
}
/**
* @dev Swap tokens
* @param amount0Out Amount of token0 to output
* @param amount1Out Amount of token1 to output
* @param to Address to receive tokens
* @param data Additional data for callbacks
*/
function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external override lock nonReentrant {
require(amount0Out > 0 || amount1Out > 0, "DEX: INSUFFICIENT_OUTPUT_AMOUNT");
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
require(amount0Out < _reserve0 && amount1Out < _reserve1, "DEX: INSUFFICIENT_LIQUIDITY");
uint256 balance0;
uint256 balance1;
{
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, "DEX: INVALID_TO");
// Transfer tokens to recipient
if (amount0Out > 0) IERC20(_token0).transfer(to, amount0Out);
if (amount1Out > 0) IERC20(_token1).transfer(to, amount1Out);
// Handle flash swap if data is provided
if (data.length > 0) {
// Call the callback function
IDEXCallee(to).dexCall(msg.sender, amount0Out, amount1Out, data);
}
// Check balances after swap
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
}
uint256 amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint256 amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, "DEX: INSUFFICIENT_INPUT_AMOUNT");
// Apply fee: 0.3%
uint256 balance0Adjusted = balance0 * 1000 - amount0In * 3;
uint256 balance1Adjusted = balance1 * 1000 - amount1In * 3;
// Check k value
require(balance0Adjusted * balance1Adjusted >= uint256(_reserve0) * _reserve1 * 1000**2, "DEX: K");
_update(balance0, balance1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}
/**
* @dev Force balances to match reserves
* @param to Address to receive excess tokens
*/
function skim(address to) external override lock {
address _token0 = token0;
address _token1 = token1;
IERC20(_token0).transfer(to, IERC20(_token0).balanceOf(address(this)) - reserve0);
IERC20(_token1).transfer(to, IERC20(_token1).balanceOf(address(this)) - reserve1);
}
/**
* @dev Force reserves to match balances
*/
function sync() external override lock {
_update(
IERC20(token0).balanceOf(address(this)),
IERC20(token1).balanceOf(address(this))
);
}
/**
* @dev Mint fee to feeTo address if applicable
* @param _reserve0 Reserve of token0
* @param _reserve1 Reserve of token1
* @return feeOn Whether fee is enabled
*/
function _mintFee(uint112 _reserve0, uint112 _reserve1) private returns (bool feeOn) {
address feeTo = IDEXFactory(factory).feeTo();
feeOn = feeTo != address(0);
uint256 _kLast = kLast; // Gas savings
if (feeOn) {
if (_kLast != 0) {
uint256 rootK = Math.sqrt(uint256(_reserve0) * _reserve1);
uint256 rootKLast = Math.sqrt(_kLast);
if (rootK > rootKLast) {
uint256 numerator = totalSupply() * (rootK - rootKLast);
uint256 denominator = rootK * 5 + rootKLast;
uint256 liquidity = numerator / denominator;
if (liquidity > 0) _mint(feeTo, liquidity);
}
}
} else if (_kLast != 0) {
kLast = 0;
}
}
}
/**
* @title Math
* @dev Math utilities
*/
library Math {
/**
* @dev Calculate the square root of a number
* @param y The number
* @return z The square root
*/
function sqrt(uint256 y) internal pure returns (uint256 z) {
if (y > 3) {
z = y;
uint256 x = y / 2 + 1;
while (x < z) {
z = x;
x = (y / x + x) / 2;
}
} else if (y != 0) {
z = 1;
}
}
/**
* @dev Return the minimum of two numbers
* @param x First number
* @param y Second number
* @return The minimum
*/
function min(uint256 x, uint256 y) internal pure returns (uint256) {
return x < y ? x : y;
}
}
/**
* @title IDEXCallee
* @dev Interface for flash swap callbacks
*/
interface IDEXCallee {
function dexCall(address sender, uint256 amount0, uint256 amount1, bytes calldata data) external;
}
Step 5: Implementing the DEX Router
Create a file named DEXRouter.sol in the contracts directory:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./interfaces/IDEX.sol";
/**
* @title DEXRouter
* @dev Router for interacting with DEX pairs
*/
contract DEXRouter is IDEXRouter, ReentrancyGuard {
address public immutable override factory;
modifier ensure(uint256 deadline) {
require(deadline >= block.timestamp, "DEXRouter: EXPIRED");
_;
}
/**
* @dev Constructor
* @param _factory Address of the factory contract
*/
constructor(address _factory) {
factory = _factory;
}
/**
* @dev Add liquidity to a pair
* @param tokenA First token address
* @param tokenB Second token address
* @param amountADesired Desired amount of tokenA
* @param amountBDesired Desired amount of tokenB
* @param amountAMin Minimum amount of tokenA
* @param amountBMin Minimum amount of tokenB
* @param to Address to receive LP tokens
* @param deadline Deadline for the transaction
* @return amountA Amount of tokenA added
* @return amountB Amount of tokenB added
* @return liquidity Amount of LP tokens minted
*/
function addLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin,
address to,
uint256 deadline
) external override ensure(deadline) nonReentrant returns (uint256 amountA, uint256 amountB, uint256 liquidity) {
// Create pair if it doesn't exist
address pair = IDEXFactory(factory).getPair(tokenA, tokenB);
if (pair == address(0)) {
pair = IDEXFactory(factory).createPair(tokenA, tokenB);
}
// Calculate optimal amounts
(amountA, amountB) = _calculateLiquidityAmounts(
tokenA,
tokenB,
amountADesired,
amountBDesired,
amountAMin,
amountBMin
);
// Transfer tokens to pair
_safeTransferFrom(tokenA, msg.sender, pair, amountA);
_safeTransferFrom(tokenB, msg.sender, pair, amountB);
// Mint LP tokens
liquidity = IDEXPair(pair).mint(to);
}
/**
* @dev Remove liquidity from a pair
* @param tokenA First token address
* @param tokenB Second token address
* @param liquidity Amount of LP tokens to burn
* @param amountAMin Minimum amount of tokenA
* @param amountBMin Minimum amount of tokenB
* @param to Address to receive tokens
* @param deadline Deadline for the transaction
* @return amountA Amount of tokenA received
* @return amountB Amount of tokenB received
*/
function removeLiquidity(
address tokenA,
address tokenB,
uint256 liquidity,
uint256 amountAMin,
uint256 amountBMin,
address to,
uint256 deadline
) external override ensure(deadline) nonReentrant returns (uint256 amountA, uint256 amountB) {
// Get pair address
address pair = IDEXFactory(factory).getPair(tokenA, tokenB);
require(pair != address(0), "DEXRouter: PAIR_DOES_NOT_EXIST");
// Transfer LP tokens to pair
IDEXPair(pair).transferFrom(msg.sender, pair, liquidity);
// Burn LP tokens
(amountA, amountB) = IDEXPair(pair).burn(to);
// Sort tokens
(address token0,) = _sortTokens(tokenA, tokenB);
(amountA, amountB) = tokenA == token0 ? (amountA, amountB) : (amountB, amountA);
// Check minimum amounts
require(amountA >= amountAMin, "DEXRouter: INSUFFICIENT_A_AMOUNT");
require(amountB >= amountBMin, "DEXRouter: INSUFFICIENT_B_AMOUNT");
}
/**
* @dev Swap exact tokens for tokens
* @param amountIn Exact amount of input tokens
* @param amountOutMin Minimum amount of output tokens
* @param path Path of token addresses
* @param to Address to receive output tokens
* @param deadline Deadline for the transaction
* @return amounts Amounts of tokens swapped
*/
function swapExactTokensForTokens(
uint256 amountIn,
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) external override ensure(deadline) nonReentrant returns (uint256[] memory amounts) {
// Calculate amounts
amounts = getAmountsOut(amountIn, path);
require(amounts[amounts.length - 1] >= amountOutMin, "DEXRouter: INSUFFICIENT_OUTPUT_AMOUNT");
// Transfer input tokens to first pair
_safeTransferFrom(
path[0],
msg.sender,
IDEXFactory(factory).getPair(path[0], path[1]),
amounts[0]
);
// Execute swap
_swap(amounts, path, to);
}
/**
* @dev Swap tokens for exact tokens
* @param amountOut Exact amount of output tokens
* @param amountInMax Maximum amount of input tokens
* @param path Path of token addresses
* @param to Address to receive output tokens
* @param deadline Deadline for the transaction
* @return amounts Amounts of tokens swapped
*/
function swapTokensForExactTokens(
uint256 amountOut,
uint256 amountInMax,
address[] calldata path,
address to,
uint256 deadline
) external override ensure(deadline) nonReentrant returns (uint256[] memory amounts) {
// Calculate amounts
amounts = getAmountsIn(amountOut, path);
require(amounts[0] <= amountInMax, "DEXRouter: EXCESSIVE_INPUT_AMOUNT");
// Transfer input tokens to first pair
_safeTransferFrom(
path[0],
msg.sender,
IDEXFactory(factory).getPair(path[0], path[1]),
amounts[0]
);
// Execute swap
_swap(amounts, path, to);
}
/**
* @dev Quote the amount of output tokens
* @param amountA Amount of input tokens
* @param reserveA Reserve of input tokens
* @param reserveB Reserve of output tokens
* @return amountB Amount of output tokens
*/
function quote(uint256 amountA, uint256 reserveA, uint256 reserveB) public pure override returns (uint256 amountB) {
require(amountA > 0, "DEXRouter: INSUFFICIENT_AMOUNT");
require(reserveA > 0 && reserveB > 0, "DEXRouter: INSUFFICIENT_LIQUIDITY");
amountB = (amountA * reserveB) / reserveA;
}
/**
* @dev Calculate the amount of output tokens for a given input
* @param amountIn Amount of input tokens
* @param reserveIn Reserve of input tokens
* @param reserveOut Reserve of output tokens
* @return amountOut Amount of output tokens
*/
function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) public pure override returns (uint256 amountOut) {
require(amountIn > 0, "DEXRouter: INSUFFICIENT_INPUT_AMOUNT");
require(reserveIn > 0 && reserveOut > 0, "DEXRouter: INSUFFICIENT_LIQUIDITY");
uint256 amountInWithFee = amountIn * 997; // 0.3% fee
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = reserveIn * 1000 + amountInWithFee;
amountOut = numerator / denominator;
}
/**
* @dev Calculate the amount of input tokens for a given output
* @param amountOut Amount of output tokens
* @param reserveIn Reserve of input tokens
* @param reserveOut Reserve of output tokens
* @return amountIn Amount of input tokens
*/
function getAmountIn(uint256 amountOut, uint256 reserveIn, uint256 reserveOut) public pure override returns (uint256 amountIn) {
require(amountOut > 0, "DEXRouter: INSUFFICIENT_OUTPUT_AMOUNT");
require(reserveIn > 0 && reserveOut > 0, "DEXRouter: INSUFFICIENT_LIQUIDITY");
uint256 numerator = reserveIn * amountOut * 1000;
uint256 denominator = (reserveOut - amountOut) * 997;
amountIn = (numerator / denominator) + 1;
}
/**
* @dev Calculate amounts out for a path
* @param amountIn Amount of input tokens
* @param path Path of token addresses
* @return amounts Amounts of tokens
*/
function getAmountsOut(uint256 amountIn, address[] calldata path) public view override returns (uint256[] memory amounts) {
require(path.length >= 2, "DEXRouter: INVALID_PATH");
amounts = new uint256[](path.length);
amounts[0] = amountIn;
for (uint256 i = 0; i < path.length - 1; i++) {
(uint256 reserveIn, uint256 reserveOut) = _getReserves(path[i], path[i + 1]);
amounts[i + 1] = getAmountOut(amounts[i], reserveIn, reserveOut);
}
}
/**
* @dev Calculate amounts in for a path
* @param amountOut Amount of output tokens
* @param path Path of token addresses
* @return amounts Amounts of tokens
*/
function getAmountsIn(uint256 amountOut, address[] calldata path) public view override returns (uint256[] memory amounts) {
require(path.length >= 2, "DEXRouter: INVALID_PATH");
amounts = new uint256[](path.length);
amounts[amounts.length - 1] = amountOut;
for (uint256 i = path.length - 1; i > 0; i--) {
(uint256 reserveIn, uint256 reserveOut) = _getReserves(path[i - 1], path[i]);
amounts[i - 1] = getAmountIn(amounts[i], reserveIn, reserveOut);
}
}
/**
* @dev Sort tokens by address
* @param tokenA First token address
* @param tokenB Second token address
* @return token0 Lower token address
* @return token1 Higher token address
*/
function _sortTokens(address tokenA, address tokenB) internal pure returns (address token0, address token1) {
require(tokenA != tokenB, "DEXRouter: IDENTICAL_ADDRESSES");
(token0, token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), "DEXRouter: ZERO_ADDRESS");
}
/**
* @dev Get reserves for a pair
* @param tokenA First token address
* @param tokenB Second token address
* @return reserveA Reserve of tokenA
* @return reserveB Reserve of tokenB
*/
function _getReserves(address tokenA, address tokenB) internal view returns (uint256 reserveA, uint256 reserveB) {
(address token0,) = _sortTokens(tokenA, tokenB);
address pair = IDEXFactory(factory).getPair(tokenA, tokenB);
require(pair != address(0), "DEXRouter: PAIR_DOES_NOT_EXIST");
(uint112 reserve0, uint112 reserve1,) = IDEXPair(pair).getReserves();
(reserveA, reserveB) = tokenA == token0 ? (reserve0, reserve1) : (reserve1, reserve0);
}
/**
* @dev Calculate optimal liquidity amounts
* @param tokenA First token address
* @param tokenB Second token address
* @param amountADesired Desired amount of tokenA
* @param amountBDesired Desired amount of tokenB
* @param amountAMin Minimum amount of tokenA
* @param amountBMin Minimum amount of tokenB
* @return amountA Optimal amount of tokenA
* @return amountB Optimal amount of tokenB
*/
function _calculateLiquidityAmounts(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin
) internal view returns (uint256 amountA, uint256 amountB) {
// Get pair reserves
(uint256 reserveA, uint256 reserveB) = (0, 0);
address pair = IDEXFactory(factory).getPair(tokenA, tokenB);
if (pair != address(0)) {
(reserveA, reserveB) = _getReserves(tokenA, tokenB);
}
if (reserveA == 0 && reserveB == 0) {
// Initial liquidity
(amountA, amountB) = (amountADesired, amountBDesired);
} else {
// Calculate optimal amounts
uint256 amountBOptimal = quote(amountADesired, reserveA, reserveB);
if (amountBOptimal <= amountBDesired) {
require(amountBOptimal >= amountBMin, "DEXRouter: INSUFFICIENT_B_AMOUNT");
(amountA, amountB) = (amountADesired, amountBOptimal);
} else {
uint256 amountAOptimal = quote(amountBDesired, reserveB, reserveA);
require(amountAOptimal <= amountADesired, "DEXRouter: EXCESSIVE_INPUT_AMOUNT");
require(amountAOptimal >= amountAMin, "DEXRouter: INSUFFICIENT_A_AMOUNT");
(amountA, amountB) = (amountAOptimal, amountBDesired);
}
}
}
/**
* @dev Execute a swap
* @param amounts Amounts of tokens
* @param path Path of token addresses
* @param _to Address to receive output tokens
*/
function _swap(uint256[] memory amounts, address[] memory path, address _to) internal {
for (uint256 i = 0; i < path.length - 1; i++) {
(address input, address output) = (path[i], path[i + 1]);
(address token0,) = _sortTokens(input, output);
uint256 amountOut = amounts[i + 1];
(uint256 amount0Out, uint256 amount1Out) = input == token0 ? (uint256(0), amountOut) : (amountOut, uint256(0));
address to = i < path.length - 2 ? IDEXFactory(factory).getPair(output, path[i + 2]) : _to;
IDEXPair(IDEXFactory(factory).getPair(input, output)).swap(
amount0Out,
amount1Out,
to,
new bytes(0)
);
}
}
/**
* @dev Safe transfer from
* @param token Token address
* @param from From address
* @param to To address
* @param value Amount to transfer
*/
function _safeTransferFrom(address token, address from, address to, uint256 value) private {
(bool success, bytes memory data) = token.call(
abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value)
);
require(
success && (data.length == 0 || abi.decode(data, (bool))),
"DEXRouter: TRANSFER_FAILED"
);
}
}
Step 6: Writing Tests
Create test files for each contract to ensure they work as expected. Here's an example for the DEXFactory contract:
// test/DEXFactory.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("DEXFactory", function () {
let factory;
let owner;
let feeSetter;
let feeRecipient;
let user;
beforeEach(async function () {
[owner, feeSetter, feeRecipient, user] = await ethers.getSigners();
const DEXFactory = await ethers.getContractFactory("DEXFactory");
factory = await DEXFactory.deploy(feeSetter.address);
await factory.deployed();
});
describe("Deployment", function () {
it("Should set the fee setter correctly", async function () {
expect(await factory.feeToSetter()).to.equal(feeSetter.address);
});
it("Should initialize with no fee recipient", async function () {
expect(await factory.feeTo()).to.equal(ethers.constants.AddressZero);
});
it("Should initialize with no pairs", async function () {
expect(await factory.allPairsLength()).to.equal(0);
});
});
describe("Pair Creation", function () {
let tokenA;
let tokenB;
beforeEach(async function () {
// Deploy test tokens
const TestToken = await ethers.getContractFactory("TestToken");
tokenA = await TestToken.deploy("Token A", "TKNA", ethers.utils.parseEther("1000000"));
tokenB = await TestToken.deploy("Token B", "TKNB", ethers.utils.parseEther("1000000"));
// Ensure tokenA address is less than tokenB for deterministic testing
if (tokenA.address > tokenB.address) {
[tokenA, tokenB] = [tokenB, tokenA];
}
});
it("Should create a pair", async function () {
await expect(factory.createPair(tokenA.address, tokenB.address))
.to.emit(factory, "PairCreated")
.withArgs(tokenA.address, tokenB.address, expect.anything(), 1);
expect(await factory.allPairsLength()).to.equal(1);
const pairAddress = await factory.getPair(tokenA.address, tokenB.address);
expect(pairAddress).to.not.equal(ethers.constants.AddressZero);
// Check reverse mapping
expect(await factory.getPair(tokenB.address, tokenA.address)).to.equal(pairAddress);
});
it("Should fail if tokens are identical", async function () {
await expect(factory.createPair(tokenA.address, tokenA.address))
.to.be.revertedWith("DEX: IDENTICAL_ADDRESSES");
});
it("Should fail if token address is zero", async function () {
await expect(factory.createPair(tokenA.address, ethers.constants.AddressZero))
.to.be.revertedWith("DEX: ZERO_ADDRESS");
});
it("Should fail if pair already exists", async function () {
await factory.createPair(tokenA.address, tokenB.address);
await expect(factory.createPair(tokenA.address, tokenB.address))
.to.be.revertedWith("DEX: PAIR_EXISTS");
});
});
describe("Fee Management", function () {
it("Should allow fee setter to set fee recipient", async function () {
await factory.connect(feeSetter).setFeeTo(feeRecipient.address);
expect(await factory.feeTo()).to.equal(feeRecipient.address);
});
it("Should allow fee setter to change fee setter", async function () {
await factory.connect(feeSetter).setFeeToSetter(user.address);
expect(await factory.feeToSetter()).to.equal(user.address);
});
it("Should fail if non-fee setter tries to set fee recipient", async function () {
await expect(factory.connect(user).setFeeTo(feeRecipient.address))
.to.be.revertedWith("DEX: FORBIDDEN");
});
it("Should fail if non-fee setter tries to change fee setter", async function () {
await expect(factory.connect(user).setFeeToSetter(user.address))
.to.be.revertedWith("DEX: FORBIDDEN");
});
});
});
// TestToken contract for testing
const TestToken = await ethers.getContractFactory("TestToken");
const tokenA = await TestToken.deploy("Token A", "TKNA", ethers.utils.parseEther("1000000"));
Step 7: Deployment Scripts
Create a deployment script to deploy your contracts:
// scripts/deploy.js
const { ethers } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying contracts with the account:", deployer.address);
// Deploy factory
const DEXFactory = await ethers.getContractFactory("DEXFactory");
const factory = await DEXFactory.deploy(deployer.address);
await factory.deployed();
console.log("DEXFactory deployed to:", factory.address);
// Deploy router
const DEXRouter = await ethers.getContractFactory("DEXRouter");
const router = await DEXRouter.deploy(factory.address);
await router.deployed();
console.log("DEXRouter deployed to:", router.address);
// For testing, deploy some tokens
const TestToken = await ethers.getContractFactory("TestToken");
const tokenA = await TestToken.deploy("Token A", "TKNA", ethers.utils.parseEther("1000000"));
await tokenA.deployed();
console.log("Token A deployed to:", tokenA.address);
const tokenB = await TestToken.deploy("Token B", "TKNB", ethers.utils.parseEther("1000000"));
await tokenB.deployed();
console.log("Token B deployed to:", tokenB.address);
// Create pair
await factory.createPair(tokenA.address, tokenB.address);
console.log("Pair created for Token A and Token B");
const pairAddress = await factory.getPair(tokenA.address, tokenB.address);
console.log("Pair address:", pairAddress);
console.log("Deployment completed!");
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Step 8: Creating a Test Token Contract
For testing purposes, create a simple ERC20 token contract:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* @title TestToken
* @dev A simple ERC20 token for testing
*/
contract TestToken is ERC20 {
/**
* @dev Constructor
* @param name Token name
* @param symbol Token symbol
* @param initialSupply Initial supply of tokens
*/
constructor(
string memory name,
string memory symbol,
uint256 initialSupply
) ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
}
}
Step 9: Running the Project
- Compile the contracts:
npx hardhat compile
- Run the tests:
npx hardhat test
- Deploy to a testnet:
npx hardhat run scripts/deploy.js --network goerli
Step 10: Interacting with the DEX
Here's a script to interact with the DEX:
// scripts/interact.js
const { ethers } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
// Contract addresses (replace with your deployed addresses)
const factoryAddress = "YOUR_FACTORY_ADDRESS";
const routerAddress = "YOUR_ROUTER_ADDRESS";
const tokenAAddress = "YOUR_TOKEN_A_ADDRESS";
const tokenBAddress = "YOUR_TOKEN_B_ADDRESS";
// Get contract instances
const factory = await ethers.getContractAt("DEXFactory", factoryAddress);
const router = await ethers.getContractAt("DEXRouter", routerAddress);
const tokenA = await ethers.getContractAt("TestToken", tokenAAddress);
const tokenB = await ethers.getContractAt("TestToken", tokenBAddress);
// Get pair address
const pairAddress = await factory.getPair(tokenAAddress, tokenBAddress);
const pair = await ethers.getContractAt("DEXPair", pairAddress);
console.log("Pair address:", pairAddress);
// Approve router to spend tokens
await tokenA.approve(routerAddress, ethers.utils.parseEther("1000"));
await tokenB.approve(routerAddress, ethers.utils.parseEther("1000"));
console.log("Approved router to spend tokens");
// Add liquidity
const amountA = ethers.utils.parseEther("100");
const amountB = ethers.utils.parseEther("100");
await router.addLiquidity(
tokenAAddress,
tokenBAddress,
amountA,
amountB,
0,
0,
deployer.address,
Math.floor(Date.now() / 1000) + 60 * 20 // 20 minutes from now
);
console.log("Added liquidity");
// Get reserves
const reserves = await pair.getReserves();
console.log("Reserve0:", ethers.utils.formatEther(reserves[0]));
console.log("Reserve1:", ethers.utils.formatEther(reserves[1]));
// Swap tokens
const swapAmount = ethers.utils.parseEther("1");
// Get amount out
const amountOut = await router.getAmountOut(
swapAmount,
reserves[0],
reserves[1]
);
console.log("Expected amount out:", ethers.utils.formatEther(amountOut));
// Execute swap
await router.swapExactTokensForTokens(
swapAmount,
0, // Min amount out
[tokenAAddress, tokenBAddress],
deployer.address,
Math.floor(Date.now() / 1000) + 60 * 20 // 20 minutes from now
);
console.log("Swapped tokens");
// Get updated reserves
const updatedReserves = await pair.getReserves();
console.log("Updated Reserve0:", ethers.utils.formatEther(updatedReserves[0]));
console.log("Updated Reserve1:", ethers.utils.formatEther(updatedReserves[1]));
// Get token balances
const balanceA = await tokenA.balanceOf(deployer.address);
const balanceB = await tokenB.balanceOf(deployer.address);
console.log("Token A balance:", ethers.utils.formatEther(balanceA));
console.log("Token B balance:", ethers.utils.formatEther(balanceB));
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Conclusion
In this project, you've built a decentralized exchange (DEX) with an automated market maker (AMM) based on the constant product formula. This project demonstrates several advanced Solidity concepts:
- Automated Market Maker: Implemented the constant product formula (x * y = k) for price discovery
- Liquidity Pools: Created pools for token pairs where users can provide liquidity
- Token Swapping: Implemented token swapping functionality with price slippage protection
- Protocol Fees: Added support for protocol fees to generate revenue
- Flash Swaps: Implemented the foundation for flash swaps
You've applied concepts from multiple modules, including:
- ERC20 token standard (Module 2: Solidity Basics)
- Access control and security (Module 8: Security Considerations)
- Gas optimization (Module 9: Gas Optimization)
- Design patterns (Module 10: Design Patterns)
- Advanced contract interactions (Module 11: Advanced Smart Contract Development)
This project demonstrates how to build a complex DeFi application with multiple interacting contracts. You can extend it by adding features like:
- Flash loans
- Price oracles
- Staking rewards for liquidity providers
- Governance mechanisms
- Multi-chain support
The DEX you've built provides a solid foundation for understanding how decentralized exchanges work and how to implement complex financial logic in smart contracts.