// Package client is a small Injective LCD (REST) client for exchange-module // order books with hedged requests across several public endpoints and a // sequence guard that rejects snapshots older than one already seen. // It uses only net/http, so the same code runs natively and in the browser // (GOOS=js GOARCH=wasm, where net/http is backed by fetch). package client import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "sync" "time" "github.com/edwardtay/injective-booklab/book" "github.com/edwardtay/injective-booklab/dec" ) // DefaultEndpoints are public, keyless Injective mainnet LCD hosts that send // CORS headers (checked 2026-11-07). var DefaultEndpoints = []string{ "https://sentry.lcd.injective.network", "https://injective-api.polkachu.com", "https://injective-rest.publicnode.com", "https://rest.cosmos.directory/injective", } var ErrStale = errors.New("client: snapshot sequence is older than one already seen") type EndpointStat struct { URL string `json:"url"` OK int `json:"ok"` Errors int `json:"errors"` Stale int `json:"stale"` LastMs int64 `json:"last_ms"` LastSeq uint64 `json:"last_seq"` LastError string `json:"last_error,omitempty"` } type Client struct { HTTP *http.Client Endpoints []string HedgeDelay time.Duration // start the next endpoint if the current one is this slow mu sync.Mutex stats map[string]*EndpointStat seen map[string]uint64 // market id -> highest seq returned to the caller primary int } func New(endpoints ...string) *Client { if len(endpoints) == 0 { endpoints = DefaultEndpoints } c := &Client{HTTP: &http.Client{Timeout: 8 * time.Second}, Endpoints: endpoints, HedgeDelay: 400 * time.Millisecond, stats: map[string]*EndpointStat{}, seen: map[string]uint64{}} for _, e := range endpoints { c.stats[e] = &EndpointStat{URL: e} } return c } // Stats returns a copy of per-endpoint health, primary first. func (c *Client) Stats() []EndpointStat { c.mu.Lock() defer c.mu.Unlock() out := make([]EndpointStat, 0, len(c.Endpoints)) for i := range c.Endpoints { out = append(out, *c.stats[c.Endpoints[(c.primary+i)%len(c.Endpoints)]]) } return out } func (c *Client) get(ctx context.Context, base, path string, v any) (time.Duration, error) { t0 := time.Now() req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil) if err != nil { return 0, err } resp, err := c.HTTP.Do(req) if err != nil { return time.Since(t0), err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) if err != nil { return time.Since(t0), err } if resp.StatusCode != http.StatusOK { return time.Since(t0), fmt.Errorf("HTTP %d from %s", resp.StatusCode, base) } return time.Since(t0), json.Unmarshal(body, v) } type attempt[T any] struct { ep string val T ms time.Duration err error } // hedged tries endpoints starting at the current primary. A new endpoint is // started when the previous one errors or exceeds HedgeDelay. The first // result that passes accept wins; the winner becomes primary. func hedged[T any](ctx context.Context, c *Client, fetch func(context.Context, string) (T, time.Duration, error), accept func(string, T) error) (T, string, error) { ctx, cancel := context.WithCancel(ctx) defer cancel() c.mu.Lock() start := c.primary c.mu.Unlock() n := len(c.Endpoints) results := make(chan attempt[T], n) launch := func(i int) { ep := c.Endpoints[(start+i)%n] go func() { v, ms, err := fetch(ctx, ep) results <- attempt[T]{ep, v, ms, err} }() } launched, pending := 1, 1 launch(0) timer := time.NewTimer(c.HedgeDelay) defer timer.Stop() var zero T var errs []error for pending > 0 || launched < n { select { case <-ctx.Done(): return zero, "", ctx.Err() case <-timer.C: if launched < n { launch(launched) launched++ pending++ timer.Reset(c.HedgeDelay) } case a := <-results: pending-- err := a.err if err == nil { err = accept(a.ep, a.val) } c.record(a.ep, a.ms, err) if err == nil { c.mu.Lock() for i, e := range c.Endpoints { if e == a.ep { c.primary = i } } c.mu.Unlock() return a.val, a.ep, nil } errs = append(errs, fmt.Errorf("%s: %w", a.ep, err)) if launched < n { launch(launched) launched++ pending++ timer.Reset(c.HedgeDelay) } } } return zero, "", errors.Join(errs...) } func (c *Client) record(ep string, d time.Duration, err error) { c.mu.Lock() defer c.mu.Unlock() s := c.stats[ep] s.LastMs = d.Milliseconds() switch { case err == nil: s.OK++ s.LastError = "" case errors.Is(err, ErrStale): s.Stale++ s.LastError = err.Error() default: s.Errors++ s.LastError = err.Error() } } type rawLevel struct { P string `json:"p"` Q string `json:"q"` } type rawBook struct { Buys []rawLevel `json:"buys_price_level"` Sells []rawLevel `json:"sells_price_level"` Seq string `json:"seq"` } func convert(rl []rawLevel) ([]book.Level, error) { out := make([]book.Level, 0, len(rl)) for _, l := range rl { p, err := dec.Parse(l.P) if err != nil { return nil, err } q, err := dec.Parse(l.Q) if err != nil { return nil, err } out = append(out, book.Level{Price: p, Qty: q}) } return out, nil } // Snapshot is a validated book plus where it came from. type Snapshot struct { Book *book.Book `json:"book"` Endpoint string `json:"endpoint"` Ms int64 `json:"ms"` At time.Time `json:"at"` } // Orderbook fetches an L2 snapshot (kind is "spot" or "derivative") from the // exchange module v2 query and validates it. A snapshot with a lower seq than // one already returned for the market is rejected as stale and the next // endpoint is tried. func (c *Client) Orderbook(ctx context.Context, kind, marketID string, limit int) (Snapshot, error) { path := fmt.Sprintf("/injective/exchange/v2/%s/orderbook/%s?limit=%d", kind, marketID, limit) t0 := time.Now() type res struct { b *book.Book ms time.Duration } fetch := func(ctx context.Context, ep string) (res, time.Duration, error) { var rb rawBook ms, err := c.get(ctx, ep, path, &rb) if err != nil { return res{}, ms, err } b := &book.Book{MarketID: marketID} if b.Seq, err = strconv.ParseUint(rb.Seq, 10, 64); err != nil { return res{}, ms, fmt.Errorf("bad seq %q", rb.Seq) } if b.Bids, err = convert(rb.Buys); err != nil { return res{}, ms, err } if b.Asks, err = convert(rb.Sells); err != nil { return res{}, ms, err } return res{b, ms}, ms, b.Validate() } accept := func(ep string, r res) error { c.mu.Lock() defer c.mu.Unlock() c.stats[ep].LastSeq = r.b.Seq if prev := c.seen[marketID]; r.b.Seq < prev { return fmt.Errorf("%w (got %d, have %d)", ErrStale, r.b.Seq, prev) } c.seen[marketID] = r.b.Seq return nil } r, ep, err := hedged(ctx, c, fetch, accept) if err != nil { return Snapshot{}, err } return Snapshot{Book: r.b, Endpoint: ep, Ms: time.Since(t0).Milliseconds(), At: time.Now()}, nil } type rawSpot struct { Markets []struct { Ticker string `json:"ticker"` MarketID string `json:"market_id"` PriceTick string `json:"min_price_tick_size"` QtyTick string `json:"min_quantity_tick_size"` MinNotional string `json:"min_notional"` MakerFee string `json:"maker_fee_rate"` TakerFee string `json:"taker_fee_rate"` } `json:"markets"` } type rawDeriv struct { Markets []struct { Market struct { Ticker string `json:"ticker"` MarketID string `json:"market_id"` PriceTick string `json:"min_price_tick_size"` QtyTick string `json:"min_quantity_tick_size"` MinNotional string `json:"min_notional"` MakerFee string `json:"maker_fee_rate"` TakerFee string `json:"taker_fee_rate"` OracleType string `json:"oracle_type"` } `json:"market"` MarkPrice string `json:"mark_price"` } `json:"markets"` } // MarketInfo is book.Market plus fields only derivatives have. type MarketInfo struct { book.Market MarkPrice dec.Dec `json:"mark_price"` OracleType string `json:"oracle_type,omitempty"` } func p(s string) dec.Dec { d, _ := dec.Parse(s); return d } // Markets lists active spot and derivative markets. func (c *Client) Markets(ctx context.Context) ([]MarketInfo, error) { var out []MarketInfo ok := func(string, rawSpot) error { return nil } spot, _, err := hedged(ctx, c, func(ctx context.Context, ep string) (rawSpot, time.Duration, error) { var r rawSpot ms, err := c.get(ctx, ep, "/injective/exchange/v2/spot/markets?status=Active", &r) return r, ms, err }, ok) if err != nil { return nil, err } for _, m := range spot.Markets { out = append(out, MarketInfo{Market: book.Market{ID: m.MarketID, Ticker: m.Ticker, Kind: "spot", PriceTick: p(m.PriceTick), QtyTick: p(m.QtyTick), MinNotional: p(m.MinNotional), MakerFee: p(m.MakerFee), TakerFee: p(m.TakerFee)}, MarkPrice: dec.Zero()}) } der, _, err := hedged(ctx, c, func(ctx context.Context, ep string) (rawDeriv, time.Duration, error) { var r rawDeriv ms, err := c.get(ctx, ep, "/injective/exchange/v2/derivative/markets?status=Active&with_mid_price_and_tob=false", &r) return r, ms, err }, func(string, rawDeriv) error { return nil }) if err != nil { return out, err } for _, x := range der.Markets { m := x.Market out = append(out, MarketInfo{Market: book.Market{ID: m.MarketID, Ticker: m.Ticker, Kind: "derivative", PriceTick: p(m.PriceTick), QtyTick: p(m.QtyTick), MinNotional: p(m.MinNotional), MakerFee: p(m.MakerFee), TakerFee: p(m.TakerFee)}, MarkPrice: p(x.MarkPrice), OracleType: m.OracleType}) } return out, nil }