Decentralized Exchanges (DEXes) play a crucial part in the world of decentralized finance (DeFi), offering users complete control over their assets while simultaneously providing a platform for the trustless transaction of cryptocurrencies. Traditional centralized exchanges (CEXes) require users to deposit assets on the platform and relinquish their private keys, creating a centralized point of failure.
Are you interested in creating your own decentralized exchange on the Ethereum blockchain? Look no further! This article will guide you through the process, providing code examples in Solidity and setting up a development environment using HardHat.
Decentralized Exchange Functionality
A basic decentralized exchange should allow users to:
- Deposit tokens or ether.
- Trade tokens or ether in a trustless manner.
- Withdraw tokens or ether.
To achieve this functionality, we’ll build our DEX using Ethereum’s most popular smart contract programming language, Solidity. To make our development process streamlined and efficient, we’ll use the HardHat development environment.
Setting Up the Development Environment
First, let’s set up our development environment using HardHat. Follow these simple steps:
- Install Node.js if you haven’t done so already. You can download it here.
- Create a new directory for your project and navigate to it in your terminal or command prompt.
- In the project directory, run the command
npm init
to initialize a new Node.js project. - Install HardHat by executing
npm install --save-dev hardhat
. - Run
npx hardhat
to generate a sample HardHat config file, and choose to create a sample project when prompted. - Delete the sample contracts (
Greeter.sol
andToken.sol
) as well as the test file (sample-test.js
).
Now, you have a blank HardHat project set up, and you’re ready to start writing your own decentralized exchange from scratch.
Creating a Token for Our Exchange
First, we’ll create an ERC20 token for our demonstration. An ERC20 token is a widely accepted token standard on the Ethereum blockchain, allowing for seamless interoperability between different decentralized applications.
In your project directory, create a new directory called contracts
. Within the contracts
directory, create a new file called MyToken.sol
. The code for our simple ERC20 token is as follows:
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
constructor(uint256 initialSupply) ERC20("MyToken", "MTK") {
_mint(msg.sender, initialSupply);
}
}
Here, we make use of the ERC20
contract provided by the OpenZeppelin library. It takes care of the nitty-gritty of ERC20 token implementation, so we can simply provide an initial supply and our token’s name and symbol.
Building the Decentralized Exchange Contract
Next, create a new file called DEX.sol
in the contracts
directory. This is where we’ll develop our decentralized exchange’s smart contract.
Initialization
Let’s start by defining the basic structure of our contract and initializing it:
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract DEX {
using SafeERC20 for ERC20; // Use OpenZeppelin's SafeERC20 to deal with tokens securely
address public owner; // The owner of the DEX
constructor() {
owner = msg.sender;
}
}
Here, we import both the ERC20
and SafeERC20
contracts from the OpenZeppelin library. We also set the DEX owner to the address that deploys the contract.
Adding Token Support
Now, let’s build the functionality for adding new tokens to our DEX:
struct Token {
ERC20 tokenContract;
uint256 totalLiquidity;
}
mapping(address => Token) public tokens; // Address of token contract to Token struct
function addToken(address tokenAddress) public {
require(msg.sender == owner, "Only owner can add tokens.");
ERC20 token = ERC20(tokenAddress);
tokens[tokenAddress] = Token({tokenContract: token, totalLiquidity: 0});
}
We define a new struct Token
, which holds an instance of the token’s contract and the token’s total liquidity on our DEX. We create a mapping from the token’s contract address to the Token
struct. Then, we add a function addToken
that allows the owner of the DEX to add new tokens by providing the token’s contract address.
Notice the require
statement to ensure that only the owner can add tokens: it checks if the function caller is the owner, and if not, the transaction is reverted with a custom error message.
Deposit and Withdraw Functions
Next, we’ll implement the deposit
and withdraw
functions. Users should be able to deposit and withdraw tokens as well as ether. For token deposits and withdrawals, we’ll make use of the transfer
and transferFrom
functions provided by the ERC20
contract.
Create the following functions in the DEX.sol
file:
mapping(address => mapping(address => uint256)) public tokenBalances; // User address -> token address -> token balance
function depositToken(address tokenAddress, uint256 amount) public {
require(tokenAddress != address(0), "Cannot deposit zero address token."); // Safety check
require(tokens[tokenAddress].tokenContract != ERC20(address(0)), "Token not supported by DEX.");
tokens[tokenAddress].tokenContract.safeTransferFrom(msg.sender, address(this), amount);
tokenBalances[msg.sender][tokenAddress] += amount;
}
function deposit() payable public {
require(msg.value > 0, "Cannot deposit zero value."); // Safety check
tokenBalances[msg.sender][address(0)] += msg.value; // ETH is stored as zero address
}
mapping(address => mapping(address => uint256)) public etherBalances; // User address -> token address -> ether balance
function withdrawToken(address tokenAddress, uint256 amount) public {
require(tokenAddress != address(0), "Cannot withdraw zero address token.");
require(tokens[tokenAddress].tokenContract != ERC20(address(0)), "Token not supported by DEX.");
tokenBalances[msg.sender][tokenAddress] -= amount;
tokens[tokenAddress].tokenContract.safeTransfer(msg.sender, amount);
}
function withdraw(uint256 amount) public {
require(etherBalances[msg.sender][address(0)] >= amount, "Insufficient ether balance.");
etherBalances[msg.sender][address(0)] -= amount;
payable(msg.sender).transfer(amount);
}
With these four functions, users can deposit and withdraw both tokens and ether, and we update their balances accordingly.
Trading
Now for the most important part: the trading functionality!
First, create a struct for an order:
struct Order {
address trader;
address token;
uint256 tokensTotal;
uint256 tokensLeft;
uint256 etherAmount;
uint256 filled;
}
Then, define an array to store open orders and a mapping to store order history. We also implement an event to emit when a new order is created:
Order[] public openOrders;
mapping(address => Order[]) public orderHistories;
event OrderPlaced(uint256 orderId, address indexed trader, address indexed token, uint256 tokensTotal, uint256 etherAmount);
We can now create the function for placing a new order:
function placeOrder(address token, uint256 tokensTotal, uint256 etherAmount) public {
require(tokens[token].tokenContract != ERC20(address(0)), "Token not supported by DEX.");
require(tokenBalances[msg.sender][token] >= tokensTotal, "Insufficient token balance.");
Order memory newOrder = Order({
trader: msg.sender,
token: token,
tokensTotal: tokensTotal,
tokensLeft: tokensTotal,
etherAmount: etherAmount,
filled: 0
});
openOrders.push(newOrder);
tokenBalances[msg.sender][token] -= tokensTotal;
emit OrderPlaced(openOrders.length-1, msg.sender, token, tokensTotal, etherAmount);
}
Finally, let’s implement the fillOrder
function, allowing users to complete open orders:
event OrderFilled(uint256 orderId, address indexed trader, uint256 tokensFilled, uint256 etherTransferred);
function fillOrder(uint256 orderId) public {
Order storage order = openOrders[orderId];
uint256 tokensToFill = order.tokensLeft;
uint256 etherToFill = (tokensToFill * order.etherAmount) / order.tokensTotal;
require(etherBalances[msg.sender][address(0)] >= etherToFill, "Insufficient ether balance.");
etherBalances[order.trader][address(0)] += etherToFill;
etherBalances[msg.sender][address(0)] -= etherToFill;
tokenBalances[msg.sender][order.token] += tokensToFill;
order.tokensLeft = 0;
order.filled += tokensToFill;
orderHistories[msg.sender].push(order);
if (order.trader != msg.sender) orderHistories[order.trader].push(order);
delete openOrders[orderId];
emit OrderFilled(orderId, order.trader, tokensToFill, etherToFill);
}
With the fillOrder
function, users can complete orders, exchanging their ether for tokens. The order is then removed from the open orders list and added to each party’s order history.
Testing and Deployment
With our DEX contract completed, you can now deploy it to a local Ethereum blockchain using HardHat, test your code, or even deploy it to a public Ethereum test network.
Well done! You’ve now created your own decentralized exchange on the Ethereum blockchain. From here, you can extend the functionality of your DEX, add more trading pairs, or improve the user interface by implementing a frontend application.
References
- Ethereum.org: Solidity
- OpenZeppelin: Smart Contract Library
- HardHat: Ethereum Development Environment
The post How to Create Your Own Decentralized Exchange (DEX) in Solidity & HardHat appeared first on CryptoLoom.
Top comments (0)