Lightcone SDK v2
Unified Rust SDK for the Lightcone protocol. Supports both native and WASM targets under a single crate with compile-time feature dispatch.
Why v2?
The v1 SDK (deps/sdk/rust/) was built as a native-first Rust library with three independent modules (program, api, websocket). It worked well for CLI tooling and server-side use, but introduced significant friction when the Dioxus fullstack app needed to use it on both native (server) and WASM (browser) targets simultaneously.
Problems with v1
- Not WASM-compatible. The API client used
tokio::sync::RwLock,tokio::time::sleep, andreqwestfeatures (.timeout(),.is_connect(),cookies) that don't compile towasm32-unknown-unknown. The WebSocket client wastokio-tungstenite-only. - No domain types. v1 only exposed raw wire types (REST response structs). The app had to define its own domain types (
Market,Order,OrderBookPair, etc.) insrc/domain/, implement allTryFromconversions, validation logic, and business methods. This duplicated work that belongs in the SDK. - No WebSocket message types. The app defined its own
MessageIn,MessageOut,Kind,SubscribeParams, andSubscriptiontypes insrc/service/ws/. These should be SDK-provided so other consumers get the same typed WS interface. - Flat client API. All endpoints were top-level methods on a single
LightconeApiClientstruct, making it hard to manage per-domain caching or organize related operations. - All-or-nothing retry. Retry was a global setting. Non-idempotent POSTs (order submission, cancellation) were retried the same as GETs, risking duplicate actions.
- No auth abstraction. Auth was a standalone function that returned a raw JWT string. No credential management, no platform-aware token handling, no logout flow.
What v2 adds
- Unified native + WASM support. Compile-time
cfgdispatch for platform-specific code (async locks, timers, HTTP features, WebSocket transport). One crate, two targets, zero runtime overhead. - Rich domain types with validation.
Market,Order,OrderBookPair,Outcome,ConditionalToken,DepositAsset,TokenBalance,Portfolio, etc. — all migrated from the app with theirTryFromconversions and business logic. - Wire types as a public secondary API. Raw serde structs for REST and WS are public under
domain::*/wirefor consumers who need raw access (server functions, debugging), but domain types are the primary interface. - Typed WebSocket messages.
MessageIn,MessageOut,Kindenum,SubscribeParams,UnsubscribeParams, andSubscriptiontrait — all SDK-provided with full channel coverage (book, trades, user, price_history, ticker, market). - Nested sub-client API.
client.markets().get_by_slug(...),client.orderbooks().decimals(...),client.orders().submit(...)— organized by domain for clean ergonomics. The SDK is stateless for HTTP data; caching is the consumer's responsibility. - Granular retry policies.
RetryPolicy::Idempotentfor GETs,RetryPolicy::Nonefor non-idempotent POSTs,RetryPolicy::Customfor anything else. Backoff + jitter, 429Retry-Aftersupport. - Secure auth with platform-aware token handling. HTTP-only cookies on WASM (SDK never touches the token), internal private storage on native (never exposed via public API). Logout calls the backend endpoint to clear cookies server-side.
- App-owned WS state containers.
OrderbookSnapshot,UserOpenOrders,TradeHistory,PriceHistoryState— standalone types with update methods that the app wraps in its own reactive state (e.g. DioxusSignal). Avoids theRwLockvs reactive signal mismatch. - On-chain program module carried over from v1. Instructions, orders, PDAs, accounts — unchanged.
Coexistence with v1
Both SDKs can be imported simultaneously for incremental migration:
use *; // v1 — existing code
use *; // v2 — new code
The v2 crate is named lightcone in Cargo.toml and imported as a non-optional dependency. Migrate domain-by-domain, then drop v1.
Architecture
The SDK is organized in five layers:
| Layer | Module | Purpose |
|---|---|---|
| 1 | shared, domain, program |
Core types, domain models, on-chain program logic. Always available, WASM-safe. |
| 2 | auth |
Message generation + platform-dependent signing. |
| 3 | http |
LightconeHttp — low-level HTTP client with per-endpoint retry policies. |
| 4 | ws |
WebSocket — compile-time dispatch: tokio-tungstenite (native) / web-sys (WASM). |
| 5 | client |
LightconeClient — high-level nested sub-client API with caching. |
Module Layout
src/
lib.rs # Public re-exports + prelude
client.rs # LightconeClient, LightconeClientBuilder, sub-client accessors
error.rs # SdkError, HttpError, WsError, AuthError
network.rs # DEFAULT_API_URL, DEFAULT_WS_URL
shared/
mod.rs # OrderBookId, PubkeyStr, Side, Resolution, SubmitOrderRequest
scaling.rs # Price/size → raw lamport conversion
price.rs # Decimal formatting utilities
fmt/ # Decimal display, number abbreviation (num.rs, decimal.rs)
serde_util.rs # Serde helpers
domain/
mod.rs
market/
mod.rs # Market, Status, ValidationError
client.rs # Markets sub-client (get, get_by_slug, search, featured, cache)
outcome.rs # Outcome, OutcomeValidationError (sub-entity of market)
tokens.rs # Token trait, ConditionalToken, DepositAsset, ValidatedTokens, TokenMetadata + TryFrom
wire.rs # MarketResponse, OutcomeResponse, DepositAssetResponse, etc.
convert.rs # TryFrom<MarketResponse> for Market
orderbook/
mod.rs # OrderBookPair, OrderBookValidationError, OutcomeImpact
client.rs # Orderbooks sub-client (get, decimals, cache)
ticker.rs # TickerData (best bid/ask/mid)
wire.rs # OrderbookResponse, DecimalsResponse, BookOrder, etc.
state.rs # OrderbookSnapshot with apply(), bids(), asks(), best_bid()
convert.rs # TryFrom<(OrderbookResponse, &[ConditionalToken])> for OrderBookPair
order/
mod.rs # Order, OrderType, OrderStatus
client.rs # Orders sub-client (submit, cancel, cancel_all, get_user_orders)
state.rs # UserOpenOrders (app-owned state container)
wire.rs # OrderUpdate, UserSnapshot, UserUpdate, AuthUpdate + WS balance types
convert.rs # From<OrderUpdate/UserSnapshotOrder> for Order
trade/
mod.rs # Trade
client.rs # Trades sub-client (get)
state.rs # TradeHistory (rolling buffer, app-owned)
wire.rs # TradeResponse, WsTrade, TradesResponse
convert.rs # From<TradeResponse/WsTrade> for Trade
price_history/
mod.rs # LineData
client.rs # PriceHistoryClient sub-client (get)
state.rs # PriceHistoryState (app-owned state container)
wire.rs # PriceHistory snapshot/update (WS)
position/
mod.rs # Portfolio, Position, PositionOutcome, WalletHolding, TokenBalance types
client.rs # Positions sub-client (get, get_for_market)
wire.rs # PositionsResponse, PositionResponse (REST)
admin/
mod.rs # AdminEnvelope (domain-level signed envelope)
client.rs # Admin sub-client (upsert_metadata)
wire.rs # UnifiedMetadataRequest/Response, *MetadataPayload types
program/ # On-chain: instructions, orders, PDAs, accounts (from v1)
auth/
mod.rs # generate_signin_message, AuthCredentials, LoginRequest/Response
client.rs # Auth sub-client (login_with_message, logout, credentials)
native.rs # KeypairAuth — sign_login_message (native-auth feature)
http/
mod.rs
client.rs # LightconeHttp (internal)
retry.rs # RetryPolicy, RetryConfig
ws/
mod.rs # MessageIn, MessageOut, Kind, WsEvent, WsConfig
subscriptions.rs # SubscribeParams, UnsubscribeParams, Subscription trait
native.rs # tokio-tungstenite (ws-native feature) [TODO: stub]
wasm.rs # web-sys WebSocket (ws-wasm feature) — full implementation
Features
| Feature | What it enables |
|---|---|
http (default) |
HTTP client (Layers 1-3). Works on both native and WASM. |
native-auth |
Keypair-based authentication (Layer 2 native). |
ws-native |
WebSocket via tokio-tungstenite (Layer 4 native). |
ws-wasm |
WebSocket via web-sys::WebSocket (Layer 4 WASM). |
solana-rpc |
solana-client for on-chain reads (native only). |
native |
Bundle: http + native-auth + ws-native + solana-rpc. For CLI/server use. |
wasm |
Bundle: http + ws-wasm. For browser use. |
Quick Start
use *;
async
Retry Strategy
- GET requests:
RetryPolicy::Idempotentby default — retries on transport failures + 502/503/504, backs off on 429. - POST /orders/submit, /orders/cancel, /orders/cancel-all:
RetryPolicy::Noneby default. - POST /auth/login:
RetryPolicy::None. - All retry policies are overridable per-call.
Rule of thumb: Retry transport failures and status checks; don't retry "actions" unless you have idempotency guarantees.
Domain Types vs Wire Types
The SDK exposes two levels of types:
- Domain types (
lightcone::domain::*) — Rich, validated types with business logic methods. These are the primary public API. Example:Market,Order,OrderBookPair. - Wire types (
lightcone::domain::*/wire) — Raw serde structs matching backend REST responses and WS messages. Also public for consumers who need raw access (forwarding data, debugging, server functions).
Shared newtypes like OrderBookId, PubkeyStr, Side, and Resolution are used directly in wire types because they are serialization-transparent — they deserialize identically to the raw format the backend sends.
State Management
HTTP: Stateless (consumer owns caching)
The SDK is intentionally stateless for HTTP data. Sub-clients are thin, typed wrappers over the REST API — they fetch, convert wire → domain types, and return. No market cache, no slug index, no TTLs.
Why? Different consumers want radically different caching strategies:
- Dioxus server functions use
static LazyLock<Mutex<Cache>>with 1-hour TTLs - CLI tools may want no caching at all
- Admin dashboards may want short TTLs with manual invalidation
The SDK shouldn't pick one strategy. The consumer knows best.
The only exception: orderbooks().decimals() — orderbook decimals are effectively immutable. This is a pure memoization, not a caching policy.
WS-Driven Live State (app-owned)
The SDK does NOT store WS-driven state internally. Instead it provides standalone state container types with update methods:
OrderbookSnapshot—apply()for book snapshots/deltas,best_bid(),best_ask(),mid_price()UserOpenOrders—upsert(),remove()for order updatesTradeHistory— rolling buffer withpush()PriceHistoryState—apply_snapshot(),apply_update()
The app instantiates these, wraps them in its own reactive state (e.g. Dioxus Signal), and calls SDK update methods when WS events arrive. This avoids the RwLock vs reactive signal mismatch.
Authentication — CRITICAL Security Model
This section is mandatory reading for any SDK implementation in any language.
Wasm/Browser
- Token lives ONLY in the HTTP-only cookie set by the backend.
- The SDK never reads, stores, or exposes the token programmatically.
- Authenticated requests work because the browser auto-includes cookies.
- Browser SDKs MUST use HTTP-only cookies — never store tokens in localStorage, sessionStorage, or any JS/WASM-accessible location.
Native/CLI
- The SDK stores the token internally (private field) and injects it as a
Cookie: auth_token=<token>header, matching the backend's cookie-only auth model. - Token is NEVER exposed via public API — no
.token()accessor. AuthCredentialsonly exposes:user_id,wallet_address,expires_at,is_authenticated().- We manually inject the cookie rather than using reqwest's
cookie_store(true)because the backend hardcodesDomain=.lightcone.xyzon the Set-Cookie, which would break local development (requests tolocalhostwouldn't match the domain).
Nonce-Based Sign-In (Native)
Login uses a server-issued, single-use nonce to prevent signature replay attacks. The full flow:
- Fetch a nonce from the server (
GET /api/auth/nonce). - Build the sign-in message embedding the nonce.
- Sign the message with the local keypair.
- Submit the signed message to log in.
use sign_login_message;
use Keypair;
// 1. Fetch a single-use nonce (5-minute TTL, consumed on login)
let nonce = client.auth.get_nonce.await?;
// 2-3. Build message + sign with keypair
let signed = sign_login_message;
// 4. Authenticate
let user = client.auth.login_with_message.await?;
The nonce is validated and deleted server-side on login -- each nonce can only be used once. If you need to retry a failed login, fetch a new nonce.
Logout
On both platforms, client.auth().logout():
- Calls
POST /api/auth/logoutto clear the HTTP-only cookie server-side. - On native: clears the internal token.
- Clears all sub-client HTTP caches.
Client-side clearing alone is insufficient for cookie-based auth — the backend must be told to invalidate.
Backend API Alignment
The SDK aligns with lightcone-backend API routes:
| SDK Method | Backend Route | HTTP Method |
|---|---|---|
markets().get(cursor, limit) |
/api/markets |
GET |
markets().get_by_slug(slug) |
/api/markets/by-slug/{slug} |
GET |
markets().get_by_pubkey(pubkey) |
/api/markets/{market_pubkey} |
GET |
markets().search(q, limit) |
/api/markets/search/by-query/{query} |
GET |
markets().featured() |
/api/markets/search/featured |
GET |
orderbooks().get(id, depth) |
/api/orderbook/{id} |
GET |
orderbooks().decimals(id) |
/api/orderbooks/{id}/decimals |
GET |
orders().submit(req) |
/api/orders/submit |
POST |
orders().cancel(req) |
/api/orders/cancel |
POST |
orders().cancel_all(req) |
/api/orders/cancel-all |
POST |
orders().get_user_orders(req) |
/api/users/orders |
POST |
positions().get(pubkey) |
/api/users/{pubkey}/positions |
GET |
positions().get_for_market(pubkey, market) |
/api/users/{pubkey}/markets/{market_pubkey}/positions |
GET |
trades().get(id, limit, before) |
/api/trades |
GET |
price_history().get(...) |
/api/price-history |
GET |
admin().upsert_metadata(env) |
/api/admin/metadata |
POST |
auth().get_nonce() |
/api/auth/nonce |
GET |
auth().login_with_message(...) |
/api/auth/login_or_register_with_message |
POST |
auth().check_session() |
/api/auth/me |
GET |
auth().logout() |
/api/auth/logout |
POST |
auth().disconnect_x() |
/api/auth/disconnect_x |
POST |
auth().connect_x(...) |
/api/auth/connect_x |
POST |
privy().sign_and_send_tx(...) |
/api/privy/sign_and_send_tx |
POST |
privy().sign_and_send_order(...) |
/api/privy/sign_and_send_order |
POST |
privy().export_wallet(...) |
/api/privy/wallet/export |
POST |
Canonical Shared Types
The SDK defines the canonical shared types for auth and user profiles:
LinkedAccount,LinkedAccountType,ChainType— linked identity typesEmbeddedWallet— Privy-managed embedded walletUser— full user profile (returned bylogin_with_messageandcheck_session)AuthCredentials— session state (user_id, wallet_address, expires_at)
These types are the source of truth. Consumers (Dioxus app, CLI tools) should use SDK types directly rather than defining local equivalents.
OAuth Authentication
OAuth login (Google, X/Twitter) is a browser redirect flow handled entirely by the Lightcone backend -- it is not an SDK method call. The client navigates the browser to the appropriate backend URL, where the full OAuth exchange takes place. On completion, the backend sets an auth_token HTTP-only cookie and redirects back to the frontend.
| Flow | URL |
|---|---|
| Login with Google | GET {backend}/api/auth/oauth/google |
| Login with X | GET {backend}/api/auth/oauth/x |
| Link X account | GET {backend}/api/auth/oauth/link/x (requires existing session) |
Because OAuth requires pre-registered redirect URIs, these endpoints only function on domains configured in the provider's developer console (e.g., the Lightcone domains). After the redirect completes, call check_session() to hydrate the authenticated user profile.
Native and CLI clients authenticate via get_nonce() + login_with_message() with a Solana wallet signature and do not use the OAuth flow. See Nonce-Based Sign-In (Native) above.
Privy Embedded Wallet
The client.privy() sub-client wraps the backend's Privy RPC endpoints for embedded wallet operations. All methods require an active authenticated session.
Embedded wallets are provisioned during login by passing use_embedded_wallet: true to login_with_message(). This works on any platform -- WASM, native, or CLI. Once provisioned, the wallet is tied to the user's account and all Privy sub-client methods are available to any authenticated client.
Native and CLI clients that sign transactions directly with their own keypair typically do not need an embedded wallet, but provisioning and using one is fully supported.
// Sign and send a Solana transaction via embedded wallet
let result = client.privy.sign_and_send_tx.await?;
// Sign an order hash and submit to the exchange engine
let result = client.privy.sign_and_send_order.await?;
// Export embedded wallet private key (HPKE encrypted)
let export = client.privy.export_wallet.await?;
The backend handles all Privy API interaction -- the SDK never touches Privy directly.
WebSocket Channels
| Channel | Subscribe | Events |
|---|---|---|
book |
SubscribeParams::Books { orderbook_ids } |
Kind::BookUpdate — snapshot + delta |
trades |
SubscribeParams::Trades { orderbook_ids } |
Kind::Trade |
user |
SubscribeParams::User |
Kind::User — snapshot, order_update, balance_update |
price_history |
SubscribeParams::PriceHistory { orderbook_id, resolution } |
Kind::PriceHistory — snapshot + update |
ticker |
SubscribeParams::Ticker { orderbook_ids } |
Kind::Ticker — best bid/ask/mid |
market |
SubscribeParams::Market { market_pubkey } |
Kind::Market — settled, created, opened, paused, orderbook_created |
WS book_update uses conditional_pair: "market_pubkey:orderbook_id" (combined string) — wire type parses/splits this.