//go:build js && wasm // The browser build: the same client, book and fba packages, exposed to // JavaScript as globalThis.inj. Network calls return Promises because // net/http blocks and must not run on the JS event loop goroutine. package main import ( "context" "encoding/json" "fmt" "runtime" "sync" "syscall/js" "time" "github.com/edwardtay/injective-booklab/book" "github.com/edwardtay/injective-booklab/client" "github.com/edwardtay/injective-booklab/dec" "github.com/edwardtay/injective-booklab/fba" ) var ( c = client.New() mu sync.Mutex markets = map[string]client.MarketInfo{} cur *book.Book curM client.MarketInfo ) func toJS(v any) js.Value { b, err := json.Marshal(v) if err != nil { return js.ValueOf(fmt.Sprintf(`{"error":%q}`, err.Error())) } return js.Global().Get("JSON").Call("parse", string(b)) } func errJS(err error) js.Value { return toJS(map[string]string{"error": err.Error()}) } // promise runs fn in a goroutine and settles a JS Promise with its result. func promise(fn func() (any, error)) js.Value { var h js.Func h = js.FuncOf(func(this js.Value, args []js.Value) any { resolve, reject := args[0], args[1] go func() { defer h.Release() v, err := fn() if err != nil { reject.Invoke(js.Global().Get("Error").New(err.Error())) return } resolve.Invoke(toJS(v)) }() return nil }) return js.Global().Get("Promise").New(h) } func parse(v js.Value) (dec.Dec, error) { if v.IsUndefined() || v.IsNull() || v.String() == "" { return dec.Zero(), nil } return dec.Parse(v.String()) } type bookView struct { Market client.MarketInfo `json:"market"` Snapshot client.Snapshot `json:"snapshot"` Mid dec.Dec `json:"mid"` SpreadBps dec.Dec `json:"spread_bps"` Depth []book.Depth `json:"depth"` Changes []book.Change `json:"changes"` SeqMoved bool `json:"seq_moved"` HasPrev bool `json:"has_prev"` DecodeUs int64 `json:"decode_us"` } func main() { api := map[string]any{ "goVersion": runtime.Version(), "markets": js.FuncOf(func(this js.Value, a []js.Value) any { return promise(func() (any, error) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() ms, err := c.Markets(ctx) if err != nil { return nil, err } mu.Lock() for _, m := range ms { markets[m.ID] = m } mu.Unlock() return ms, nil }) }), "book": js.FuncOf(func(this js.Value, a []js.Value) any { id, limit := a[0].String(), a[1].Int() return promise(func() (any, error) { mu.Lock() m, ok := markets[id] mu.Unlock() if !ok { return nil, fmt.Errorf("unknown market %s", id) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() t0 := time.Now() s, err := c.Orderbook(ctx, m.Kind, id, limit) if err != nil { return nil, err } v := bookView{Market: m, Snapshot: s, DecodeUs: time.Since(t0).Microseconds() - s.Ms*1000} v.Mid, _ = s.Book.Mid() v.SpreadBps, _ = s.Book.SpreadBps() for _, bps := range []int64{10, 50, 100, 200} { v.Depth = append(v.Depth, s.Book.DepthWithin(bps)) } mu.Lock() if cur != nil && cur.MarketID == id { v.HasPrev = true v.Changes = book.Diff(cur, s.Book) v.SeqMoved = s.Book.Seq != cur.Seq } cur, curM = s.Book, m mu.Unlock() return v, nil }) }), "sim": js.FuncOf(func(this js.Value, a []js.Value) any { mu.Lock() b, m := cur, curM mu.Unlock() if b == nil { return errJS(fmt.Errorf("load a book first")) } side, err := book.ParseSide(a[0].String()) if err != nil { return errJS(err) } qty, err := parse(a[1]) if err != nil || !qty.IsPositive() { return errJS(fmt.Errorf("quantity must be a positive decimal")) } limit, err := parse(a[2]) if err != nil { return errJS(fmt.Errorf("worst price must be a decimal")) } t0 := time.Now() r := book.SimulateTaker(b, side, qty, limit, m.TakerFee) out := map[string]any{"result": r, "micros": time.Since(t0).Microseconds(), "taker_fee_rate": m.TakerFee} if limit.IsPositive() { out["checks"] = m.CheckOrder(limit, qty) } else { out["checks"] = m.CheckOrder(r.Mid.FloorTo(m.PriceTick), qty) // market order: only size rules bite } return toJS(out) }), "check": js.FuncOf(func(this js.Value, a []js.Value) any { mu.Lock() m := curM mu.Unlock() price, err1 := parse(a[0]) qty, err2 := parse(a[1]) if err1 != nil || err2 != nil { return toJS([]string{"price and quantity must be plain decimals (no exponents, at most 18 fractional digits)"}) } return toJS(m.CheckOrder(price, qty)) }), "auction": js.FuncOf(func(this js.Value, a []js.Value) any { mu.Lock() b, m := cur, curM mu.Unlock() if b == nil { return errJS(fmt.Errorf("load a book first")) } var in []struct { ID string `json:"id"` Side string `json:"side"` Type string `json:"type"` Price string `json:"price"` Qty string `json:"qty"` } if err := json.Unmarshal([]byte(a[0].String()), &in); err != nil { return errJS(err) } var mb, ms, lim []fba.Order for _, o := range in { side, err := book.ParseSide(o.Side) if err != nil { return errJS(err) } px, err := parse(js.ValueOf(o.Price)) if err != nil { return errJS(fmt.Errorf("%s: bad price %q", o.ID, o.Price)) } q, err := dec.Parse(o.Qty) if err != nil || !q.IsPositive() { return errJS(fmt.Errorf("%s: bad quantity %q", o.ID, o.Qty)) } if o.Type != "market" && !px.IsPositive() { return errJS(fmt.Errorf("%s: a limit order needs a positive price", o.ID)) } fo := fba.Order{ID: o.ID, Side: side, Price: px, Qty: q} switch { case o.Type == "market" && side == book.Buy: mb = append(mb, fo) case o.Type == "market": ms = append(ms, fo) default: lim = append(lim, fo) } } t0 := time.Now() res := map[string]any{} // Stage 1 on the start-of-block book, stage 2 on what is left. start := cloneBook(b) res["market_buys"] = fba.ClearMarket(start, book.Buy, mb) res["market_sells"] = fba.ClearMarket(start, book.Sell, ms) afterMkt := consume(start, res["market_buys"].(fba.SideResult), res["market_sells"].(fba.SideResult)) res["limit"] = fba.ClearLimit(afterMkt, lim, m.MarkPrice) res["micros"] = time.Since(t0).Microseconds() return toJS(res) }), "stats": js.FuncOf(func(this js.Value, a []js.Value) any { return toJS(c.Stats()) }), } js.Global().Set("inj", js.ValueOf(api)) js.Global().Call("dispatchEvent", js.Global().Get("Event").New("inj-ready")) select {} } func cloneBook(b *book.Book) *book.Book { return &book.Book{MarketID: b.MarketID, Seq: b.Seq, Bids: append([]book.Level(nil), b.Bids...), Asks: append([]book.Level(nil), b.Asks...)} } // consume removes stage-1 resting fills from the book before stage 2. func consume(b *book.Book, buys, sells fba.SideResult) *book.Book { take := func(lv []book.Level, fills []book.Fill) []book.Level { out := append([]book.Level(nil), lv...) for _, f := range fills { for i := range out { if out[i].Price.Equal(f.Price) { out[i].Qty = out[i].Qty.Sub(f.Qty) } } } kept := out[:0] for _, l := range out { if l.Qty.IsPositive() { kept = append(kept, l) } } return kept } return &book.Book{MarketID: b.MarketID, Seq: b.Seq, Asks: take(b.Asks, buys.RestingFills), Bids: take(b.Bids, sells.RestingFills)} }