CoinHub is a foundation for building centralized exchanges on top of EVM networks. Instead of spending months wiring up order matching, wallet generation, user auth, and blockchain event listening, you get all of that out of the box so you can focus on your product.
The exchange runs as a set of independent Go processes (API, engine, workers) communicating through Kafka — so you can scale or replace each piece without touching the rest.
The heart of the system. The engine runs fully in-memory — no database round-trips during matching — and processes each trading pair on its own goroutine. Orders come in through Kafka (ORDER_SUBMITTED), get routed to the right pair worker, matched, and the resulting events (ORDER_PARTIAL, ORDER_FILLED, TRADE_EXECUTED) are published back to Kafka for projection and notification consumers to pick up.
Supported order types: limit, market, cancel.
Matching follows price-time priority with self-trade prevention. Partial fills are supported — any unfilled remainder of a limit order rests on the book at the given price level.
Order book structure: Price levels use a B-Tree, giving O(log n) insertion and lookup instead of O(n) scans — critical for deep books under high load.
The flow end-to-end:
POST /order/limit
→ SubmitOrder (usecase)
→ Kafka ORDER_SUBMITTED
→ orderMainConsumer (engine process)
→ OrderRouter (fan-out by pair)
→ orderHandlerWorker (one goroutine per pair)
→ Orderbook.MatchLimit / MatchMarket / Cancel
→ Kafka ORDER_* + TRADE_EXECUTED
→ Projection consumer (writes to DB)
→ Notification consumer (pushes WebSocket updates)
HTTP handlers return 202 Accepted immediately. Matching is asynchronous.
Users get a deterministic EVM wallet address on registration, derived from a single HD wallet mnemonic using BIP-44 (m/44'/60'/0'/0/<index>). You hold the mnemonic, users hold their balances on-chain.
The watcher process listens for ERC-20 transfer events on-chain and automatically credits deposits to user accounts. Withdrawals are signed server-side and submitted as EIP-1559 transactions.
Supported networks: Base mainnet and Base Sepolia, switchable via NETWORK_STATUS env var.
- Username/password with bcrypt
- Gmail-based login with email verification codes (sent via SMTP, stored in Redis with TTL)
- JWT tokens on all protected routes
- Role-based access:
user,admin,system
A WebSocket endpoint (GET /v1/order/events/ws) pushes order status changes to connected clients. The notification consumer reads ORDER_* events from Kafka and broadcasts to the relevant user's connection. There's also a blockchain WebSocket client that streams on-chain transfer events.
A back-office interface mounted at /admin, powered by GoAdmin. Provides out-of-the-box UI to manage admin users, roles, permissions, and operation logs. Access requires a GoAdmin account (separate from the exchange user accounts).
Default credentials (development): admin / admin
Prometheus metrics are exposed at /metrics and cover: orders submitted/matched/cancelled/expired per pair, reaper removal failures, trades executed, Kafka events consumed, and HTTP request latency. A Grafana setup with provisioned dashboards is included in /monitoring.
Background jobs (email codes, pending transaction tracking) run through Asynq (Redis-backed), with a monitoring UI at /monitoring.
flowchart TB
Client(["Client"])
subgraph App ["App Processes"]
API["API Server\nGin · :8083"]
Engine["Matching Engine\n1 goroutine per pair"]
Proj["Projection Consumer\nwrites state to DB"]
Notif["Notification Consumer\nWebSocket push"]
Worker["Background Worker\nAsynq — email, tx tracking"]
Watcher["Chain Watcher\nlistens for on-chain deposits"]
end
subgraph Infra ["Infrastructure"]
Kafka[("Kafka\nKRaft · no ZooKeeper")]
PG[("PostgreSQL 16")]
Redis[("Redis 7\ncache · job queue")]
end
EVM(["Base · EVM RPC"])
Client -- "REST" --> API
API -. "202 Accepted" .-> Client
Notif -- "WebSocket" --> Client
API -- "ORDER_SUBMITTED" --> Kafka
Kafka -- "consume" --> Engine
Engine -- "ORDER_* · TRADE_EXECUTED" --> Kafka
Kafka -- "consume" --> Proj
Kafka -- "consume" --> Notif
Proj --> PG
API --> PG
API --> Redis
Worker --> Redis
Watcher -- "subscribe Transfer logs" --> EVM
API -- "sign & broadcast tx" --> EVM
Watcher -- "credit deposit" --> PG
Each process (api, engine, worker, order-projection-consumer, notification-consumer, watcher) runs independently. Kafka is the shared backbone — the engine never touches the database directly, and all state changes flow through events.
| Layer | Choice |
|---|---|
| Language | Go 1.25 |
| HTTP | Gin |
| Database | PostgreSQL 16 (GORM) |
| Message broker | Kafka (KRaft, no ZooKeeper) |
| Cache / queues | Redis 7 |
| Blockchain | go-ethereum, HD wallet (BIP-44) |
| Metrics | Prometheus + Grafana |
| Background jobs | Asynq |
| Config | cleanenv (.env file) |
- Docker & Docker Compose
- An Alchemy (or compatible) RPC key for Base mainnet/Sepolia
- A Gmail app password if you want email auth
Copy the example env and fill in your secrets:
cp .env.docker .envRequired values to change before first boot:
APP_JWT_SECRET=<random string>
HDWALLET_MNEMONIC=<your 12/24 word mnemonic> # keep this secret and backed up
# Alchemy RPC endpoints
ETH_CLIENT_MAINNET=https://base-mainnet.g.alchemy.com/v2/<key>
ETH_CLIENT_TESTNET=https://base-sepolia.g.alchemy.com/v2/<key>
WS_CLIENT_ETHEREUM_MAINNET=wss://base-mainnet.g.alchemy.com/v2/<key>
WS_CLIENT_ETHEREUM_TESTNET=wss://base-sepolia.g.alchemy.com/v2/<key>
# Email (optional — only needed for Gmail auth)
MAIL_SMTP_USERNAME=your@gmail.com
MAIL_SMTP_PASSWORD=<gmail app password>Switch between testnet and mainnet:
NETWORK_STATUS=TESTNET # or MAINNETdocker compose build
docker compose up -dThis starts the full stack: Postgres, Redis, Kafka, all app processes, Prometheus, and Grafana.
To bring up only the API (if you're running infrastructure separately):
docker compose up -d apigo run main.go migrateMigrations live in /migrations and run automatically in the Docker setup via the migrate service.
Each process is a subcommand:
go run main.go api # HTTP server (port 8083)
go run main.go engine # order matching engine
go run main.go worker # background job worker (Asynq)
go run main.go order-projection-consumer # persists Kafka events to DB
go run main.go notification-consumer # pushes events to WebSocket clients
go run main.go watcher # on-chain deposit listenerAll routes are under /v1.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/auth/register |
— | Register a new user |
POST |
/auth/login/username |
— | Login with username + password |
POST |
/auth/login/gmail |
— | Start Gmail login flow |
POST |
/auth/verify/gmail-code |
— | Verify email code |
POST |
/order/limit |
JWT | Place a limit order |
POST |
/order/market |
JWT | Place a market order |
DELETE |
/order/cancel |
JWT | Cancel an open order |
GET |
/order/events/ws |
JWT | WebSocket — order updates |
POST |
/transaction/withdraw |
JWT | Withdraw funds on-chain |
POST |
/system/operation/asset/add |
Admin | Create a new asset |
GET |
/health |
— | Health check |
GET |
/metrics |
— | Prometheus metrics |
Full Swagger docs are available at /swagger/index.html when running.
| Service | URL |
|---|---|
| Grafana | http://localhost:3000 (admin / coinhub) |
| Prometheus | http://localhost:9090 |
| Kafka UI | http://localhost:8080 |
| Asynqmon (job monitor) | http://localhost:8083/v1/monitoring |
| Admin panel | http://localhost:8083/admin |
- Insert the base and quote assets via
POST /system/operation/asset/add(requires admin token) - Insert the trading pair record in the database (migration or seed script)
- Restart the engine — it reads active pairs at startup and creates an order book + goroutine per pair. Kafka topics are auto-created.
- B-Tree for order book price levels — O(log n) insertion/lookup replaces O(n) slice scans (
internal/engine/orderbook.go) - Admin panel — back-office UI for managing users, roles, and permissions (
/admin) - Asset creation endpoint —
POST /v1/system/operation/asset/add(admin only)
- Asset info endpoints — public routes to query a single asset and list all assets with their network availability, trading pairs, and status (
internal/adapter/handler/http/asset.go) - Multi-network config — currently hardcoded to support two networks simultaneously (
internal/infrastructure/configs/configs.go) - Graceful shutdown — all processes handle SIGTERM/SIGINT; HTTP server drains in-flight requests with a 10s timeout;
app.Shutdown()closes DB pool, Redis, ETH clients, Asynq, and Kafka producer in order (cmd/api.go,internal/application.go) - Order expiration — a reaper goroutine (min-heap, 100ms tick) removes GTC orders past their
expires_at, publishesORDER_EXPIREDto Kafka, and the projection consumer updates the DB status toexpired
- Lock down WebSocket CORS — remove
InsecureSkipVerifyand restrict to actual origins (internal/adapter/handler/http/router.go) - Set trusted proxy addresses for
X-Forwarded-For(load balancer IP) (internal/adapter/handler/http/router.go) - Sentry integration for crash reporting in production (
internal/adapter/handler/http/router.go)
MIT