mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
Default build pulls zero gRPC code; rebuild with `-tags=grpc` to opt
into gRPC.
Decomplected via a `grpcBackend` interface in `sdk/go/client` and a
`grpcSubClient` interface in `client/go`. The default build wires a
no-op backend (`*_nogrpc.go`); the `grpc` build wires the real
protobuf-backed client (`*_grpc.go`). Public methods stay the same in
both builds — the gRPC fast path is preferred when wired, else
JSON-RPC / WebSocket.
Files gated:
- pkg/grpc/pb/lxdex_grpc.pb.go (//go:build grpc)
- pkg/proto/engine/proto/lx_engine_grpc.pb.go (//go:build grpc)
Files refactored:
- sdk/go/client.go: //go:build grpc with stub in client_nogrpc.go
- sdk/go/client/client.go: gRPC removed from method bodies; backed by
the grpcBackend interface (grpc_backend{,_grpc,_nogrpc}.go)
- client/go/main.go: GrpcClient + ConnectGrpc moved to grpc.go
(//go:build grpc); nogrpc.go stubs ConnectGrpc with a clear error
Verification:
- go build ./... (default) clean across sdk/go, client/go, top-level
- go build -tags=grpc ./... clean across all three modules
- go test ./... and go test -tags=grpc ./... green
- go list -deps shows 0 google.golang.org/grpc packages in default
builds of sdk/go/client and client/go; 62 packages with -tags=grpc
READMEs in sdk/go and client/go document the build-tag opt-in.
529 lines
13 KiB
Go
529 lines
13 KiB
Go
// Package client provides a Go SDK for interacting with LX.
|
|
//
|
|
// The default build ships JSON-RPC and WebSocket transports only and
|
|
// pulls in zero gRPC code. gRPC dial support is an opt-in capability
|
|
// gated behind the `grpc` build tag (see client_grpc.go). Consumers
|
|
// that want the binary gRPC transport rebuild with `-tags=grpc`.
|
|
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// Client is the main client for interacting with LX.
|
|
//
|
|
// Public methods (PlaceOrder, CancelOrder, GetOrderBook, GetTrades)
|
|
// route to the gRPC backend first when it is wired and otherwise fall
|
|
// back to JSON-RPC. The grpc backend is a no-op in the default build.
|
|
type Client struct {
|
|
// Configuration
|
|
jsonRPCURL string
|
|
wsURL string
|
|
grpcURL string
|
|
apiKey string
|
|
|
|
// JSON-RPC
|
|
httpClient *http.Client
|
|
idCounter uint64
|
|
|
|
// WebSocket
|
|
wsConn *websocket.Conn
|
|
wsCallbacks map[string]func(interface{})
|
|
wsMu sync.RWMutex
|
|
wsRunning bool
|
|
wsStop chan struct{}
|
|
|
|
// gRPC backend (no-op unless built with -tags=grpc).
|
|
grpc grpcBackend
|
|
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewClient creates a new LX client.
|
|
func NewClient(opts ...Option) (*Client, error) {
|
|
c := &Client{
|
|
jsonRPCURL: "http://localhost:8080",
|
|
wsURL: "ws://localhost:8081",
|
|
grpcURL: "localhost:50051",
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
wsCallbacks: make(map[string]func(interface{})),
|
|
wsStop: make(chan struct{}),
|
|
grpc: newGRPCBackend(),
|
|
}
|
|
|
|
// Apply options
|
|
for _, opt := range opts {
|
|
opt(c)
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// Option is a client configuration option.
|
|
type Option func(*Client)
|
|
|
|
// WithJSONRPCURL sets the JSON-RPC URL.
|
|
func WithJSONRPCURL(url string) Option {
|
|
return func(c *Client) {
|
|
c.jsonRPCURL = url
|
|
}
|
|
}
|
|
|
|
// WithWebSocketURL sets the WebSocket URL.
|
|
func WithWebSocketURL(url string) Option {
|
|
return func(c *Client) {
|
|
c.wsURL = url
|
|
}
|
|
}
|
|
|
|
// WithGRPCURL sets the gRPC URL. The URL is only dialed when the
|
|
// client is built with the `grpc` build tag and ConnectGRPC is called.
|
|
func WithGRPCURL(url string) Option {
|
|
return func(c *Client) {
|
|
c.grpcURL = url
|
|
}
|
|
}
|
|
|
|
// WithAPIKey sets the API key for authentication.
|
|
func WithAPIKey(key string) Option {
|
|
return func(c *Client) {
|
|
c.apiKey = key
|
|
}
|
|
}
|
|
|
|
// ConnectGRPC establishes a gRPC connection. Returns an error in the
|
|
// default build instructing callers to rebuild with `-tags=grpc`.
|
|
func (c *Client) ConnectGRPC(ctx context.Context) error {
|
|
return c.grpc.connect(ctx, c.grpcURL)
|
|
}
|
|
|
|
// ConnectWebSocket establishes a WebSocket connection.
|
|
func (c *Client) ConnectWebSocket(ctx context.Context) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.wsConn != nil {
|
|
return nil // Already connected
|
|
}
|
|
|
|
dialer := websocket.Dialer{
|
|
HandshakeTimeout: 10 * time.Second,
|
|
}
|
|
|
|
conn, _, err := dialer.DialContext(ctx, c.wsURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to WebSocket: %w", err)
|
|
}
|
|
|
|
c.wsConn = conn
|
|
c.wsRunning = true
|
|
|
|
// Start message handler
|
|
go c.handleWebSocketMessages()
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleWebSocketMessages processes incoming WebSocket messages.
|
|
func (c *Client) handleWebSocketMessages() {
|
|
defer func() {
|
|
c.mu.Lock()
|
|
c.wsRunning = false
|
|
c.wsConn.Close()
|
|
c.wsConn = nil
|
|
c.mu.Unlock()
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-c.wsStop:
|
|
return
|
|
default:
|
|
c.wsConn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
|
|
var msg map[string]interface{}
|
|
err := c.wsConn.ReadJSON(&msg)
|
|
if err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
fmt.Printf("WebSocket error: %v\n", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle message
|
|
if channel, ok := msg["channel"].(string); ok {
|
|
c.wsMu.RLock()
|
|
if callback, exists := c.wsCallbacks[channel]; exists {
|
|
c.wsMu.RUnlock()
|
|
go callback(msg["data"])
|
|
} else {
|
|
c.wsMu.RUnlock()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Disconnect closes all connections.
|
|
func (c *Client) Disconnect() error {
|
|
// Close WebSocket
|
|
if c.wsRunning {
|
|
close(c.wsStop)
|
|
c.mu.Lock()
|
|
if c.wsConn != nil {
|
|
c.wsConn.Close()
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// Close gRPC backend (no-op in default build).
|
|
_ = c.grpc.close()
|
|
|
|
return nil
|
|
}
|
|
|
|
// PlaceOrder places a new order. Uses the gRPC backend when available,
|
|
// otherwise falls back to JSON-RPC.
|
|
func (c *Client) PlaceOrder(ctx context.Context, order *Order) (*OrderResponse, error) {
|
|
if resp, ok, err := c.grpc.placeOrder(ctx, order); ok {
|
|
return resp, err
|
|
}
|
|
|
|
params := map[string]interface{}{
|
|
"symbol": order.Symbol,
|
|
"type": order.Type,
|
|
"side": order.Side,
|
|
"price": order.Price,
|
|
"size": order.Size,
|
|
"userID": order.UserID,
|
|
"clientID": order.ClientID,
|
|
"timeInForce": order.TimeInForce,
|
|
"postOnly": order.PostOnly,
|
|
"reduceOnly": order.ReduceOnly,
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := c.callJSONRPC(ctx, "lx_placeOrder", params, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &OrderResponse{
|
|
OrderID: uint64(result["orderId"].(float64)),
|
|
Status: result["status"].(string),
|
|
}, nil
|
|
}
|
|
|
|
// CancelOrder cancels an existing order.
|
|
func (c *Client) CancelOrder(ctx context.Context, orderID uint64) error {
|
|
if ok, err := c.grpc.cancelOrder(ctx, orderID); ok {
|
|
return err
|
|
}
|
|
|
|
params := map[string]interface{}{
|
|
"orderId": orderID,
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
return c.callJSONRPC(ctx, "lx_cancelOrder", params, &result)
|
|
}
|
|
|
|
// GetOrderBook retrieves the order book for a symbol.
|
|
func (c *Client) GetOrderBook(ctx context.Context, symbol string, depth int32) (*OrderBook, error) {
|
|
if ob, ok, err := c.grpc.getOrderBook(ctx, symbol, depth); ok {
|
|
return ob, err
|
|
}
|
|
|
|
params := map[string]interface{}{
|
|
"symbol": symbol,
|
|
"depth": depth,
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := c.callJSONRPC(ctx, "lx_getOrderBook", params, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ob := &OrderBook{
|
|
Symbol: result["Symbol"].(string),
|
|
Timestamp: int64(result["Timestamp"].(float64)),
|
|
}
|
|
|
|
bids := result["Bids"].([]interface{})
|
|
ob.Bids = make([]PriceLevel, len(bids))
|
|
for i, bid := range bids {
|
|
b := bid.(map[string]interface{})
|
|
ob.Bids[i] = PriceLevel{
|
|
Price: b["Price"].(float64),
|
|
Size: b["Size"].(float64),
|
|
}
|
|
}
|
|
|
|
asks := result["Asks"].([]interface{})
|
|
ob.Asks = make([]PriceLevel, len(asks))
|
|
for i, ask := range asks {
|
|
a := ask.(map[string]interface{})
|
|
ob.Asks[i] = PriceLevel{
|
|
Price: a["Price"].(float64),
|
|
Size: a["Size"].(float64),
|
|
}
|
|
}
|
|
|
|
return ob, nil
|
|
}
|
|
|
|
// GetTrades retrieves recent trades.
|
|
func (c *Client) GetTrades(ctx context.Context, symbol string, limit int32) ([]*Trade, error) {
|
|
if trades, ok, err := c.grpc.getTrades(ctx, symbol, limit); ok {
|
|
return trades, err
|
|
}
|
|
|
|
params := map[string]interface{}{
|
|
"symbol": symbol,
|
|
"limit": limit,
|
|
}
|
|
|
|
var result []interface{}
|
|
if err := c.callJSONRPC(ctx, "lx_getTrades", params, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
trades := make([]*Trade, len(result))
|
|
for i, t := range result {
|
|
trade := t.(map[string]interface{})
|
|
trades[i] = &Trade{
|
|
TradeID: uint64(trade["tradeId"].(float64)),
|
|
Symbol: trade["symbol"].(string),
|
|
Price: trade["price"].(float64),
|
|
Size: trade["size"].(float64),
|
|
Side: OrderSide(trade["side"].(float64)),
|
|
BuyOrderID: uint64(trade["buyOrderId"].(float64)),
|
|
SellOrderID: uint64(trade["sellOrderId"].(float64)),
|
|
BuyerID: trade["buyerId"].(string),
|
|
SellerID: trade["sellerId"].(string),
|
|
Timestamp: int64(trade["timestamp"].(float64)),
|
|
}
|
|
}
|
|
|
|
return trades, nil
|
|
}
|
|
|
|
// Subscribe subscribes to a WebSocket channel.
|
|
func (c *Client) Subscribe(channel string, callback func(interface{})) error {
|
|
c.wsMu.Lock()
|
|
c.wsCallbacks[channel] = callback
|
|
c.wsMu.Unlock()
|
|
|
|
msg := map[string]interface{}{
|
|
"type": "subscribe",
|
|
"channel": channel,
|
|
}
|
|
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
if c.wsConn == nil {
|
|
return fmt.Errorf("WebSocket not connected")
|
|
}
|
|
|
|
return c.wsConn.WriteJSON(msg)
|
|
}
|
|
|
|
// Unsubscribe unsubscribes from a WebSocket channel.
|
|
func (c *Client) Unsubscribe(channel string) error {
|
|
c.wsMu.Lock()
|
|
delete(c.wsCallbacks, channel)
|
|
c.wsMu.Unlock()
|
|
|
|
msg := map[string]interface{}{
|
|
"type": "unsubscribe",
|
|
"channel": channel,
|
|
}
|
|
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
if c.wsConn == nil {
|
|
return nil // Not connected, nothing to do
|
|
}
|
|
|
|
return c.wsConn.WriteJSON(msg)
|
|
}
|
|
|
|
// SubscribeOrderBook subscribes to order book updates.
|
|
func (c *Client) SubscribeOrderBook(symbol string, callback func(*OrderBook)) error {
|
|
return c.Subscribe(fmt.Sprintf("orderbook:%s", symbol), func(data interface{}) {
|
|
if m, ok := data.(map[string]interface{}); ok {
|
|
ob := &OrderBook{
|
|
Symbol: m["symbol"].(string),
|
|
Timestamp: int64(m["timestamp"].(float64)),
|
|
}
|
|
callback(ob)
|
|
}
|
|
})
|
|
}
|
|
|
|
// SubscribeTrades subscribes to trade updates.
|
|
func (c *Client) SubscribeTrades(symbol string, callback func(*Trade)) error {
|
|
return c.Subscribe(fmt.Sprintf("trades:%s", symbol), func(data interface{}) {
|
|
if m, ok := data.(map[string]interface{}); ok {
|
|
trade := &Trade{
|
|
TradeID: uint64(m["tradeId"].(float64)),
|
|
Symbol: m["symbol"].(string),
|
|
Price: m["price"].(float64),
|
|
Size: m["size"].(float64),
|
|
Timestamp: int64(m["timestamp"].(float64)),
|
|
}
|
|
callback(trade)
|
|
}
|
|
})
|
|
}
|
|
|
|
// StreamOrderBook streams order book updates. Requires the `grpc`
|
|
// build tag; returns ErrGRPCNotBuilt otherwise.
|
|
func (c *Client) StreamOrderBook(ctx context.Context, symbol string) (<-chan *OrderBook, error) {
|
|
return c.grpc.streamOrderBook(ctx, symbol)
|
|
}
|
|
|
|
// callJSONRPC makes a JSON-RPC call.
|
|
func (c *Client) callJSONRPC(ctx context.Context, method string, params interface{}, result interface{}) error {
|
|
id := atomic.AddUint64(&c.idCounter, 1)
|
|
|
|
request := map[string]interface{}{
|
|
"jsonrpc": "2.0",
|
|
"method": method,
|
|
"params": params,
|
|
"id": id,
|
|
}
|
|
|
|
reqBody, err := json.Marshal(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.jsonRPCURL+"/rpc", bytes.NewBuffer(reqBody))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.apiKey != "" {
|
|
req.Header.Set("X-API-Key", c.apiKey)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var response struct {
|
|
Result interface{} `json:"result"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
ID uint64 `json:"id"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
|
return err
|
|
}
|
|
|
|
if response.Error != nil {
|
|
return fmt.Errorf("RPC error %d: %s", response.Error.Code, response.Error.Message)
|
|
}
|
|
|
|
if result != nil {
|
|
resultBytes, _ := json.Marshal(response.Result)
|
|
return json.Unmarshal(resultBytes, result)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// sendJSONRPCRequest sends a JSON-RPC request and returns the response.
|
|
func (c *Client) sendJSONRPCRequest(ctx context.Context, req map[string]interface{}) (*struct {
|
|
Result json.RawMessage `json:"result"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
ID uint64 `json:"id"`
|
|
}, error) {
|
|
reqBody, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.jsonRPCURL+"/rpc", bytes.NewBuffer(reqBody))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
if c.apiKey != "" {
|
|
httpReq.Header.Set("X-API-Key", c.apiKey)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var response struct {
|
|
Result json.RawMessage `json:"result"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
ID uint64 `json:"id"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if response.Error != nil {
|
|
return nil, fmt.Errorf("RPC error %d: %s", response.Error.Code, response.Error.Message)
|
|
}
|
|
|
|
return &response, nil
|
|
}
|
|
|
|
// sendWSMessage sends a message through the WebSocket connection.
|
|
func (c *Client) sendWSMessage(msg map[string]interface{}) error {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
if c.wsConn == nil {
|
|
return fmt.Errorf("WebSocket not connected")
|
|
}
|
|
|
|
return c.wsConn.WriteJSON(msg)
|
|
}
|
|
|
|
// Ping checks if the server is responsive.
|
|
func (c *Client) Ping(ctx context.Context) error {
|
|
var result string
|
|
return c.callJSONRPC(ctx, "lx_ping", nil, &result)
|
|
}
|
|
|
|
// GetInfo retrieves node information.
|
|
func (c *Client) GetInfo(ctx context.Context) (*NodeInfo, error) {
|
|
var result NodeInfo
|
|
if err := c.callJSONRPC(ctx, "lx_getInfo", nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|