← Blog

Building a High-Performance DEX SDK in Rust: Lighter DEX Integration

2025-11-20

rustdexsdkwebsocketcryptographytokio

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:

  1. Pure Rust, zero FFI: No C dependencies, no OpenSSL linking headaches, no unsafe blocks for crypto. Everything compiles with cargo build on any target.

  2. 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.

  3. Thread-safe by default: Every public type is Send + Sync. You can share a single LighterClient across 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(&params);

        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(&params);
        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(&params);
        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(&params). 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.