DEV Community

Cover image for Building a Decentralized Exchange on Stellar: From Concept to Code
cauhlins
cauhlins

Posted on

Building a Decentralized Exchange on Stellar: From Concept to Code

Welcome to this comprehensive guide on building a Decentralized Exchange (DEX) on the Stellar network! Whether you're a blockchain novice or an experienced developer, this tutorial will walk you through creating a functional DEX from scratch.

Conceptual illustration of a decentralized exchange

Table of Contents

  1. Introduction to Decentralized Exchanges
  2. Understanding Stellar's Core Concepts
  3. Setting Up the Development Environment
  4. Designing the DEX Smart Contract
  5. Implementing the Smart Contract
  6. Creating Custom Tokens for Trading
  7. Deploying the Smart Contract
  8. Building the Frontend
  9. Testing the DEX
  10. Advanced Features and Optimizations
  11. Security Considerations
  12. Conclusion and Next Steps

Introduction to Decentralized Exchanges

A Decentralized Exchange (DEX) is a type of cryptocurrency exchange that operates without a central authority. Instead, it uses smart contracts to facilitate trades directly between users.

Key Terms:

  • Decentralized Exchange (DEX): A peer-to-peer marketplace for cryptocurrencies without a central authority.
  • Smart Contract: Self-executing code that automatically implements the terms of an agreement between parties.
  • Liquidity: The ease with which an asset can be bought or sold without affecting its price significantly.

Understanding Stellar's Core Concepts

Before we dive into building our DEX, let's review some core Stellar concepts:

  1. Accounts: Represent participants in the Stellar network. Each account has a public key and can hold balances in various assets.

  2. Assets: Represent different types of value on the Stellar network. The native asset is called Lumens (XLM), but custom assets can also be created.

  3. Operations: Individual commands that modify the ledger state, such as creating accounts, sending payments, or making offers.

  4. Transactions: Groups of operations that are submitted to the network as a single unit.

  5. Orderbook: A record of outstanding offers to buy or sell assets on the Stellar network.

  6. Path Payments: Allows sending one type of asset and having the recipient receive another type, with the network automatically handling the conversion.

Setting Up the Development Environment

Let's set up our development environment:

  1. Install Node.js and npm (if not already installed)
  2. Install the Stellar SDK:
   npm install stellar-sdk
Enter fullscreen mode Exit fullscreen mode
  1. Install Soroban CLI for smart contract development:
   cargo install --locked --version 20.0.0-rc2 soroban-cli
Enter fullscreen mode Exit fullscreen mode
  1. Set up a new project:
   mkdir stellar-dex
   cd stellar-dex
   npm init -y
Enter fullscreen mode Exit fullscreen mode

Designing the DEX Smart Contract

Our DEX will need the following core functionalities:

  1. Create and manage order books for different trading pairs
  2. Place buy and sell orders
  3. Match and execute orders
  4. Manage user balances
  5. Withdraw funds

Let's design our smart contract structure:

pub struct DEX {
    order_books: Map<AssetPair, OrderBook>,
    balances: Map<Address, Map<Asset, i128>>,
}

pub struct AssetPair {
    base_asset: Asset,
    quote_asset: Asset,
}

pub struct OrderBook {
    buy_orders: Vec<Order>,
    sell_orders: Vec<Order>,
}

pub struct Order {
    user: Address,
    amount: i128,
    price: i128,
    is_buy: bool,
}

pub struct Asset {
    code: Symbol,
    issuer: Option<Address>,
}
Enter fullscreen mode Exit fullscreen mode

Implementing the Smart Contract

Now, let's implement our DEX smart contract. Create a new file src/lib.rs:

#![no_std]
use soroban_sdk::{contractimpl, Address, Env, Map, Symbol, Vec};

mod types;
use types::{DEX, AssetPair, OrderBook, Order, Asset};

const PRECISION: i128 = 10000000; // 7 decimal places

#[contractimpl]
impl DEX {
    pub fn init(env: Env) -> Self {
        Self {
            order_books: Map::new(&env),
            balances: Map::new(&env),
        }
    }

    pub fn deposit(&mut self, env: Env, user: Address, asset: Asset, amount: i128) {
        user.require_auth();
        let balance = self.balances.get(user).unwrap_or(Map::new(&env));
        let new_balance = balance.get(asset).unwrap_or(0) + amount;
        balance.set(asset, new_balance);
        self.balances.set(user, balance);
    }

    pub fn withdraw(&mut self, env: Env, user: Address, asset: Asset, amount: i128) {
        user.require_auth();
        let mut balance = self.balances.get(user).unwrap();
        let current_balance = balance.get(asset).unwrap();
        if current_balance < amount {
            panic!("Insufficient balance");
        }
        balance.set(asset, current_balance - amount);
        self.balances.set(user, balance);
        // Here we would typically initiate a Stellar transaction to send the assets
    }

    pub fn place_order(&mut self, env: Env, user: Address, pair: AssetPair, amount: i128, price: i128, is_buy: bool) {
        user.require_auth();
        let order = Order {
            user: user.clone(),
            amount,
            price,
            is_buy,
        };
        let mut order_book = self.order_books.get(pair).unwrap_or(OrderBook::new(&env));
        if is_buy {
            order_book.buy_orders.push_back(order);
            order_book.buy_orders.sort_by(|a, b| b.price.cmp(&a.price));
        } else {
            order_book.sell_orders.push_back(order);
            order_book.sell_orders.sort_by(|a, b| a.price.cmp(&b.price));
        }
        self.order_books.set(pair, order_book);
        self.match_orders(env, pair);
    }

    fn match_orders(&mut self, env: Env, pair: AssetPair) {
        let mut order_book = self.order_books.get(pair).unwrap();
        while !order_book.buy_orders.is_empty() && !order_book.sell_orders.is_empty() {
            let buy_order = order_book.buy_orders.first().unwrap();
            let sell_order = order_book.sell_orders.first().unwrap();
            if buy_order.price < sell_order.price {
                break;
            }
            let trade_price = (buy_order.price + sell_order.price) / 2;
            let trade_amount = buy_order.amount.min(sell_order.amount);
            self.execute_trade(env, &pair, &buy_order.user, &sell_order.user, trade_amount, trade_price);
            // Update or remove orders
            if buy_order.amount > trade_amount {
                order_book.buy_orders.set(0, Order {
                    amount: buy_order.amount - trade_amount,
                    ..buy_order
                });
            } else {
                order_book.buy_orders.remove(0);
            }
            if sell_order.amount > trade_amount {
                order_book.sell_orders.set(0, Order {
                    amount: sell_order.amount - trade_amount,
                    ..sell_order
                });
            } else {
                order_book.sell_orders.remove(0);
            }
        }
        self.order_books.set(pair, order_book);
    }

    fn execute_trade(&mut self, env: Env, pair: &AssetPair, buyer: &Address, seller: &Address, amount: i128, price: i128) {
        let base_amount = amount;
        let quote_amount = amount * price / PRECISION;
        // Transfer base asset from seller to buyer
        self.transfer(env, seller, buyer, pair.base_asset, base_amount);
        // Transfer quote asset from buyer to seller
        self.transfer(env, buyer, seller, pair.quote_asset, quote_amount);
    }

    fn transfer(&mut self, env: Env, from: &Address, to: &Address, asset: Asset, amount: i128) {
        let mut from_balance = self.balances.get(from).unwrap();
        let mut to_balance = self.balances.get(to).unwrap_or(Map::new(&env));
        let from_amount = from_balance.get(asset).unwrap();
        let to_amount = to_balance.get(asset).unwrap_or(0);
        from_balance.set(asset, from_amount - amount);
        to_balance.set(asset, to_amount + amount);
        self.balances.set(from, from_balance);
        self.balances.set(to, to_balance);
    }
}
Enter fullscreen mode Exit fullscreen mode

This implementation covers the core functionalities of our DEX:

  • Depositing and withdrawing assets
  • Placing buy and sell orders
  • Matching and executing orders
  • Managing user balances

Creating Custom Tokens for Trading

To create a more interesting trading environment, let's create two custom tokens:

  1. SpaceBucks (SPC)
  2. LunarCredits (LNC)

Create a new file src/tokens.rs:

use soroban_sdk::{contractimpl, token, Address, Env, String};

pub struct Token;

#[contractimpl]
impl Token {
    pub fn initialize(env: Env, admin: Address, decimal: u32, name: String, symbol: String) {
        let token = token::Interface::new(&env, &env.current_contract_address());
        token.initialize(&admin, &decimal, &name, &symbol);
    }

    pub fn mint(env: Env, to: Address, amount: i128) {
        let token = token::Interface::new(&env, &env.current_contract_address());
        token.mint(&to, &amount);
    }

    pub fn balance(env: Env, id: Address) -> i128 {
        let token = token::Interface::new(&env, &env.current_contract_address());
        token.balance(&id)
    }

    pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
        let token = token::Interface::new(&env, &env.current_contract_address());
        token.transfer(&from, &to, &amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

Deploying the Smart Contract

Now, let's deploy our smart contracts to the Stellar testnet:

  1. Build the contracts:
   soroban contract build
Enter fullscreen mode Exit fullscreen mode
  1. Create a Stellar account for deployment:
   soroban config identity generate admin
   soroban config network add testnet --rpc-url https://soroban-testnet.stellar.org
   soroban config identity fund admin --network testnet
Enter fullscreen mode Exit fullscreen mode
  1. Deploy the DEX contract:
   soroban contract deploy --wasm target/wasm32-unknown-unknown/release/dex.wasm --source admin --network testnet
Enter fullscreen mode Exit fullscreen mode
  1. Deploy the token contracts:
   soroban contract deploy --wasm target/wasm32-unknown-unknown/release/token.wasm --source admin --network testnet
   soroban contract deploy --wasm target/wasm32-unknown-unknown/release/token.wasm --source admin --network testnet
Enter fullscreen mode Exit fullscreen mode

Make note of the contract IDs returned for each deployment.

Building the Frontend

For our frontend, we'll use React with the Stellar SDK. Create a new React app:

npx create-react-app stellar-dex-frontend
cd stellar-dex-frontend
npm install stellar-sdk @stellar/freighter-api
Enter fullscreen mode Exit fullscreen mode

Replace the content of src/App.js with:

import React, { useState, useEffect } from 'react';
import { Server } from 'stellar-sdk';
import { isConnected, getPublicKey } from '@stellar/freighter-api';

const server = new Server('https://horizon-testnet.stellar.org');
const dexContractId = 'YOUR_DEX_CONTRACT_ID';
const spcTokenId = 'YOUR_SPC_TOKEN_CONTRACT_ID';
const lncTokenId = 'YOUR_LNC_TOKEN_CONTRACT_ID';

function App() {
  const [account, setAccount] = useState(null);
  const [spcBalance, setSpcBalance] = useState(0);
  const [lncBalance, setLncBalance] = useState(0);
  const [orderBooks, setOrderBooks] = useState({ buy: [], sell: [] });

  useEffect(() => {
    checkConnection();
  }, []);

  const checkConnection = async () => {
    const connected = await isConnected();
    if (connected) {
      const publicKey = await getPublicKey();
      setAccount(publicKey);
      fetchBalances(publicKey);
      fetchOrderBooks();
    }
  };

  const fetchBalances = async (publicKey) => {
    const spcBalance = await server.loadAccount(publicKey).then(account => {
      return account.balances.find(b => b.asset_code === 'SPC')?.balance || '0';
    });
    const lncBalance = await server.loadAccount(publicKey).then(account => {
      return account.balances.find(b => b.asset_code === 'LNC')?.balance || '0';
    });
    setSpcBalance(spcBalance);
    setLncBalance(lncBalance);
  };

  const fetchOrderBooks = async () => {
    // In a real implementation, you would fetch this data from your smart contract
    setOrderBooks({
      buy: [
        { price: 1.2, amount: 100 },
        { price: 1.1, amount: 200 },
      ],
      sell: [
        { price: 1.3, amount: 150 },
        { price: 1.4, amount: 300 },
      ],
    });
  };

  const placeOrder = async (isBuy, amount, price) => {
    // Implementation of placing an order via smart contract
    console.log(`Placing ${isBuy ? 'buy' : 'sell'} order: ${amount} @ ${price}`);
  };

  return (
    <div className="App">
      <h1>Stellar DEX</h1>
      {account ? (
        <>
          <p>Connected: {account}</p>
          <p>SPC Balance: {spcBalance}</p>
          <p>LNC Balance: {lncBalance}</p>
          <h2>Order Books</h2>
          <div style={{ display: 'flex', justifyContent: 'space-around' }}>
            <div>
              <h3>Buy Orders</h3>
              <ul>
                {orderBooks.buy.map((order, index) => (
                  <li key={index}>
                    {order.amount} SPC @ {order.price} LNC
                  </li>
                ))}
              </ul>
            </div>
            <div>
              <h3>Sell Orders</h3>
              <ul>
                {orderBooks.sell.map((order, index) => (
                  <li key={index}>
                    {order.amount} SPC @ {order.price} LNC
                  </li>
                ))}
              </ul>
            </div>
          </div>
          <h2>Place Order</h2>
          <form onSubmit={(e) => {
            e.preventDefault();
            const formData = new FormData(e.target);
            placeOrder(
              formData.get('type') === 'buy',
              parseFloat(formData.get('amount')),
              parseFloat(formData.get('price'))
            );
          }}>
            <select name="type">
              <option value="buy">Buy</option>
              <option value="sell">Sell</option>
            </select>
            <input type="number" name="amount" placeholder="Amount" step="0.01" required />
            <input type="number" name="price" placeholder="Price" step="0.01" required />
            <button type="submit">Place Order</button>
          </form>
        </>
      ) : (
        <p>Please connect your Stellar wallet</p>
      )}
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

This completes our basic frontend implementation. It provides a user interface for viewing balances, order books, and placing orders.

Testing the DEX

To test our DEX, we'll need to:

  1. Deploy the smart contracts (if not already done)
  2. Create and fund test accounts
  3. Mint some test tokens
  4. Place orders and observe the matching process

Here's a script to help with testing (test_dex.js):

const StellarSdk = require('stellar-sdk');
const { Contract, Server } = StellarSdk;

const server = new Server('https://horizon-testnet.stellar.org');
const dexContractId = 'YOUR_DEX_CONTRACT_ID';
const spcTokenId = 'YOUR_SPC_TOKEN_CONTRACT_ID';
const lncTokenId = 'YOUR_LNC_TOKEN_CONTRACT_ID';

async function createTestAccount() {
  const pair = StellarSdk.Keypair.random();
  await server.friendbot(pair.publicKey()).call();
  return pair;
}

async function mintTestTokens(account, tokenId, amount) {
  const contract = new Contract(tokenId);
  const tx = new StellarSdk.TransactionBuilder(account, { 
    fee: StellarSdk.BASE_FEE,
    networkPassphrase: StellarSdk.Networks.TESTNET
  })
    .addOperation(contract.call('mint', account.publicKey(), amount))
    .setTimeout(30)
    .build();

  tx.sign(account);
  await server.submitTransaction(tx);
}

async function placeOrder(account, isBuy, amount, price) {
  const contract = new Contract(dexContractId);
  const tx = new StellarSdk.TransactionBuilder(account, { 
    fee: StellarSdk.BASE_FEE,
    networkPassphrase: StellarSdk.Networks.TESTNET
  })
    .addOperation(contract.call('place_order', {
      user: account.publicKey(),
      pair: { base_asset: spcTokenId, quote_asset: lncTokenId },
      amount: amount,
      price: price,
      is_buy: isBuy
    }))
    .setTimeout(30)
    .build();

  tx.sign(account);
  await server.submitTransaction(tx);
}

async function runTest() {
  const alice = await createTestAccount();
  const bob = await createTestAccount();

  await mintTestTokens(alice, spcTokenId, '1000');
  await mintTestTokens(bob, lncTokenId, '1000');

  await placeOrder(alice, true, '100', '1.2');  // Alice places a buy order
  await placeOrder(bob, false, '100', '1.2');   // Bob places a matching sell order

  console.log('Test completed successfully!');
}

runTest().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Run this script with Node.js to test your DEX functionality.

Advanced Features and Optimizations

To take your DEX to the next level, consider implementing these advanced features:

  1. Limit and Market Orders: Implement different order types to provide more trading options.

  2. Order Book Optimization: Use a more efficient data structure (e.g., a binary heap) for faster order matching.

  3. Fee System: Implement a small fee for trades to incentivize liquidity providers.

  4. Liquidity Pools: Add automated market maker (AMM) functionality alongside the order book.

  5. Cross-Asset Trades: Implement path payments to allow trading between any asset pairs.

  6. Price Oracle: Integrate with external price feeds for more accurate pricing.

  7. Trade History: Store and display recent trades for each asset pair.

Security Considerations

When building a DEX, security is paramount. Here are some key considerations:

  1. Smart Contract Audits: Have your smart contracts audited by professional security researchers.

  2. Rate Limiting: Implement rate limiting to prevent spam and potential DoS attacks.

  3. Access Control: Ensure that only authorized users can perform sensitive operations.

  4. Integer Overflow Protection: Use safe math operations to prevent integer overflows.

  5. Reentrancy Guards: Implement checks to prevent reentrancy attacks.

  6. Formal Verification: Consider using formal verification tools to mathematically prove the correctness of your smart contracts.

  7. Upgrade Mechanism: Implement a secure upgrade mechanism to fix bugs and add features without compromising user funds.

Conclusion and Next Steps

Congratulations! You've built a basic decentralized exchange on Stellar. This project has introduced you to:

  • Stellar's core concepts and smart contract platform (Soroban)
  • Implementing complex financial logic in smart contracts
  • Integrating Stellar operations with a web frontend
  • Security considerations for decentralized finance applications

To continue your journey:

  1. Expand the frontend to include more user-friendly features like charts and detailed order history.
  2. Implement the advanced features mentioned above.
  3. Conduct thorough testing, including edge cases and stress tests.
  4. Consider the regulatory implications of running a DEX and consult with legal experts.
  5. Engage with the Stellar community for feedback and potential collaborations.

Remember, building a production-ready DEX requires extensive testing, auditing, and compliance considerations. This tutorial serves as a starting point for your exploration of decentralized finance on Stellar.

Happy coding, and welcome to the exciting world of decentralized exchanges!

Top comments (1)

Collapse
 
onwuagba profile image
cauhlins

Happy to read comments and take corrections on any error found in this article. Thank you