Finally done with this project!!
I was pushing myself into not watching the help videos and just trying to make it work with the market order, the sorting of the order book and the logics behind it. It feels so great to have done this
I think the “next step” for this dex would ofcourse be to build a front end to this and also create functionality for limit orders acting as market orders.
Example:
SELL: 1:200 wei, 1:300 wei
BUY: 2:200 wei
The BUY limit order then should be able to buy one token from the first seller in this example
I am super excited to begin with the course Ethereum Smart Contract Security to keep on getting better
dex.sol:
pragma solidity 0.8.0;
pragma experimental ABIEncoderV2;
import "./wallet.sol";
contract Dex is Wallet {
using SafeMath for uint256;
//This enum is created for differentiating which side you are trading on (BUY or SELL)
enum Side {
BUY,
SELL
}
//This is an order struct which contains several attributes used through the contract
struct Order {
uint id;
address trader;
Side side;
bytes32 ticker;
uint amount;
uint price;
uint filled;
}
//This is used for adding id numbers to limit orders
uint public nextOrderId = 0;
//This is for mapping the orderbook of a specific token and which side (BUY or SELL) it is
mapping(bytes32 => mapping(uint => Order[])) public orderBook;
//This is created for depositing ETH and stores the ETH in the balance mapping
function depositEth() public payable {
require(0 < msg.value, "Dex: Value needs to be over 0");
balances[msg.sender][bytes32("ETH")] = balances[msg.sender][bytes32("ETH")].add(msg.value);
}
/*
* This function gets the order book for a specific side (SELL or BUY)
*/
function getOrderBook(bytes32 ticker, Side side) view public returns(Order[] memory) {
return orderBook[ticker][uint(side)];
}
/*
* This function is for adding limit orders to the order book based on if it is a BUY or SELL order.
*
* The token needs to be added by the contract owner before being able to create limit orders.
*
* If it is a BUY order then the eth balance is checked and if it is a sell order then the
* token balance is checked.
*
* The order gets added and then if the orders are unsorted the function sorts them in correct order.
*/
function createLimitOrder(Side side, bytes32 ticker, uint256 amount, uint price) tokenExist(ticker) public {
require(0 < amount, "Dex: Amount needs to be higher than 0");
//Checks of token balance depending on if it is a BUY or SELL order
if(side == Side.BUY){
require(amount.mul(price) <= balances[msg.sender]["ETH"], "Dex: Eth balance needs to be higher than the order value");
}
else if(side == Side.SELL) {
require(amount <= balances[msg.sender][ticker], "Dex: Token balance needs to be higher than or equal to the sell order amount");
}
//Stores the order book in a variable and pushes the order in the array.
Order[] storage orders = orderBook[ticker][uint(side)];
orders.push(
Order(nextOrderId, msg.sender, side, ticker, amount, price, 0)
);
//Depending on if it is a BUY or SELL order then the order gets sorted in the correct order
if(side == Side.BUY){
for(uint i=1;i<orders.length;i++){
Order memory moveOrder;
moveOrder = orders[orders.length-i];
if(orders[orders.length-i-1].price < moveOrder.price){
orders[orders.length-i] = orders[orders.length-i-1];
orders[orders.length-i-1] = moveOrder;
}
else if(orders[orders.length-i-1].price >= moveOrder.price){
break;
}
}
}
else if(side == Side.SELL){
for(uint i=1;i<orders.length;i++){
Order memory moveOrder;
moveOrder = orders[orders.length-i];
if(orders[orders.length-i-1].price > moveOrder.price){
orders[orders.length-i] = orders[orders.length-i-1];
orders[orders.length-i-1] = moveOrder;
}
else if(orders[orders.length-i-1].price <= moveOrder.price){
break;
}
}
}
//This variable gets incremented for the next orders
nextOrderId++;
}
/*
* This function is for making market orders on the dex.
*
* The token needs to be added by the contract owner before being able to create limit orders.
*
* If it is a BUY order then the ETH balance of the total order cost is checked.
* Example: 3 orders in order book for a total of 8000 wei, msg.sender has created a market
* order but only has 7000 wei. Then the order is reverted due to that the total eth price
* for the trade costs more than the owned amount.
*
* If it is a SELL order then the token balance of the order is checked.
*
* The orders that is 100 % filled also gets cleaned by the internal function "cleanOrderBook".
*/
function createMarketOrder(Side side, bytes32 ticker, uint amount) tokenExist(ticker) public{
uint orderBookSide;
if(side == Side.BUY){
orderBookSide = 1;
//Checks if the buyer has enough ETH balance for the trade
checkEthBalance(ticker, amount);
}
else{
orderBookSide = 0;
//Checks that the msg.sender has sufficient token balance to sell
require(balances[msg.sender][ticker] >= amount, "Insufficient balance");
}
Order[] storage orders = orderBook[ticker][orderBookSide];
uint totalFilled;
/*
* Loops through the entire orderbook and shifts balances with as many orders possible for
* the market order.
*
* After the loop is completed then the order book gets cleaned up.
*/
for (uint256 i = 0; i < orders.length && totalFilled < amount; i++) {
//Checks the amount that can be filled of the current order
uint indexAmountToFill = orders[i].amount.sub(orders[i].filled);
uint indexPrice;
//Checks if the amount left to fill is less than or equal than the amount to fill
if(amount.sub(totalFilled) <= indexAmountToFill){
//Updates the filled attribute in the order
orders[i].filled = orders[i].filled.add(amount.sub(totalFilled));
//Total price of the amount to fill and the order's price
indexPrice = orders[i].price.mul(amount.sub(totalFilled));
//Shifts the balances between buyer/seller
shiftBalances(orders[i].trader, ticker, orderBookSide, indexPrice, indexAmountToFill);
//Update of the totalFilled variable (used for the for loop)
totalFilled = totalFilled.add(amount.sub(totalFilled));
}
else{
orders[i].filled = orders[i].filled.add(indexAmountToFill);
indexPrice = orders[i].price.mul(indexAmountToFill);
//Shifts the balances between buyer/seller
shiftBalances(orders[i].trader, ticker, orderBookSide, indexPrice, indexAmountToFill);
totalFilled = totalFilled.add(indexAmountToFill);
}
}
//Loops through the orderbook and removes the 100% filled orders
cleanOrderBook(orderBookSide, ticker);
}
//This function is created for shifting the balances between buyer and seller in a market order
function shiftBalances(address fromTrader, bytes32 ticker, uint orderBookSide, uint indexPrice, uint indexAmountToFill) internal{
if(orderBookSide == 1){
balances[msg.sender][ticker] = balances[msg.sender][ticker].add(indexAmountToFill);
balances[msg.sender]["ETH"] = balances[msg.sender]["ETH"].sub(indexPrice);
balances[fromTrader][ticker] = balances[fromTrader][ticker].sub(indexAmountToFill);
balances[fromTrader]["ETH"] = balances[fromTrader]["ETH"].add(indexPrice);
}
else{
balances[msg.sender][ticker] = balances[msg.sender][ticker].sub(indexAmountToFill);
balances[msg.sender]["ETH"] = balances[msg.sender]["ETH"].add(indexPrice);
balances[fromTrader][ticker] = balances[fromTrader][ticker].add(indexAmountToFill);
balances[fromTrader]["ETH"] = balances[fromTrader]["ETH"].sub(indexPrice);
}
}
//This function is created for checking ETH balance on a market BUY order
function checkEthBalance(bytes32 ticker, uint amount) view internal{
Order[] memory orders = orderBook[ticker][1];
uint totalEthPrice;
uint totalAmountToTrade;
/*
* Loops through the entire orderbook for up to the the market order amount
* gets calculated with the existing orders and the amount that exists.
*
* The price is added for each iteration of the loop to totalEthPrice and
* the balance of msg.sender gets checked at the end of the loop.
*/
for (uint256 u = 0; u < orders.length && totalAmountToTrade < amount; u++) {
uint indexTradeableAmount = orders[u].amount.sub(orders[u].filled);
if(amount.sub(totalAmountToTrade) <= indexTradeableAmount){
totalEthPrice = totalEthPrice.add(orders[u].price.mul(amount.sub(totalAmountToTrade)));
totalAmountToTrade = totalAmountToTrade.add(amount.sub(totalAmountToTrade));
}
else{
totalEthPrice = totalEthPrice.add(orders[u].price.mul(indexTradeableAmount));
totalAmountToTrade = totalAmountToTrade.add(indexTradeableAmount);
}
}
//Checks if the ETH balance of msg.sender is higher than the total cost
require(balances[msg.sender]["ETH"] >= totalEthPrice, "Not enough ETH balance for the trade");
}
//This function is created for checking the orderbook and cleaning the filled orders
function cleanOrderBook(uint orderBookSide, bytes32 ticker) internal{
Order[] storage orders = orderBook[ticker][orderBookSide];
/*
* First this checks if the current index order is 100 % filled.
*
* For each time it is a 100 % filled order then it gets replaced, the loop gets sorted and the last array index gets poped
*/
for(uint256 i = 0; i < orders.length; i) {
if(orders.length == 1){
if(orders[i].amount == orders[i].filled){
orders.pop();
}
else{
break;
}
}
else if(orders[i].amount == orders[i].filled){
Order memory moveOrder;
for(uint256 u = 0; u < orders.length - 1; u++){
moveOrder = orders[u+1];
orders[u] = moveOrder;
}
orders.pop();
}
else{
break;
}
}
}
}
wallet.sol:
pragma solidity 0.8.0;
import "../node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../node_modules/@openzeppelin/contracts/utils/math/SafeMath.sol";
import "../node_modules/@openzeppelin/contracts/access/Ownable.sol";
contract Wallet is Ownable {
using SafeMath for uint256;
struct Token {
bytes32 ticker;
address tokenAddress;
}
//This mapping shows an added token and its address.
mapping(bytes32 => Token) public tokenMapping;
bytes32[] public tokenList;
//This is a mapping for displaying the total amount of a token a specific address has.
mapping(address => mapping (bytes32 => uint256)) public balances;
//This modifier can be used to check if the token is added to the exchange or not.
modifier tokenExist(bytes32 ticker){
require(tokenMapping[ticker].tokenAddress != address(0), "Wallet: Token does not exist");
_;
}
/*
* This adds the token to the exchange.
*
* Only the owner of the contract is able to add tokens.
*/
function addToken(bytes32 ticker, address tokenAddress) onlyOwner external {
tokenMapping[ticker] = Token(ticker, tokenAddress);
tokenList.push(ticker);
}
/*
* Created for depositing a ERC20-token.
*
* This token needs to be added before it can be deposited through the "addToken"-function.
*/
function deposit(uint amount, bytes32 ticker) tokenExist(ticker) external {
IERC20(tokenMapping[ticker].tokenAddress).transferFrom(msg.sender, address(this), amount);
balances[msg.sender][ticker] = balances[msg.sender][ticker].add(amount);
}
/*
* This is for withdrawing a token.
*
* First the function is checking if the token exists. Then it checks
* if the msg.sender has the balance of the specified token.
*/
function withdraw(uint amount, bytes32 ticker) tokenExist(ticker) external {
require(amount <= balances[msg.sender][ticker], "Wallet: Balance not sufficient");
balances[msg.sender][ticker] = balances[msg.sender][ticker].sub(amount);
IERC20(tokenMapping[ticker].tokenAddress).transfer(msg.sender, amount);
}
}
market_order_test.js:
const Dex = artifacts.require("Dex")
const Link = artifacts.require("Link")
const truffleAssert = require('truffle-assertions');
contract("Dex", accounts => {
//When creating a SELL market order, the seller needs to have enough tokens for the trade.
it("should throw an error if token balance is too low when creating SELL market order", async () => {
let dex = await Dex.deployed()
let balance = await dex.balances(accounts[0], web3.utils.fromUtf8("LINK"))
assert.equal( balance.toNumber(), 0, "Initial LINK balance is not 0" )
await truffleAssert.reverts(
dex.createMarketOrder(1, web3.utils.fromUtf8("LINK"), 10000)
)
})
//When creating a BUY market order, the buyer needs to have enough ETH for the trade.
it("should throw an error if ETH balance is too low when creating market BUY order", async () => {
let dex = await Dex.deployed()
let link = await Link.deployed()
let balance = await dex.balances(accounts[0], web3.utils.fromUtf8("ETH"))
assert.equal( balance.toNumber(), 0, "Initial ETH balance is not 0" )
//Token "LINK" added
//5 tokens approved for accounts[0]
//5 LINK deposited
await dex.addToken(web3.utils.fromUtf8("LINK"), link.address)
await link.approve(dex.address, 5)
await dex.deposit(5, web3.utils.fromUtf8("LINK"))
//Sell limit: 5 LINK, 100 wei per token
await dex.createLimitOrder(1, web3.utils.fromUtf8("LINK"), 5, 100)
await truffleAssert.reverts(
dex.createMarketOrder(0, web3.utils.fromUtf8("LINK"), 7)
)
await dex.depositEth({value: 500})
/*
* To remove the sell limit order.
* Market BUY: 5 LINK for 500 wei
*/
await dex.createMarketOrder(0, web3.utils.fromUtf8("LINK"), 5)
})
//Market orders can be submitted even if the order book is empty
it("should not throw an error if order book is empty when creating market order", async () => {
let dex = await Dex.deployed()
await dex.depositEth({value: 10000})
let orderbook = await dex.getOrderBook(web3.utils.fromUtf8("LINK"), 0)
assert(orderbook.length == 0, "Buy side Orderbook length is not 0")
await truffleAssert.passes(
dex.createMarketOrder(0, web3.utils.fromUtf8("LINK"), 10)
)
})
//Market orders should be filled until the order book is empty or the market order is 100% filled
it("Market orders should not fill more limit orders than the market order amount", async () => {
let dex = await Dex.deployed()
let link = await Link.deployed()
let orderbook = await dex.getOrderBook(web3.utils.fromUtf8("LINK"), 1)
assert(orderbook.length == 0, "Sell side Orderbook should be empty at start of test")
//Token (LINK) is already added
//Send LINK tokens to accounts 1, 2, 3 from accounts 0
await link.transfer(accounts[1], 50)
await link.transfer(accounts[2], 50)
await link.transfer(accounts[3], 50)
//Approve DEX for accounts 1, 2, 3
await link.approve(dex.address, 50, {from: accounts[1]})
await link.approve(dex.address, 50, {from: accounts[2]})
await link.approve(dex.address, 50, {from: accounts[3]})
//Deposit DEX for accounts 1, 2, 3
await dex.deposit(50, web3.utils.fromUtf8("LINK"), {from: accounts[1]})
await dex.deposit(50, web3.utils.fromUtf8("LINK"), {from: accounts[2]})
await dex.deposit(50, web3.utils.fromUtf8("LINK"), {from: accounts[3]})
//Fill up the sell order book
await dex.createLimitOrder(1, web3.utils.fromUtf8("LINK"), 5, 300, {from: accounts[1]})
await dex.createLimitOrder(1, web3.utils.fromUtf8("LINK"), 5, 400, {from: accounts[2]})
await dex.createLimitOrder(1, web3.utils.fromUtf8("LINK"), 5, 500, {from: accounts[3]})
//Create market order that should fill 2/3 orders in the book
await dex.createMarketOrder(0, web3.utils.fromUtf8("LINK"), 10)
orderbook = await dex.getOrderBook(web3.utils.fromUtf8("LINK"), 1)
assert(orderbook.length == 1, "Sell side Orderbook should only have 1 order left")
assert(orderbook[0].filled == 0, "Sell side order should have 0 filled")
})
//Market orders should be filled until the order book is empty or the market order is 100% filled
it("Market orders should be filled until the order book is empty", async () => {
let dex = await Dex.deployed()
let orderbook = await dex.getOrderBook(web3.utils.fromUtf8("LINK"), 1)
assert(orderbook.length == 1, "Sell side Orderbook should have 1 order left")
//Fill up the sell order book again
await dex.createLimitOrder(1, web3.utils.fromUtf8("LINK"), 5, 400, {from: accounts[1]})
await dex.createLimitOrder(1, web3.utils.fromUtf8("LINK"), 5, 500, {from: accounts[2]})
//check buyer link balance before link purchase
let balanceBefore = await dex.balances(accounts[0], web3.utils.fromUtf8("LINK"))
//ETH balance is 6500. Adding another 10000, the sum is 16500.
await dex.depositEth({value: 10000})
//Create market order that could fill more than the entire order book (15 link)
await dex.createMarketOrder(0, web3.utils.fromUtf8("LINK"), 50)
//check buyer link balance after link purchase
let balanceAfter = await dex.balances(accounts[0], web3.utils.fromUtf8("LINK"))
//Buyer should have 15 more link after, even tough order was for 50
assert.equal(balanceBefore.toNumber() + 15, balanceAfter.toNumber())
})
//The eth balance of the buyer should decrease with the filled amount
it("eth balance of the market order buyer should decrease with the filled amount", async () => {
let dex = await Dex.deployed()
let link = await Link.deployed()
//Seller deposits link and creates a sell limit order for 1 link for 300 wei
await link.approve(dex.address, 500, {from: accounts[1]})
await dex.createLimitOrder(1, web3.utils.fromUtf8("LINK"), 1, 300, {from: accounts[1]})
//Check buyer ETH balance before trade
let balanceBefore = await dex.balances(accounts[0], web3.utils.fromUtf8("ETH"))
await dex.createMarketOrder(0, web3.utils.fromUtf8("LINK"), 1)
let balanceAfter = await dex.balances(accounts[0], web3.utils.fromUtf8("ETH"))
assert.equal(balanceBefore - 300, balanceAfter)
})
//The token balances of the limit order sellers should decrease with the filled amounts.
it("token balance of the limit order seller should decrease with the filled amount", async () => {
let dex = await Dex.deployed()
let link = await Link.deployed()
let orderbook = await dex.getOrderBook(web3.utils.fromUtf8("LINK"), 1)
assert(orderbook.length == 0, "Sell side Orderbook should be empty at start of test")
//Seller Account[1] already has approved and deposited Link
//Transfer 100 LINK to accounts[2]
await link.transfer(accounts[2], 100)
//Seller Account[2] deposits link
await link.approve(dex.address, 500, {from: accounts[2]})
await dex.deposit(100, web3.utils.fromUtf8("LINK"), {from: accounts[2]})
await dex.createLimitOrder(1, web3.utils.fromUtf8("LINK"), 1, 300, {from: accounts[1]})
await dex.createLimitOrder(1, web3.utils.fromUtf8("LINK"), 1, 400, {from: accounts[2]})
//Check sellers Link balances before trade
let account1balanceBefore = await dex.balances(accounts[1], web3.utils.fromUtf8("LINK"))
let account2balanceBefore = await dex.balances(accounts[2], web3.utils.fromUtf8("LINK"))
//Account[0] created market order to buy up both sell orders
await dex.createMarketOrder(0, web3.utils.fromUtf8("LINK"), 2)
//Check sellers Link balances after trade
let account1balanceAfter = await dex.balances(accounts[1], web3.utils.fromUtf8("LINK"))
let account2balanceAfter = await dex.balances(accounts[2], web3.utils.fromUtf8("LINK"))
assert.equal(account1balanceBefore.toNumber() - 1, account1balanceAfter.toNumber())
assert.equal(account2balanceBefore.toNumber() - 1, account2balanceAfter.toNumber())
})
//Filled limit orders should be removed from the orderbook
it("filled limit orders should be removed from the orderbook", async () => {
let dex = await Dex.deployed()
let orderbook = await dex.getOrderBook(web3.utils.fromUtf8("LINK"), 1)
assert(orderbook.length == 0, "Sell side Orderbook should be empty at start of test")
await dex.createLimitOrder(1, web3.utils.fromUtf8("LINK"), 1, 300, {from: accounts[1]})
await dex.createMarketOrder(0, web3.utils.fromUtf8("LINK"), 1)
orderbook = await dex.getOrderBook(web3.utils.fromUtf8("LINK"), 1)
assert(orderbook.length == 0, "Sell side Orderbook should be empty after trade")
})
//Partly filled limit orders should be modified to represent the filled/remaining amount
it("Limit orders filled property should be set correctly after a trade", async () => {
let dex = await Dex.deployed()
let orderbook = await dex.getOrderBook(web3.utils.fromUtf8("LINK"), 1)
assert(orderbook.length == 0, "Sell side Orderbook should be empty at start of test")
await dex.createLimitOrder(1, web3.utils.fromUtf8("LINK"), 5, 300, {from: accounts[1]})
await dex.createMarketOrder(0, web3.utils.fromUtf8("LINK"), 2)
orderbook = await dex.getOrderBook(web3.utils.fromUtf8("LINK"), 1)
assert.equal(orderbook[0].filled, 2)
assert.equal(orderbook[0].amount, 5)
})
})