A native Rust client for the Lighter
exchange. It is a from-scratch port of the official Go SDK
(lighter-go) and produces byte-identical transaction
hashes and Schnorr signatures, so payloads it signs are accepted by the same
sequencer.
This crate contains no cgo/FFI shims and no hand-rolled cryptography. It is
idiomatic Rust that depends on two audited-in-spirit community crates which
mirror the elliottech/poseidon_crypto Go reference:
| Concern | Crate | Mirrors (Go) |
|---|---|---|
Goldilocks field + Poseidon2 hash + Fp5 quintic extension |
poseidon-hash |
field/goldilocks, hash/poseidon2_goldilocks* |
| ECgFp5 curve + Schnorr signatures + scalar field | goldilocks-crypto |
curve/ecgfp5, signature/schnorr |
We verified that:
poseidon-hash::hash_to_quintic_extensionreproduces Go'sp2.HashToQuinticExtension(both Poseidon2 variants in Go use identical round constants and the same permutation; only their internal field representation differs, so the output values are equal).goldilocks-cryptouses the same 5-limb (320-bit) scalar field as Go, and its Schnorr challengee = H(r ‖ H(m)), responses = k − e·sk, ands ‖ e80-byte encoding matchschnorr.SchnorrSignHashedMessage.
⚠️ Like the upstream crates, this library is not security audited. Use at your own risk.
[dependencies]
lighter = { path = "../lighter-rust" }Feature flags:
| Feature | Enables |
|---|---|
http (default) |
Blocking reqwest client for nonce / api-key lookups. |
trading |
Async runtime: REST submission, nonce manager, order/cancel/market helpers, prioritized read endpoints. |
eth |
Ethereum L1 (EIP-191 personal_sign) signing for transfer / change_pub_key / approve_integrator. |
ws |
Async WebSocket live-data client (implies trading). |
For a pure offline signer:
lighter = { path = "../lighter-rust", default-features = false }use lighter::{TxClient, TransactOpts, OrderReq, TxInfo};
let client = TxClient::new(
"0x<40-byte-hex-private-key>",
/* account_index */ 3,
/* api_key_index */ 0,
/* chain_id */ 304,
)?;
// Provide the nonce yourself for a fully offline signer...
let opts = TransactOpts { nonce: Some(0), ..Default::default() };
// ...or attach an HTTP client and leave `nonce: None` to fetch it:
// let client = client.with_http(Box::new(
// lighter::http::ReqwestClient::new("https://mainnet.zklighter.elliot.ai")?,
// ));
let order = client.create_order(
OrderReq {
market_index: 0,
client_order_index: 1,
base_amount: 1000,
price: 50_000,
is_ask: 0,
order_type: 0, // limit
time_in_force: 1, // good-till-time
reduce_only: 0,
trigger_price: 0,
order_expiry: 99_999_999,
},
&opts,
)?;
println!("tx type: {}", order.tx_type());
println!("tx hash: {}", order.tx_hash());
println!("payload: {}", order.to_json()?);The trading feature adds a full async runtime on top of the signer: REST
submission (sendTx / sendTxBatch), a nonce manager, order-book reads, and
high-level order helpers. Bring your own async runtime (e.g. tokio).
[dependencies]
lighter = { path = "../lighter-rust", features = ["trading"] }
tokio = { version = "1", features = ["full"] }use std::collections::HashMap;
use lighter::{TradingClient, NonceStrategy, OrderParams, TradeOpts};
use lighter::constants::{LIMIT_ORDER, GOOD_TILL_TIME};
let mut keys = HashMap::new();
keys.insert(0u8, "0x<40-byte-hex-private-key>".to_string());
let client = TradingClient::new(
"https://mainnet.zklighter.elliot.ai",
/* account_index */ 3,
keys,
NonceStrategy::Optimistic,
)
.await?;
// Limit order — nonce + api key resolved automatically.
let (order, resp) = client
.create_order(
OrderParams {
market_index: 0,
client_order_index: 1,
base_amount: 1000,
price: 50_000,
is_ask: false,
order_type: LIMIT_ORDER,
time_in_force: GOOD_TILL_TIME,
..Default::default()
},
&TradeOpts::default(),
)
.await?;
println!("submitted {} -> {}", order.tx_hash(), resp.tx_hash);
// IOC market order bounded by 1% slippage off the live book.
let (_mkt, _resp) = client
.create_market_order_limited_slippage(0, 2, 500, 0.01, false, false, None, &TradeOpts::default())
.await?;NonceStrategy::{Optimistic, Api, NoOp} mirror the Python nonce managers.
Failed submissions automatically roll back the optimistic counter (or hard-
refresh on an "invalid nonce" rejection). Lower-level building blocks are also
public: RestClient, NonceManager, and the per-transaction structs.
TradingClient covers the full trading surface: create / grouped / cancel /
cancel-all / modify orders, TP & SL (market + limit) helpers, market orders by
base size, quote amount, or slippage budget (create_market_order,
create_market_order_limited_slippage, create_market_order_if_slippage,
create_market_order_quote_amount), withdraw, transfer, update leverage /
margin / account & asset config, create / update public pools, mint / burn
shares, stake / unstake, sub-accounts, and approve-integrator. Float amount
arguments are scaled by the asset's ticker scale (see
lighter::constants::ticker_scale).
Order price / base_amount are integer ticks. To trade in human units, the
client caches each market's decimals from orderBookDetails and converts for
you (ticks = round(human × 10^decimals)):
client.refresh_markets().await?; // optional warm-up; otherwise fetched lazily
// 1.5 ETH @ $1234.56, good-till-time:
let (order, _resp) = client
.create_limit_order(0, 1, 1.5, 1234.56, false, GOOD_TILL_TIME, false, &TradeOpts::default())
.await?;
// or convert explicitly
let price_ticks = client.price_to_ticks(0, 1234.56).await?;
let size_ticks = client.size_to_ticks(0, 1.5).await?;create_market_order_value is the IOC-market equivalent in human units.
transfer, change_pub_key, and approve_integrator require an L1
personal_sign (EIP-191) signature by the account's Ethereum wallet. Pass the
L1 key and the client signs both layers:
let (tx, resp) = client
.transfer(
"0x<eth-private-key>",
/* to_account_index */ 2,
/* asset_id (ETH) */ 1,
/* route_from */ 0,
/* route_to */ 0,
/* amount (float) */ 1.5,
/* fee (usdc ticks) */ 1_000,
/* memo (32B / hex) */ &"00".repeat(32),
&TradeOpts::default(),
)
.await?;For transfers between accounts under the same L1 master, use the
*_same_master_account variants, which skip the L1 signature. EthSigner is
also usable standalone for offline EIP-191 signing.
TradingClient and RestClient expose the trading-relevant read endpoints:
account, active_orders / inactive_orders, order_books /
order_book_details, recent_trades / trades, candles, fundings /
funding_rates, system_config, and tx_by_hash / txs.
use lighter::ws::{WsClient, WsEvent};
const ACCOUNT: i64 = 10;
// Subscribe to market 0's book *and* account 10's stream (no auth needed for
// the `account_all` channel).
let mut ws = WsClient::connect("mainnet.zklighter.elliot.ai", &[0], &[ACCOUNT]).await?;
while let Some(event) = ws.next_event().await? {
match event {
WsEvent::OrderBook { market_id, book } =>
println!("market {market_id}: {} bids / {} asks", book.bids.len(), book.asks.len()),
WsEvent::Account { account_id, update } => {
// `update.is_snapshot` is true for the first message (history),
// false for incremental deltas (newly matched fills).
for fill in update.fills_for(ACCOUNT) {
let side = if fill.is_buy { "buy" } else { "sell" };
let role = if fill.is_maker { "maker" } else { "taker" };
println!(
"fill acct {account_id} mkt {} {side} {role} {} @ {} (fee {})",
fill.market_id, fill.size, fill.price, fill.fee,
);
}
if let Some(pos) = update.position(0) {
println!("position mkt0: {}", pos.signed_size());
}
}
WsEvent::Reconnected => {
// Cached state was cleared; fresh snapshots follow. Reset any
// locally-derived state (e.g. mid-prices, open orders) here.
println!("ws reconnected");
}
}
}By default the client auto-reconnects with exponential backoff, sends a 15s
keepalive ping, and forces a reconnect if no frame arrives within 60s — so a
long-running bot survives drops without manual restart logic. On reconnect it
clears its cached order-book / account state and emits WsEvent::Reconnected;
the server then replays fresh snapshots. Tune via WsConfig:
use lighter::ws::WsConfig;
use std::time::Duration;
let cfg = WsConfig {
idle_timeout: Some(Duration::from_secs(30)),
max_reconnect_attempts: Some(10),
..Default::default()
};
let mut ws = WsClient::connect_with_config(host, &[0], &[ACCOUNT], cfg).await?;Account messages from the account_all channel are parsed into a typed
[AccountUpdate] (positions, balances, funding, pool shares, and per-market
trades). WsTrade::fill_for / AccountUpdate::fills_for resolve each trade into
an [AccountFill] from your account's perspective — side (buy/sell),
maker/taker role, and the fee charged to you — which is exactly what an MM bot
needs to feed its P&L instead of optimistic booking + REST reconcile. The client
answers pings, sends subscriptions on connect, and maintains incremental
order-book state, surfacing a typed WsEvent pull-stream that composes with
tokio::select!.
Not yet handled: the auth-gated account channels (
account_market,account_all_orders, …) which need a signed auth token, and order-book sequence-gap detection viaoffset/nonce.
Out of scope:
paper_clientand the full ~60-endpoint REST read surface.
let (private_key_hex, public_key_hex) = lighter::generate_api_key();
let token = client.auth_token(/* deadline unix secs */ 1_900_000_000, &Default::default())?;TxClient exposes a signing method for every L2 transaction in the Go SDK:
create / grouped / cancel / cancel-all / modify orders, change pubkey, create sub-account, transfer, withdraw, update leverage / margin / account-config / account-asset-config, approve integrator, create / update public pool, mint / burn shares, stake / unstake assets.
Each returns a strongly-typed struct implementing [TxInfo], which exposes
tx_type(), validate(), hash(), tx_hash(), to_json(), and (for
ChangePubKey / Transfer / ApproveIntegrator) message_to_sign() for the L1
wallet signature.
use lighter::TxAttributes;
let opts = TransactOpts {
nonce: Some(0),
tx_attributes: Some(TxAttributes {
integrator_account_index: Some(7),
integrator_taker_fee: Some(100),
integrator_maker_fee: Some(50),
..Default::default()
}),
..Default::default()
};Parity is enforced by tests that diff against vectors generated by the Go reference:
tests/vectors.json— produced bygo run ./cmd/vectorsinlighter-go;tests/parity.rsrebuilds each transaction and asserts the message hash, JSON payload, and L1 message match byte-for-byte.tests/sigvector.json— produced bygo run ./cmd/sigvector;tests/signing.rsasserts public-key derivation and a fixed-nonce signature match Go exactly, and that fresh signatures verify.
cargo testTo regenerate the vectors after changing the protocol:
cd ../lighter-go
go run ./cmd/vectors > ../lighter-rust/tests/vectors.json
go run ./cmd/sigvector > ../lighter-rust/tests/sigvector.jsonMIT OR Apache-2.0.