Building a High-Performance DEX SDK in Rust: Lighter DEX Integration
2025-11-20
Building a High-Performance DEX SDK in Rust: Lighter DEX Integration
Decentralized exchanges have evolved far beyond simple AMM swap interfaces. Modern DEXs like Lighter operate as full-featured perpetual futures exchanges with orderbooks, leverage, and sub-second execution — all settled on-chain with zero-knowledge proofs. Building a production-grade SDK for such a platform demands careful attention to cryptography, concurrency, and real-time data handling.
In this post, I'll walk through the design and implementation of the Lighter DEX Rust SDK — a pure-Rust client library with zero FFI dependencies, built for high-frequency trading workloads.
What is Lighter DEX?
Lighter is a zk-powered perpetual futures exchange that combines the performance of a centralized exchange with the settlement guarantees of a blockchain. Orders are matched off-chain for speed, but every trade is proved and settled on-chain using zero-knowledge proofs. This means:
- Centralized-exchange speed: Order matching happens in microseconds, not block times
- Decentralized custody: Users retain control of their funds at all times
- Verifiable execution: Every match is accompanied by a cryptographic proof that the execution was fair
The exchange uses a custom cryptographic signature scheme based on ECgFp5 (an elliptic curve over a degree-5 extension of a Goldilocks prime field) and Poseidon2 hash functions, optimized for efficient proof generation inside their zk circuit.
SDK Design Philosophy
When I started building this SDK, I set three non-negotiable constraints:
-
Pure Rust, zero FFI: No C dependencies, no OpenSSL linking headaches, no
unsafeblocks for crypto. Everything compiles withcargo buildon any target. -
Async-first with Tokio: Every I/O operation is non-blocking. The SDK is designed to run inside a Tokio runtime alongside other async tasks — trading bots, data pipelines, monitoring systems.
-
Thread-safe by default: Every public type is
Send + Sync. You can share a singleLighterClientacross threads without wrapping it in additional synchronization primitives.
These constraints shaped every decision that followed.
Core Architecture
The SDK is organized into three primary modules, each handling a distinct concern:
lighter-sdk/
├── signer/ # LighterSigner - Cryptographic operations
│ ├── ecgfp5.rs # Elliptic curve arithmetic
│ ├── poseidon2.rs # Hash function
│ └── mod.rs # Signing interface
├── client/ # LighterClient - REST API
│ ├── orders.rs # Order management
│ ├── account.rs # Account & positions
│ └── mod.rs # HTTP client
├── websocket/ # LighterWebSocket - Real-time streams
│ ├── streams.rs # Subscription management
│ └── mod.rs # WebSocket connection
└── lib.rs # Public API surface
LighterSigner: The Cryptographic Core
The most challenging component is the signer. Lighter's signature scheme is not your standard secp256k1 ECDSA — it uses ECgFp5, a curve designed specifically for efficient arithmetic inside zk-STARK proofs.
ECgFp5 operates over GF(p^5) where p is the Goldilocks prime (2^64 - 2^32 + 1). Arithmetic in this extension field requires implementing multiplication, inversion, and square root operations over quintic extensions — significantly more complex than standard prime field arithmetic:
#[derive(Clone, Copy, Debug)]
pub struct GFp5Element {
pub coeffs: [u64; 5], // Coefficients in GF(p^5)
}
impl GFp5Element {
pub fn mul(&self, other: &Self) -> Self {
// Schoolbook multiplication with reduction modulo
// the irreducible polynomial x^5 - 3
let mut result = [0u128; 9];
for i in 0..5 {
for j in 0..5 {
result[i + j] += self.coeffs[i] as u128 * other.coeffs[j] as u128;
}
}
// Reduce modulo x^5 - 3
Self::reduce(&result)
}
pub fn inv(&self) -> Self {
// Extended Euclidean algorithm in GF(p^5)
self.pow(Self::P_POW5_MINUS_2)
}
}
Message hashing uses Poseidon2, a hash function designed for algebraic efficiency in zero-knowledge proofs. Unlike SHA-256, Poseidon2 operates natively over prime fields, making it orders of magnitude cheaper to prove inside a zk circuit:
pub struct Poseidon2Hasher {
state: [GFp5Element; POSEIDON2_WIDTH],
round_constants: Vec<[GFp5Element; POSEIDON2_WIDTH]>,
mds_matrix: [[GFp5Element; POSEIDON2_WIDTH]; POSEIDON2_WIDTH],
}
impl Poseidon2Hasher {
pub fn hash(&self, inputs: &[GFp5Element]) -> GFp5Element {
let mut state = self.initialize(inputs);
// Full rounds → Partial rounds → Full rounds
for r in 0..FULL_ROUNDS_HALF {
state = self.full_round(state, r);
}
for r in 0..PARTIAL_ROUNDS {
state = self.partial_round(state, r);
}
for r in 0..FULL_ROUNDS_HALF {
state = self.full_round(state, r + FULL_ROUNDS_HALF);
}
state[1] // Output element
}
}
The signing flow combines these primitives: hash the order parameters with Poseidon2, then sign the hash using ECDSA over ECgFp5:
pub struct LighterSigner {
private_key: ECgFp5Scalar,
public_key: ECgFp5Point,
hasher: Poseidon2Hasher,
}
impl LighterSigner {
pub fn sign_order(&self, order: &OrderParams) -> Signature {
let message = self.encode_order(order);
let hash = self.hasher.hash(&message);
self.ecdsa_sign(hash)
}
}
LighterClient: REST API Interface
The REST client handles all synchronous operations — placing orders, querying positions, managing account state. It's built on reqwest with automatic retry logic and rate limiting:
pub struct LighterClient {
http: reqwest::Client,
base_url: String,
signer: Arc<LighterSigner>,
nonce: Arc<AtomicU64>,
}
impl LighterClient {
pub async fn place_limit_order(
&self,
market: &str,
side: Side,
price: Decimal,
size: Decimal,
leverage: Option<Decimal>,
) -> Result<OrderResponse> {
let nonce = self.nonce.fetch_add(1, Ordering::SeqCst);
let params = OrderParams {
market: market.to_string(),
side,
order_type: OrderType::Limit,
price: Some(price),
size,
leverage: leverage.unwrap_or(Decimal::ONE),
nonce,
timestamp: Utc::now().timestamp_millis() as u64,
};
let signature = self.signer.sign_order(¶ms);
let resp = self.http
.post(&format!("{}/api/v1/orders", self.base_url))
.json(&SignedOrder { params, signature })
.send()
.await?
.error_for_status()?
.json::<OrderResponse>()
.await?;
Ok(resp)
}
pub async fn place_market_order(
&self,
market: &str,
side: Side,
size: Decimal,
) -> Result<OrderResponse> {
// Market orders omit price, use immediate-or-cancel semantics
let nonce = self.nonce.fetch_add(1, Ordering::SeqCst);
let params = OrderParams {
market: market.to_string(),
side,
order_type: OrderType::Market,
price: None,
size,
leverage: Decimal::ONE,
nonce,
timestamp: Utc::now().timestamp_millis() as u64,
};
let signature = self.signer.sign_order(¶ms);
self.post_signed_order(params, signature).await
}
pub async fn cancel_order(
&self,
market: &str,
order_id: &str,
) -> Result<CancelResponse> {
let nonce = self.nonce.fetch_add(1, Ordering::SeqCst);
let cancel = CancelParams { market: market.into(), order_id: order_id.into(), nonce };
let signature = self.signer.sign_cancel(&cancel);
self.http
.delete(&format!("{}/api/v1/orders/{}", self.base_url, order_id))
.json(&SignedCancel { params: cancel, signature })
.send()
.await?
.json()
.await
}
}
Thread-Safe Nonce Management
Nonce management is deceptively tricky in concurrent trading systems. Each signed message must include a monotonically increasing nonce — if two threads use the same nonce, one transaction will be rejected. If nonces have gaps, the exchange may reject subsequent messages.
The SDK uses AtomicU64 for lock-free nonce generation:
pub struct NonceManager {
current: AtomicU64,
}
impl NonceManager {
pub fn new(initial: u64) -> Self {
Self { current: AtomicU64::new(initial) }
}
pub fn next(&self) -> u64 {
self.current.fetch_add(1, Ordering::SeqCst)
}
/// Sync nonce with server state after reconnection
pub fn sync(&self, server_nonce: u64) {
self.current.store(server_nonce, Ordering::SeqCst);
}
}
Ordering::SeqCst ensures that nonce increments are globally ordered across all threads. This is slightly slower than Relaxed ordering but eliminates any possibility of nonce reuse in multi-threaded scenarios.
On reconnection or error recovery, the SDK queries the server for the current nonce and syncs the local counter, preventing gaps.
LighterWebSocket: Real-Time Data Streams
For trading systems, REST polling is too slow. The SDK provides a WebSocket client for real-time subscriptions to orderbook updates, trade fills, and execution reports:
pub struct LighterWebSocket {
sender: mpsc::Sender<WsCommand>,
subscriptions: Arc<DashMap<String, broadcast::Sender<WsMessage>>>,
}
impl LighterWebSocket {
pub async fn connect(url: &str) -> Result<Self> {
let (ws_stream, _) = tokio_tungstenite::connect_async(url).await?;
let (write, read) = ws_stream.split();
let (cmd_tx, cmd_rx) = mpsc::channel(256);
let subscriptions = Arc::new(DashMap::new());
// Spawn read loop
let subs = subscriptions.clone();
tokio::spawn(async move {
Self::read_loop(read, subs).await;
});
// Spawn write loop
tokio::spawn(async move {
Self::write_loop(write, cmd_rx).await;
});
Ok(Self { sender: cmd_tx, subscriptions })
}
pub async fn subscribe_orderbook(
&self,
market: &str,
) -> Result<broadcast::Receiver<OrderbookUpdate>> {
let (tx, rx) = broadcast::channel(1024);
let channel = format!("orderbook:{}", market);
self.subscriptions.insert(channel.clone(), tx);
self.sender.send(WsCommand::Subscribe {
channel,
params: json!({ "market": market }),
}).await?;
Ok(rx)
}
pub async fn subscribe_fills(
&self,
market: &str,
) -> Result<broadcast::Receiver<Fill>> {
// Similar pattern for trade fills
let (tx, rx) = broadcast::channel(1024);
let channel = format!("fills:{}", market);
self.subscriptions.insert(channel.clone(), tx);
self.sender.send(WsCommand::Subscribe {
channel,
params: json!({ "market": market }),
}).await?;
Ok(rx)
}
async fn read_loop(
mut read: SplitStream<WebSocketStream>,
subscriptions: Arc<DashMap<String, broadcast::Sender<WsMessage>>>,
) {
while let Some(Ok(msg)) = read.next().await {
if let Message::Text(text) = msg {
if let Ok(parsed) = serde_json::from_str::<WsMessage>(&text) {
if let Some(tx) = subscriptions.get(&parsed.channel) {
let _ = tx.send(parsed);
}
}
}
}
}
}
The WebSocket client uses DashMap (a concurrent hash map) for subscription routing, broadcast channels for fan-out to multiple consumers, and separate Tokio tasks for read and write loops. This design supports thousands of concurrent subscriptions without blocking.
Tokio Async Architecture
The entire SDK is built around Tokio's async runtime. A typical trading bot using the SDK looks like this:
#[tokio::main]
async fn main() -> Result<()> {
let signer = LighterSigner::from_private_key(&private_key)?;
let client = LighterClient::new("https://api.lighter.xyz", signer.clone());
let ws = LighterWebSocket::connect("wss://ws.lighter.xyz").await?;
// Subscribe to orderbook
let mut orderbook = ws.subscribe_orderbook("ETH-USD").await?;
// Subscribe to our fills
let mut fills = ws.subscribe_fills("ETH-USD").await?;
// Trading loop
loop {
tokio::select! {
Ok(update) = orderbook.recv() => {
// Process orderbook update, decide on action
if let Some(order) = strategy.on_orderbook(&update) {
client.place_limit_order(
"ETH-USD",
order.side,
order.price,
order.size,
Some(order.leverage),
).await?;
}
}
Ok(fill) = fills.recv() => {
// Process fill, update position tracking
strategy.on_fill(&fill);
}
}
}
}
tokio::select! allows the bot to concurrently wait on multiple async streams, reacting to whichever fires first. This is the idiomatic Rust pattern for event-driven systems and compiles down to a state machine with zero heap allocation per poll.
Position and Leverage Management
The SDK provides high-level abstractions for position management, critical for perpetual futures trading:
impl LighterClient {
pub async fn get_positions(&self) -> Result<Vec<Position>> {
self.get("/api/v1/positions").await
}
pub async fn set_leverage(
&self,
market: &str,
leverage: Decimal,
) -> Result<LeverageResponse> {
let nonce = self.nonce.fetch_add(1, Ordering::SeqCst);
let params = LeverageParams { market: market.into(), leverage, nonce };
let signature = self.signer.sign_leverage(¶ms);
self.post_signed("/api/v1/leverage", params, signature).await
}
pub async fn get_account_summary(&self) -> Result<AccountSummary> {
// Returns equity, margin usage, unrealized PnL, available balance
self.get("/api/v1/account/summary").await
}
}
Error Handling and Resilience
Production trading systems cannot crash on transient errors. The SDK implements structured error types with automatic retry for recoverable failures:
#[derive(Debug, thiserror::Error)]
pub enum LighterError {
#[error("Rate limited, retry after {retry_after_ms}ms")]
RateLimited { retry_after_ms: u64 },
#[error("Nonce too low (expected {expected}, got {got})")]
NonceTooLow { expected: u64, got: u64 },
#[error("Insufficient margin for order")]
InsufficientMargin,
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
}
Rate limiting errors trigger automatic backoff. Nonce errors trigger a sync with the server. Network errors trigger reconnection with exponential backoff.
Conclusion
Building the Lighter DEX SDK was an exercise in making complex cryptography accessible through clean APIs. The ECgFp5/Poseidon2 signature scheme is exotic, but from the user's perspective, it's just signer.sign_order(¶ms). The WebSocket complexity is hidden behind simple subscribe_* methods that return standard Tokio channels.
The pure-Rust, zero-FFI approach paid dividends in cross-platform compatibility — the SDK compiles to WASM, runs on ARM, and integrates into any Rust project with a single line in Cargo.toml. No system dependencies, no build scripts, no surprises.
For the full implementation, visit github.com/robustfengbin/lighter-sdk.