use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use serde_json::Value;
use crate::core::{
HttpClient, Credentials,
ExchangeId, AccountType, Symbol,
ExchangeError, ExchangeResult,
Price, Kline, Ticker, OrderBook,
Order, Balance, AccountInfo,
Position, FundingRate, PublicTrade,
OrderRequest, CancelRequest,
BalanceQuery, PositionQuery, PositionModification,
OrderHistoryFilter, PlaceOrderResponse, FeeInfo,
UserTrade, UserTradeFilter,
};
use crate::core::traits::{
ExchangeIdentity, MarketData, Trading, Account, Positions,
};
use crate::core::types::{ConnectorStats, SymbolInfo, MarketDataCapabilities, TradingCapabilities, AccountCapabilities};
use crate::core::utils::{RuntimeLimiter, RateLimitMonitor, RateLimitPressure};
use crate::core::types::{RateLimitCapabilities, LimitModel, RestLimitPool, WsLimits, OrderbookCapabilities};
use super::endpoints::{LighterUrls, LighterEndpoint, map_kline_interval, format_symbol, symbol_to_market_id};
use super::auth::LighterAuth;
use super::parser::LighterParser;
static LIGHTER_POOLS: &[RestLimitPool] = &[RestLimitPool {
name: "default",
max_budget: 60,
window_seconds: 60,
is_weight: true,
has_server_headers: false,
server_header: None,
header_reports_used: false,
}];
static LIGHTER_RATE_CAPS: RateLimitCapabilities = RateLimitCapabilities {
model: LimitModel::Weight,
rest_pools: LIGHTER_POOLS,
decaying: None,
endpoint_weights: &[],
ws: WsLimits {
max_connections: Some(100),
max_subs_per_conn: Some(100),
max_msg_per_sec: None,
max_streams_per_conn: None,
},
};
pub struct LighterConnector {
http: HttpClient,
_auth: Option<LighterAuth>,
urls: LighterUrls,
testnet: bool,
limiter: Arc<Mutex<RuntimeLimiter>>,
monitor: Arc<Mutex<RateLimitMonitor>>,
}
impl LighterConnector {
pub async fn new(credentials: Option<Credentials>, testnet: bool) -> ExchangeResult<Self> {
let urls = if testnet {
LighterUrls::TESTNET
} else {
LighterUrls::MAINNET
};
let http = HttpClient::new(30_000)?;
let auth = credentials
.as_ref()
.map(LighterAuth::new)
.transpose()?;
let limiter = Arc::new(Mutex::new(RuntimeLimiter::from_caps(&LIGHTER_RATE_CAPS)));
let monitor = Arc::new(Mutex::new(RateLimitMonitor::new("Lighter")));
Ok(Self {
http,
_auth: auth,
urls,
testnet,
limiter,
monitor,
})
}
pub async fn public(testnet: bool) -> ExchangeResult<Self> {
let urls = if testnet {
LighterUrls::TESTNET
} else {
LighterUrls::MAINNET
};
let http = HttpClient::new(30_000)?;
let auth = Some(LighterAuth::public_only());
let limiter = Arc::new(Mutex::new(RuntimeLimiter::from_caps(&LIGHTER_RATE_CAPS)));
let monitor = Arc::new(Mutex::new(RateLimitMonitor::new("Lighter")));
Ok(Self {
http,
_auth: auth,
urls,
testnet,
limiter,
monitor,
})
}
async fn rate_limit_wait(&self, weight: u32, essential: bool) -> bool {
loop {
let wait_time = {
let mut limiter = self.limiter.lock()
.expect("rate limiter mutex poisoned");
let pressure = self.monitor.lock()
.expect("rate monitor mutex poisoned")
.check(&mut limiter);
if pressure >= RateLimitPressure::Cutoff && !essential {
return false;
}
if limiter.try_acquire("default", weight) {
return true;
}
limiter.time_until_ready("default", weight)
};
if wait_time > Duration::ZERO {
tokio::time::sleep(wait_time).await;
}
}
}
async fn get(
&self,
endpoint: LighterEndpoint,
params: HashMap<String, String>,
weight: u32,
) -> ExchangeResult<Value> {
if !self.rate_limit_wait(weight, false).await {
return Err(ExchangeError::RateLimitExceeded {
retry_after: None,
message: "Rate limit budget >= 90% used; non-essential market data request dropped".to_string(),
});
}
let base_url = self.urls.rest_url();
let path = endpoint.path();
let query = if params.is_empty() {
String::new()
} else {
let qs: Vec<String> = params.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect();
format!("?{}", qs.join("&"))
};
let url = format!("{}{}{}", base_url, path, query);
let headers = HashMap::new();
let response = self.http.get_with_headers(&url, &HashMap::new(), &headers).await?;
self.check_response(&response)?;
Ok(response)
}
async fn post(
&self,
endpoint: LighterEndpoint,
body: Value,
weight: u32,
) -> ExchangeResult<Value> {
self.rate_limit_wait(weight, true).await;
let base_url = self.urls.rest_url();
let path = endpoint.path();
let url = format!("{}{}", base_url, path);
let _auth = self._auth.as_ref()
.ok_or_else(|| ExchangeError::Auth("Authentication required".to_string()))?;
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
let response = self.http.post(&url, &body, &headers).await?;
self.check_response(&response)?;
Ok(response)
}
async fn fetch_next_nonce(&self, account_index: u64) -> ExchangeResult<u64> {
let mut params = HashMap::new();
params.insert("account_index".to_string(), account_index.to_string());
let response = self.get(LighterEndpoint::NextNonce, params, 100).await?;
response.get("nonce")
.and_then(|v| v.as_u64())
.ok_or_else(|| ExchangeError::Parse(
"Missing or invalid 'nonce' field in nextNonce response".to_string()
))
}
fn check_response(&self, response: &Value) -> ExchangeResult<()> {
LighterParser::check_success(response)
}
async fn get_market_id(&self, symbol: &str, _account_type: AccountType) -> ExchangeResult<u16> {
let base = symbol.split('/').next().unwrap_or(symbol);
symbol_to_market_id(base).ok_or_else(|| {
ExchangeError::InvalidRequest(format!(
"Unknown Lighter market for symbol '{}' (base: '{}')", symbol, base
))
})
}
}
impl ExchangeIdentity for LighterConnector {
fn exchange_id(&self) -> ExchangeId {
ExchangeId::Lighter
}
fn metrics(&self) -> ConnectorStats {
let (http_requests, http_errors, last_latency_ms) = self.http.stats();
let (rate_used, rate_max) = if let Ok(mut limiter) = self.limiter.lock() {
limiter.primary_stats()
} else {
(0, 0)
};
ConnectorStats {
http_requests,
http_errors,
last_latency_ms,
rate_used,
rate_max,
rate_groups: Vec::new(),
ws_ping_rtt_ms: 0,
}
}
fn is_testnet(&self) -> bool {
self.testnet
}
fn supported_account_types(&self) -> Vec<AccountType> {
vec![
AccountType::Spot,
AccountType::FuturesCross,
]
}
fn rate_limit_capabilities(&self) -> RateLimitCapabilities {
LIGHTER_RATE_CAPS
}
fn orderbook_capabilities(&self, _account_type: AccountType) -> OrderbookCapabilities {
OrderbookCapabilities {
ws_depths: &[],
ws_default_depth: None,
rest_max_depth: Some(250),
rest_depth_values: &[],
supports_snapshot: true,
supports_delta: true,
update_speeds_ms: &[50],
default_speed_ms: Some(50),
ws_channels: &[],
checksum: None,
has_sequence: true,
has_prev_sequence: true,
supports_aggregation: false,
aggregation_levels: &[],
}
}
}
#[async_trait]
impl MarketData for LighterConnector {
async fn get_price(&self, symbol: Symbol, account_type: AccountType) -> ExchangeResult<Price> {
let formatted_symbol = if let Some(raw) = symbol.raw() {
raw.to_string()
} else {
format_symbol(&symbol.base, &symbol.quote, account_type)
};
let market_id = self.get_market_id(&formatted_symbol, account_type).await?;
let mut params = HashMap::new();
params.insert("market_id".to_string(), market_id.to_string());
let response = self.get(LighterEndpoint::OrderBookDetails, params, 300).await?;
LighterParser::parse_price(&response)
}
async fn get_ticker(&self, symbol: Symbol, account_type: AccountType) -> ExchangeResult<Ticker> {
let formatted_symbol = if let Some(raw) = symbol.raw() {
raw.to_string()
} else {
format_symbol(&symbol.base, &symbol.quote, account_type)
};
let market_id = self.get_market_id(&formatted_symbol, account_type).await?;
let mut params = HashMap::new();
params.insert("market_id".to_string(), market_id.to_string());
let response = self.get(LighterEndpoint::OrderBookDetails, params, 300).await?;
LighterParser::parse_ticker(&response)
}
async fn get_orderbook(&self, symbol: Symbol, depth: Option<u16>, account_type: AccountType) -> ExchangeResult<OrderBook> {
let formatted_symbol = if let Some(raw) = symbol.raw() {
raw.to_string()
} else {
format_symbol(&symbol.base, &symbol.quote, account_type)
};
let market_id = self.get_market_id(&formatted_symbol, account_type).await?;
let limit = depth.unwrap_or(50).min(250).max(1);
let mut params = HashMap::new();
params.insert("market_id".to_string(), market_id.to_string());
params.insert("limit".to_string(), limit.to_string());
let response = self.get(LighterEndpoint::OrderBookOrders, params, 300).await?;
LighterParser::parse_orderbook(&response)
}
async fn get_klines(
&self,
symbol: Symbol,
interval: &str,
limit: Option<u16>,
account_type: AccountType,
end_time: Option<i64>,
) -> ExchangeResult<Vec<Kline>> {
let formatted_symbol = if let Some(raw) = symbol.raw() {
raw.to_string()
} else {
format_symbol(&symbol.base, &symbol.quote, account_type)
};
let market_id = self.get_market_id(&formatted_symbol, account_type).await?;
let bars = limit.unwrap_or(500).min(500) as u64;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let end_ms = end_time.map(|t| t as u64).unwrap_or(now_ms);
let interval_ms = interval_to_ms(interval);
let start_ms = end_ms.saturating_sub(interval_ms * bars * 2);
let mut params = HashMap::new();
params.insert("market_id".to_string(), market_id.to_string());
params.insert("resolution".to_string(), map_kline_interval(interval).to_string());
params.insert("count_back".to_string(), bars.to_string());
params.insert("end_timestamp".to_string(), end_ms.to_string());
params.insert("start_timestamp".to_string(), start_ms.to_string());
let response = self.get(LighterEndpoint::Candlesticks, params, 300).await?;
LighterParser::parse_klines(&response)
}
async fn ping(&self) -> ExchangeResult<()> {
let _ = self.get(LighterEndpoint::Status, HashMap::new(), 300).await?;
Ok(())
}
fn market_data_capabilities(&self, _account_type: AccountType) -> MarketDataCapabilities {
MarketDataCapabilities {
has_ping: true, has_price: true, has_ticker: true, has_orderbook: true, has_klines: true, has_exchange_info: true, has_recent_trades: true, has_ws_klines: false, has_ws_trades: true, has_ws_orderbook: true, has_ws_ticker: true, supported_intervals: &["1m", "5m", "15m", "1h", "4h", "1d"],
max_kline_limit: Some(500),
}
}
async fn get_exchange_info(&self, account_type: AccountType) -> ExchangeResult<Vec<SymbolInfo>> {
let known_symbols: &[(&str, u16)] = &[
("ETH", 0),
("BTC", 1),
("SOL", 2),
("ARB", 3),
("OP", 4),
("DOGE", 5),
("MATIC", 6),
("AVAX", 7),
("LINK", 8),
("SUI", 9),
("1000PEPE", 10),
("WIF", 11),
("SEI", 12),
("AAVE", 13),
("NEAR", 14),
("WLD", 15),
("FTM", 16),
("BONK", 17),
("APT", 19),
("BNB", 25),
];
let is_spot = matches!(account_type, AccountType::Spot);
let infos = known_symbols.iter().map(|(base, _market_id)| {
let (symbol, quote_asset) = if is_spot {
(format!("{}/USDC", base), "USDC".to_string())
} else {
(base.to_string(), "USDC".to_string())
};
SymbolInfo {
symbol,
base_asset: base.to_string(),
quote_asset,
status: "TRADING".to_string(),
price_precision: 8,
quantity_precision: 8,
min_quantity: None,
max_quantity: None,
tick_size: None,
step_size: None,
min_notional: None,
account_type,
}
}).collect();
Ok(infos)
}
}
#[async_trait]
impl Trading for LighterConnector {
async fn place_order(&self, req: OrderRequest) -> ExchangeResult<PlaceOrderResponse> {
self.place_order_signed(req).await
}
async fn cancel_order(&self, req: CancelRequest) -> ExchangeResult<Order> {
self.cancel_order_signed(req).await
}
async fn get_order(
&self,
_symbol: &str,
order_id: &str,
_account_type: AccountType,
) -> ExchangeResult<Order> {
let account_index = self._auth.as_ref()
.and_then(|a| a.account_index())
.ok_or_else(|| ExchangeError::Auth(
"Lighter get_order requires account_index in credentials passphrase JSON.".to_string()
))?;
let mut active_params = HashMap::new();
active_params.insert("account_index".to_string(), account_index.to_string());
if let Ok(response) = self.get(LighterEndpoint::AccountActiveOrders, active_params, 300).await {
if let Ok(active_orders) = LighterParser::parse_open_orders(&response) {
if let Some(order) = active_orders.into_iter().find(|o| o.id == order_id) {
return Ok(order);
}
}
}
let mut inactive_params = HashMap::new();
inactive_params.insert("account_index".to_string(), account_index.to_string());
inactive_params.insert("limit".to_string(), "100".to_string());
let response = self.get(LighterEndpoint::AccountInactiveOrders, inactive_params, 100).await?;
let orders = LighterParser::parse_orders(&response)?;
orders.into_iter()
.find(|o| o.id == order_id)
.ok_or_else(|| ExchangeError::NotFound(format!("Order {} not found", order_id)))
}
async fn get_open_orders(
&self,
symbol: Option<&str>,
account_type: AccountType,
) -> ExchangeResult<Vec<Order>> {
let account_index = self._auth.as_ref()
.and_then(|a| a.account_index())
.ok_or_else(|| ExchangeError::Auth(
"Lighter get_open_orders requires account_index in credentials passphrase JSON.".to_string()
))?;
let mut params = HashMap::new();
params.insert("account_index".to_string(), account_index.to_string());
if let Some(sym) = symbol {
if let Ok(market_id) = self.get_market_id(sym, account_type).await {
params.insert("market_id".to_string(), market_id.to_string());
}
}
let response = self.get_authenticated(LighterEndpoint::AccountActiveOrders, params, 300).await?;
let orders = LighterParser::parse_open_orders(&response)?;
Ok(orders)
}
async fn get_order_history(
&self,
filter: OrderHistoryFilter,
account_type: AccountType,
) -> ExchangeResult<Vec<Order>> {
let account_index = self._auth.as_ref()
.and_then(|a| a.account_index())
.ok_or_else(|| ExchangeError::Auth(
"Lighter get_order_history requires account_index in credentials passphrase JSON.".to_string()
))?;
let mut params = HashMap::new();
params.insert("account_index".to_string(), account_index.to_string());
if let Some(limit) = filter.limit {
params.insert("limit".to_string(), limit.min(100).to_string());
}
if let Some(sym) = &filter.symbol {
let symbol_str = sym.base.as_str();
if let Ok(market_id) = self.get_market_id(symbol_str, account_type).await {
params.insert("market_id".to_string(), market_id.to_string());
}
}
let response = self.get(LighterEndpoint::AccountInactiveOrders, params, 100).await?;
let mut orders = LighterParser::parse_orders(&response)?;
if let Some(start) = filter.start_time {
orders.retain(|o| o.created_at >= start);
}
if let Some(end) = filter.end_time {
orders.retain(|o| o.created_at <= end);
}
Ok(orders)
}
async fn get_user_trades(
&self,
filter: UserTradeFilter,
account_type: AccountType,
) -> ExchangeResult<Vec<UserTrade>> {
let account_index = self._auth.as_ref()
.and_then(|a| a.account_index())
.ok_or_else(|| ExchangeError::Auth(
"Lighter get_user_trades requires account_index in credentials passphrase JSON.".to_string()
))?;
let mut params = HashMap::new();
params.insert("account_index".to_string(), account_index.to_string());
if let Some(limit) = filter.limit {
params.insert("limit".to_string(), limit.min(100).to_string());
}
if let Some(start) = filter.start_time {
params.insert("start_time".to_string(), start.to_string());
}
if let Some(end) = filter.end_time {
params.insert("end_time".to_string(), end.to_string());
}
if let Some(sym) = &filter.symbol {
if let Ok(market_id) = self.get_market_id(sym.as_str(), account_type).await {
params.insert("market_id".to_string(), market_id.to_string());
}
}
let response = self.get(LighterEndpoint::Trades, params, 100).await?;
let mut trades = LighterParser::parse_user_trades(&response)?;
if let Some(oid) = &filter.order_id {
trades.retain(|t| &t.order_id == oid);
}
Ok(trades)
}
fn trading_capabilities(&self, _account_type: AccountType) -> TradingCapabilities {
TradingCapabilities {
has_market_order: true, has_limit_order: true, has_stop_market: false, has_stop_limit: false, has_trailing_stop: false, has_bracket: false, has_oco: false, has_amend: false, has_batch: false, max_batch_size: None,
has_cancel_all: false, has_user_trades: true, has_order_history: true, }
}
}
#[async_trait]
impl Account for LighterConnector {
async fn get_balance(&self, query: BalanceQuery) -> ExchangeResult<Vec<Balance>> {
let (by_field, value) = self.resolve_account_query()?;
let mut params = HashMap::new();
params.insert("by".to_string(), by_field);
params.insert("value".to_string(), value);
let response = self.get(LighterEndpoint::Account, params, 3000).await?;
let mut balances = LighterParser::parse_balance(&response)?;
if let Some(asset_filter) = &query.asset {
balances.retain(|b| b.asset.eq_ignore_ascii_case(asset_filter));
}
Ok(balances)
}
async fn get_account_info(&self, account_type: AccountType) -> ExchangeResult<AccountInfo> {
let (by_field, value) = self.resolve_account_query()?;
let mut params = HashMap::new();
params.insert("by".to_string(), by_field);
params.insert("value".to_string(), value);
let response = self.get(LighterEndpoint::Account, params, 3000).await?;
let balances = LighterParser::parse_balance(&response)?;
let fees_response = self.get(LighterEndpoint::OrderBooks, HashMap::new(), 300).await;
let (maker_commission, taker_commission) = if let Ok(fee_resp) = fees_response {
let book = fee_resp.get("order_books")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.cloned();
if let Some(b) = book {
let maker = b.get("maker_fee")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let taker = b.get("taker_fee")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0001);
(maker, taker)
} else {
(0.0, 0.0001)
}
} else {
(0.0, 0.0001)
};
Ok(AccountInfo {
account_type,
can_trade: true,
can_withdraw: true,
can_deposit: true,
maker_commission,
taker_commission,
balances,
})
}
async fn get_fees(&self, symbol: Option<&str>) -> ExchangeResult<FeeInfo> {
let mut params = HashMap::new();
if let Some(sym) = symbol {
if let Ok(market_id) = self.get_market_id(sym, AccountType::FuturesCross).await {
params.insert("market_id".to_string(), market_id.to_string());
}
}
let response = self.get(LighterEndpoint::OrderBooks, params, 300).await?;
let order_books = response
.get("order_books")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.cloned();
let (maker_rate, taker_rate) = if let Some(book) = order_books {
let maker = book.get("maker_fee")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let taker = book.get("taker_fee")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0001); (maker, taker)
} else {
(0.0, 0.0001)
};
Ok(FeeInfo {
maker_rate,
taker_rate,
symbol: symbol.map(|s| s.to_string()),
tier: None,
})
}
fn account_capabilities(&self, _account_type: AccountType) -> AccountCapabilities {
AccountCapabilities {
has_balances: true, has_account_info: true, has_fees: true, has_transfers: false, has_sub_accounts: false, has_deposit_withdraw: false, has_margin: false, has_earn_staking: false, has_funding_history: false, has_ledger: false, has_convert: false, has_positions: true, }
}
}
#[async_trait]
impl Positions for LighterConnector {
async fn get_positions(&self, query: PositionQuery) -> ExchangeResult<Vec<Position>> {
let (by_field, value) = self.resolve_account_query()?;
let mut params = HashMap::new();
params.insert("by".to_string(), by_field);
params.insert("value".to_string(), value);
let response = self.get(LighterEndpoint::Account, params, 3000).await?;
let mut positions = LighterParser::parse_positions(&response)?;
if let Some(sym) = &query.symbol {
let base = sym.base.to_uppercase();
positions.retain(|p| p.symbol.to_uppercase() == base);
}
Ok(positions)
}
async fn get_funding_rate(
&self,
symbol: &str,
_account_type: AccountType,
) -> ExchangeResult<FundingRate> {
let symbol_str = symbol;
let symbol = {
let parts: Vec<&str> = symbol_str.split('/').collect();
if parts.len() == 2 {
crate::core::Symbol::new(parts[0], parts[1])
} else {
crate::core::Symbol { base: symbol_str.to_string(), quote: String::new(), raw: Some(symbol_str.to_string()) }
}
};
let formatted_symbol = if let Some(raw) = symbol.raw() {
raw.to_string()
} else {
format_symbol(&symbol.base, &symbol.quote, AccountType::FuturesCross)
};
let market_id = self.get_market_id(&formatted_symbol, AccountType::FuturesCross).await?;
let mut params = HashMap::new();
params.insert("market_id".to_string(), market_id.to_string());
let response = self.get(LighterEndpoint::Fundings, params, 300).await?;
let mut funding = LighterParser::parse_funding_rate(&response)?;
funding.symbol = symbol.to_string();
Ok(funding)
}
async fn modify_position(&self, req: PositionModification) -> ExchangeResult<()> {
match req {
PositionModification::SetLeverage { .. } => {
Err(ExchangeError::UnsupportedOperation(
"Lighter does not support per-account leverage changes via REST. \
Leverage is controlled by initial margin fraction set at the market level.".to_string()
))
}
_ => Err(ExchangeError::UnsupportedOperation(
format!("{:?} is not supported on Lighter", req)
)),
}
}
}
impl LighterConnector {
fn resolve_account_query(&self) -> ExchangeResult<(String, String)> {
let auth = self._auth.as_ref()
.ok_or_else(|| ExchangeError::Auth(
"Lighter account queries require credentials (account_index or l1_address).".to_string()
))?;
if let Some(idx) = auth.account_index() {
return Ok(("index".to_string(), idx.to_string()));
}
if let Some(addr) = auth.l1_address() {
return Ok(("l1_address".to_string(), addr.to_string()));
}
Err(ExchangeError::Auth(
"Lighter account queries require either account_index or l1_address in credentials. \
Pass them via Credentials::new(\"\", \"\").with_passphrase(r#\"{\"account_index\": 1}\"#).".to_string()
))
}
}
impl LighterConnector {
pub async fn get_recent_trades(
&self,
symbol: &str,
account_type: AccountType,
limit: Option<u32>,
) -> ExchangeResult<Vec<PublicTrade>> {
let market_id = self.get_market_id(symbol, account_type).await?;
let mut params = HashMap::new();
params.insert("market_id".to_string(), market_id.to_string());
if let Some(lim) = limit {
params.insert("limit".to_string(), lim.to_string());
}
let response = self.get(LighterEndpoint::RecentTrades, params, 600).await?;
LighterParser::parse_trades(&response)
}
pub async fn get_exchange_stats(&self) -> ExchangeResult<Value> {
let response = self.get(LighterEndpoint::ExchangeStats, HashMap::new(), 300).await?;
Ok(response)
}
pub async fn get_current_height(&self) -> ExchangeResult<i64> {
let response = self.get(LighterEndpoint::CurrentHeight, HashMap::new(), 300).await?;
response.get("height")
.and_then(|h| h.as_i64())
.ok_or_else(|| ExchangeError::Parse("Missing height field".to_string()))
}
pub async fn get_trading_pairs(&self, account_type: AccountType) -> ExchangeResult<Vec<String>> {
let params = {
let mut p = HashMap::new();
let filter = match account_type {
AccountType::Spot => "spot",
_ => "perp",
};
p.insert("filter".to_string(), filter.to_string());
p
};
let response = self.get(LighterEndpoint::OrderBookDetails, params, 300).await?;
LighterParser::parse_trading_pairs(&response)
}
pub async fn get_funding_rates(
&self,
market_id: Option<u16>,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
if let Some(id) = market_id {
params.insert("market_id".to_string(), id.to_string());
}
self.get(LighterEndpoint::FundingRates, params, 300).await
}
pub async fn get_exchange_metrics(&self) -> ExchangeResult<Value> {
self.get(LighterEndpoint::ExchangeMetrics, HashMap::new(), 300).await
}
pub async fn get_account_limits(&self) -> ExchangeResult<Value> {
let (by_field, value) = self.resolve_account_query()?;
let mut params = HashMap::new();
params.insert("by".to_string(), by_field);
params.insert("value".to_string(), value);
self.get(LighterEndpoint::AccountLimits, params, 300).await
}
pub async fn get_account_metadata(&self) -> ExchangeResult<Value> {
let (by_field, value) = self.resolve_account_query()?;
let mut params = HashMap::new();
params.insert("by".to_string(), by_field);
params.insert("value".to_string(), value);
self.get(LighterEndpoint::AccountMetadata, params, 300).await
}
pub async fn get_position_funding(
&self,
market_id: Option<u16>,
limit: Option<u32>,
) -> ExchangeResult<Value> {
let (by_field, value) = self.resolve_account_query()?;
let mut params = HashMap::new();
params.insert("by".to_string(), by_field);
params.insert("value".to_string(), value);
if let Some(id) = market_id {
params.insert("market_id".to_string(), id.to_string());
}
if let Some(l) = limit {
params.insert("limit".to_string(), l.to_string());
}
self.get(LighterEndpoint::PositionFunding, params, 300).await
}
pub async fn get_liquidations(
&self,
market_id: Option<u16>,
limit: Option<u32>,
) -> ExchangeResult<Value> {
let (by_field, value) = self.resolve_account_query()?;
let mut params = HashMap::new();
params.insert("by".to_string(), by_field);
params.insert("value".to_string(), value);
if let Some(id) = market_id {
params.insert("market_id".to_string(), id.to_string());
}
if let Some(l) = limit {
params.insert("limit".to_string(), l.to_string());
}
self.get(LighterEndpoint::Liquidations, params, 300).await
}
pub async fn get_withdrawal_delays(&self) -> ExchangeResult<Value> {
let (by_field, value) = self.resolve_account_query()?;
let mut params = HashMap::new();
params.insert("by".to_string(), by_field);
params.insert("value".to_string(), value);
self.get(LighterEndpoint::WithdrawalDelays, params, 300).await
}
}
impl LighterConnector {
pub(crate) async fn place_order_signed(
&self,
req: OrderRequest,
) -> ExchangeResult<PlaceOrderResponse> {
let auth = self._auth.as_ref()
.ok_or_else(|| ExchangeError::Auth(
"Authentication required for place_order. \
Provide credentials with api_key_index, account_index, and api_secret (hex 40 bytes).".to_string()
))?;
let account_index = auth.account_index()
.ok_or_else(|| ExchangeError::Auth(
"account_index required in credentials passphrase JSON for Lighter order placement. \
Example: Credentials::new(\"\", \"<private_key_hex>\").with_passphrase(r#\"{\"account_index\": 1, \"api_key_index\": 0}\"#)".to_string()
))?;
let symbol_str = format_symbol(&req.symbol.base, &req.symbol.quote, req.account_type);
let market_id_u16 = self.get_market_id(&symbol_str, req.account_type).await?;
let market_index = market_id_u16 as i16;
let nonce = self.fetch_next_nonce(account_index).await? as i64;
let is_ask = matches!(req.side, crate::core::OrderSide::Sell);
let (price_tick, order_type_code) = match &req.order_type {
crate::core::OrderType::Limit { price } => {
((*price * 1e8) as u32, 0u8) }
crate::core::OrderType::Market => {
(0u32, 1u8) }
crate::core::OrderType::PostOnly { price } => {
((*price * 1e8) as u32, 0u8) }
crate::core::OrderType::Ioc { price } => {
let p = price.unwrap_or(0.0);
((p * 1e8) as u32, 0u8) }
_ => {
return Err(ExchangeError::UnsupportedOperation(
"Lighter only supports Limit, Market, PostOnly, and IOC order types.".to_string()
));
}
};
let base_amount = (req.quantity * 1e8) as i64;
let tif_code: u8 = match &req.order_type {
crate::core::OrderType::PostOnly { .. } => 2,
crate::core::OrderType::Ioc { .. } => 0,
_ => match req.time_in_force {
crate::core::TimeInForce::Gtc => 1,
crate::core::TimeInForce::Ioc => 0,
crate::core::TimeInForce::Fok => 0,
_ => 1,
},
};
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
let expired_at_ms = now_ms + 3_600_000; let order_expiry_ms = now_ms + 28 * 86_400_000;
let signature_b64 = auth.sign_create_order(
market_index,
nonce,
expired_at_ms,
base_amount,
price_tick,
is_ask,
order_type_code,
tif_code,
false, 0, order_expiry_ms,
None, )?;
let tx_info = serde_json::json!({
"account_index": account_index,
"market_index": market_index,
"is_ask": is_ask,
"base_amount": base_amount.to_string(),
"price": price_tick,
"nonce": nonce,
"expired_at": expired_at_ms,
"order_type": order_type_code,
"time_in_force": tif_code,
"reduce_only": false,
"trigger_price": 0,
"order_expiry": order_expiry_ms,
"client_order_index": serde_json::Value::Null,
"signature": signature_b64,
});
let body = serde_json::json!({
"tx_type": 14,
"tx_info": tx_info,
});
let response = self.post(LighterEndpoint::SendTx, body, 100).await?;
let order_index = response.get("order_index")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let order_type_parsed = match &req.order_type {
crate::core::OrderType::Limit { price } => crate::core::OrderType::Limit { price: *price },
crate::core::OrderType::Market => crate::core::OrderType::Market,
crate::core::OrderType::PostOnly { price } => crate::core::OrderType::PostOnly { price: *price },
crate::core::OrderType::Ioc { price } => crate::core::OrderType::Ioc { price: *price },
other => other.clone(),
};
let order = crate::core::Order {
id: order_index.to_string(),
client_order_id: req.client_order_id.clone(),
symbol: symbol_str,
side: req.side,
order_type: order_type_parsed,
status: crate::core::types::OrderStatus::New,
price: match &req.order_type {
crate::core::OrderType::Limit { price } => Some(*price),
crate::core::OrderType::PostOnly { price } => Some(*price),
crate::core::OrderType::Ioc { price } => *price,
_ => None,
},
stop_price: None,
quantity: req.quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: now_ms,
updated_at: None,
time_in_force: req.time_in_force,
};
Ok(PlaceOrderResponse::Simple(order))
}
pub(crate) async fn cancel_order_signed(
&self,
req: CancelRequest,
) -> ExchangeResult<crate::core::Order> {
let auth = self._auth.as_ref()
.ok_or_else(|| ExchangeError::Auth(
"Authentication required for cancel_order.".to_string()
))?;
let account_index = auth.account_index()
.ok_or_else(|| ExchangeError::Auth(
"account_index required in credentials passphrase JSON for Lighter order cancellation.".to_string()
))?;
let order_id_str = match &req.scope {
crate::core::types::CancelScope::Single { order_id } => order_id.clone(),
other => {
return Err(ExchangeError::UnsupportedOperation(
format!(
"Lighter cancel_order only supports CancelScope::Single. \
Got: {:?}. Each Lighter cancel requires a signed transaction per order.",
other
)
));
}
};
let order_index: i64 = order_id_str.parse().map_err(|_| {
ExchangeError::InvalidRequest(format!(
"Lighter order_id must be a numeric order_index, got '{}'", order_id_str
))
})?;
let market_id_u16 = if let Some(sym) = &req.symbol {
let sym_str = format_symbol(&sym.base, &sym.quote, req.account_type);
self.get_market_id(&sym_str, req.account_type).await?
} else {
return Err(ExchangeError::InvalidRequest(
"Lighter cancel_order requires a symbol hint to determine market_id. \
Set CancelRequest::symbol to the symbol of the order being cancelled.".to_string()
));
};
let market_index = market_id_u16 as i16;
let nonce = self.fetch_next_nonce(account_index).await? as i64;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
let expired_at_ms = now_ms + 3_600_000;
let signature_b64 = auth.sign_cancel_order(
market_index,
nonce,
expired_at_ms,
order_index,
)?;
let tx_info = serde_json::json!({
"account_index": account_index,
"market_index": market_index,
"order_index": order_index,
"nonce": nonce,
"expired_at": expired_at_ms,
"signature": signature_b64,
});
let body = serde_json::json!({
"tx_type": 15,
"tx_info": tx_info,
});
let _response = self.post(LighterEndpoint::SendTx, body, 100).await?;
let symbol_str = req.symbol
.as_ref()
.map(|s| format_symbol(&s.base, &s.quote, req.account_type))
.unwrap_or_default();
Ok(crate::core::Order {
id: order_id_str,
client_order_id: None,
symbol: symbol_str,
side: crate::core::types::OrderSide::Buy, order_type: crate::core::OrderType::Limit { price: 0.0 },
status: crate::core::types::OrderStatus::Canceled,
price: None,
stop_price: None,
quantity: 0.0,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: now_ms,
updated_at: Some(now_ms),
time_in_force: crate::core::TimeInForce::Gtc,
})
}
async fn get_authenticated(
&self,
endpoint: LighterEndpoint,
params: HashMap<String, String>,
weight: u32,
) -> ExchangeResult<Value> {
self.rate_limit_wait(weight, true).await;
let base_url = self.urls.rest_url();
let path = endpoint.path();
let query = if params.is_empty() {
String::new()
} else {
let qs: Vec<String> = params.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect();
format!("?{}", qs.join("&"))
};
let url = format!("{}{}{}", base_url, path, query);
let headers = self._auth.as_ref()
.map(|a| a.make_auth_headers())
.unwrap_or_default();
let response = self.http.get_with_headers(&url, &HashMap::new(), &headers).await?;
self.check_response(&response)?;
Ok(response)
}
}
fn interval_to_ms(interval: &str) -> u64 {
match interval {
"1m" => 60_000,
"5m" => 300_000,
"15m" => 900_000,
"30m" => 1_800_000,
"1h" => 3_600_000,
"4h" => 14_400_000,
"12h" => 43_200_000,
"1d" => 86_400_000,
"1w" => 604_800_000,
_ => 3_600_000, }
}