A decentralised exchange implementing a full central limit order book on chain — price-time priority matching, limit and market orders, and custodial balance management — in ~630 lines of Solidity, with a React front end.
Most DEXs of this era were automated market makers precisely because an on-chain order book is hard: every resting order costs storage, and every match costs gas. Building one directly is the interesting part of this project, and the constraints it runs into are the substance of it.
Orders match on price first, then time of arrival — the same priority rule a traditional exchange uses.
SELL SIDE (asks) BUY SIDE (bids)
┌────────────────┐ ┌────────────────┐
│ 105 × 50 │ │ 99 × 30 │
│ 103 × 20 │ │ 100 × 75 │ ← best bid
│ 101 × 10 │ ← best ask │ 98 × 40 │
└────────────────┘ └────────────────┘
▲ ▲
└────── incoming order crosses ───┘
the spread and fills
at the resting price,
oldest order first
An incoming buy walks the ask queue upward while its limit price allows, filling resting orders oldest-first at each level; a sell walks the bid queue downward. Any unfilled remainder rests as a new order.
Each side is a queue of order indices with a mapping from index to order, so cancelling is a mapping delete rather than an array shift — an array shift would be O(n) in gas and would let a deep book price out its own cancellations.
| Contract | Role |
|---|---|
contracts/Exchange.sol |
Order books, matching, balances, deposits and withdrawals (627 lines) |
contracts/Token.sol |
ERC-20 implementation |
contracts/Token2.sol, contracts/Token3.sol |
Additional listings for multi-pair testing |
contracts/owned.sol |
Ownership modifier gating token listing |
contracts/Migrations.sol |
Truffle deployment bookkeeping |
Balances are held in the exchange (tokenBalanceForAddress,
etherBalanceForAddress) rather than pulled per trade. Settling every fill
against external ERC-20 transfers would multiply gas cost by the number of
fills — a single order crossing five resting orders would trigger five
external calls, each with its own re-entrancy surface.
This is an educational project on Solidity 0.5.1, and it should be read with that in mind. Documenting the issues honestly rather than quietly is the point of this section.
Unchecked multiplication — contracts/Exchange.sol:533.
uint total_ether_needed = priceInWei * amount;
require(total_ether_needed <= getEtherBalanceInWei());Solidity did not add built-in overflow checks until 0.8.0. Under 0.5.1 this
product wraps silently, so a sufficiently large priceInWei × amount can
produce a small total_ether_needed, pass the balance check, and create a
large buy order backed by almost no ether. On a modern compiler this reverts
automatically; here it needs an explicit guard or SafeMath. depositEther
does use a manual overflow require, so the pattern is applied inconsistently
rather than absent.
Other observations:
msg.sender.transfer()forwards a fixed 2300 gas. This was the recommended pattern in 2019 but breaks for smart-contract recipients after gas repricing; the modern equivalent iscallwith a re-entrancy guard.- Withdrawals do decrement state before transferring, so checks-effects- interactions is correctly observed against re-entrancy.
test/is empty. A matching engine holding custody of user funds with no test suite is the single largest gap here — an order book has enough edge cases (partial fills, self-crossing, cancel-during-match, empty book) that they cannot be verified by inspection.
Requires Ganache on 127.0.0.1:7545.
npm install
truffle compile
truffle migrate --reset
cd frontend
npm install
npm startfrontend/.env holds the deployed exchange address and the Ganache RPC URL —
local development values only.
Solidity 0.5.1 · Truffle · React · web3.js / ethers.js · Ganache
Full write-up: DEX Documentation.pdf.