decred.org/dcrdex@v1.0.3/client/cmd/testbinance/main.go (about) 1 package main 2 3 /* 4 * Starts an http server that responds to some of the binance api's endpoints. 5 * The "runserver" command starts the server, and other commands are used to 6 * update the server's state. 7 */ 8 9 import ( 10 "context" 11 "encoding/hex" 12 "encoding/json" 13 "flag" 14 "fmt" 15 "io" 16 "math" 17 "math/rand" 18 "net/http" 19 "os" 20 "os/signal" 21 "strconv" 22 "strings" 23 "sync" 24 "sync/atomic" 25 "time" 26 27 "decred.org/dcrdex/client/mm/libxc/bntypes" 28 "decred.org/dcrdex/dex" 29 "decred.org/dcrdex/dex/encode" 30 "decred.org/dcrdex/dex/fiatrates" 31 "decred.org/dcrdex/dex/msgjson" 32 "decred.org/dcrdex/dex/utils" 33 "decred.org/dcrdex/dex/ws" 34 "decred.org/dcrdex/server/comms" 35 "github.com/go-chi/chi/v5" 36 ) 37 38 const ( 39 pongWait = 60 * time.Second 40 pingPeriod = (pongWait * 9) / 10 41 depositConfs = 3 42 43 // maxWalkingSpeed is that maximum amount the mid-gap can change per shuffle. 44 // Default about 3% of the basis price, but can be scaled by walkingspeed 45 // flag. The actual mid-gap shift during a shuffle is randomized in the 46 // range [0, defaultWalkingSpeed*walkingSpeedAdj]. 47 defaultWalkingSpeed = 0.03 48 ) 49 50 var ( 51 log dex.Logger 52 53 walkingSpeedAdj float64 54 gapRange float64 55 flappyWS bool 56 57 xcInfo = &bntypes.ExchangeInfo{ 58 Timezone: "UTC", 59 ServerTime: time.Now().Unix(), 60 RateLimits: []*bntypes.RateLimit{}, 61 Symbols: []*bntypes.Market{ 62 makeMarket("dcr", "btc"), 63 makeMarket("eth", "btc"), 64 makeMarket("dcr", "usdc"), 65 makeMarket("zec", "btc"), 66 }, 67 } 68 69 coinInfos = []*bntypes.CoinInfo{ 70 makeCoinInfo("BTC", "BTC", true, true, 0.00000610, 0.0007), 71 makeCoinInfo("ETH", "ETH", true, true, 0.00035, 0.008), 72 makeCoinInfo("DCR", "DCR", true, true, 0.00001000, 0.05), 73 makeCoinInfo("USDC", "MATIC", true, true, 0.01000, 10), 74 makeCoinInfo("ZEC", "ZEC", true, true, 0.00500000, 0.01000000), 75 } 76 77 coinpapAssets = []*fiatrates.CoinpaprikaAsset{ 78 makeCoinpapAsset(0, "btc", "Bitcoin"), 79 makeCoinpapAsset(42, "dcr", "Decred"), 80 makeCoinpapAsset(60, "eth", "Ethereum"), 81 makeCoinpapAsset(966001, "usdc.polygon", "USDC"), 82 makeCoinpapAsset(133, "zec", "Zcash"), 83 } 84 85 initialBalances = []*bntypes.Balance{ 86 makeBalance("btc", 1.5), 87 makeBalance("dcr", 10000), 88 makeBalance("eth", 5), 89 makeBalance("usdc", 1152), 90 makeBalance("zec", 10000), 91 } 92 ) 93 94 func parseAssetID(asset string) uint32 { 95 symbol := strings.ToLower(asset) 96 switch symbol { 97 case "usdc": 98 symbol = "usdc.polygon" 99 } 100 assetID, _ := dex.BipSymbolID(symbol) 101 return assetID 102 } 103 104 func makeMarket(baseSymbol, quoteSymbol string) *bntypes.Market { 105 baseSymbol, quoteSymbol = strings.ToUpper(baseSymbol), strings.ToUpper(quoteSymbol) 106 return &bntypes.Market{ 107 Symbol: baseSymbol + quoteSymbol, 108 Status: "TRADING", 109 BaseAsset: baseSymbol, 110 BaseAssetPrecision: 8, 111 QuoteAsset: quoteSymbol, 112 QuoteAssetPrecision: 8, 113 OrderTypes: []string{ 114 "LIMIT", 115 "LIMIT_MAKER", 116 "MARKET", 117 "STOP_LOSS", 118 "STOP_LOSS_LIMIT", 119 "TAKE_PROFIT", 120 "TAKE_PROFIT_LIMIT", 121 }, 122 } 123 } 124 125 func makeBalance(symbol string, bal float64) *bntypes.Balance { 126 return &bntypes.Balance{ 127 Asset: strings.ToUpper(symbol), 128 Free: bal, 129 } 130 } 131 132 func makeCoinInfo(coin, network string, withdrawsEnabled, depositsEnabled bool, withdrawFee, withdrawMin float64) *bntypes.CoinInfo { 133 return &bntypes.CoinInfo{ 134 Coin: coin, 135 NetworkList: []*bntypes.NetworkInfo{{ 136 Coin: coin, 137 Network: network, 138 WithdrawEnable: withdrawsEnabled, 139 DepositEnable: depositsEnabled, 140 WithdrawFee: withdrawFee, 141 WithdrawMin: withdrawMin, 142 }}, 143 } 144 } 145 146 func makeCoinpapAsset(assetID uint32, symbol, name string) *fiatrates.CoinpaprikaAsset { 147 return &fiatrates.CoinpaprikaAsset{ 148 AssetID: assetID, 149 Symbol: symbol, 150 Name: name, 151 } 152 } 153 154 // sendBalanceUpdateRequest sends a balance update request to the testbinance server 155 // running in another process. 156 func sendBalanceUpdateRequest(coin string, balanceUpdate float64) { 157 if coin == "" || balanceUpdate == 0 { 158 fmt.Printf("Invalid balance update request: coin = %q, balanceUpdate = %f\n", coin, balanceUpdate) 159 return 160 } 161 162 url := fmt.Sprintf("http://localhost:37346/testbinance/updatebalance?coin=%s&amt=%f", 163 coin, balanceUpdate) 164 resp, err := http.Get(url) 165 if err != nil { 166 log.Errorf("Error sending balance update request: %v", err) 167 return 168 } 169 170 defer resp.Body.Close() 171 if resp.StatusCode != http.StatusOK { 172 body, _ := io.ReadAll(resp.Body) 173 fmt.Println("Balance update request failed:", string(body)) 174 return 175 } 176 177 fmt.Println("Balance update request sent") 178 } 179 180 func main() { 181 var logDebug, logTrace bool 182 var coin string 183 var balanceUpdate float64 184 flag.Float64Var(&walkingSpeedAdj, "walkspeed", 1.0, "scale the maximum walking speed. default scale of 1.0 is about 3%") 185 flag.Float64Var(&gapRange, "gaprange", 0.04, "a ratio of how much the gap can vary. default is 0.04 => 4%") 186 flag.BoolVar(&logDebug, "debug", false, "use debug logging") 187 flag.BoolVar(&logTrace, "trace", false, "use trace logging") 188 flag.BoolVar(&flappyWS, "flappyws", false, "periodically drop websocket clients and delete subscriptions") 189 flag.Float64Var(&balanceUpdate, "balupdate", 0, "update the balance of an asset on a testbinance server running as another process") 190 flag.StringVar(&coin, "coin", "", "coin for testbinance admin update") 191 flag.Parse() 192 193 if balanceUpdate != 0 { 194 sendBalanceUpdateRequest(coin, balanceUpdate) 195 return 196 } 197 198 switch { 199 case logTrace: 200 log = dex.StdOutLogger("TB", dex.LevelTrace) 201 comms.UseLogger(dex.StdOutLogger("C", dex.LevelTrace)) 202 case logDebug: 203 log = dex.StdOutLogger("TB", dex.LevelDebug) 204 comms.UseLogger(dex.StdOutLogger("C", dex.LevelDebug)) 205 default: 206 log = dex.StdOutLogger("TB", dex.LevelInfo) 207 comms.UseLogger(dex.StdOutLogger("C", dex.LevelInfo)) 208 } 209 210 if err := mainErr(); err != nil { 211 fmt.Fprint(os.Stderr, err) 212 os.Exit(1) 213 } 214 os.Exit(0) 215 } 216 217 func mainErr() error { 218 if walkingSpeedAdj > 10 { 219 return fmt.Errorf("invalid walkspeed must be in < 10") 220 } 221 222 ctx, cancel := context.WithCancel(context.Background()) 223 defer cancel() 224 225 killChan := make(chan os.Signal, 1) 226 signal.Notify(killChan, os.Interrupt) 227 go func() { 228 <-killChan 229 log.Info("Shutting down...") 230 cancel() 231 }() 232 233 bnc, err := newFakeBinanceServer(ctx) 234 if err != nil { 235 return err 236 } 237 238 bnc.run(ctx) 239 240 return nil 241 } 242 243 type withdrawal struct { 244 amt float64 245 txID atomic.Value // string 246 coin string 247 network string 248 address string 249 apiKey string 250 } 251 252 type marketSubscriber struct { 253 *ws.WSLink 254 255 // markets is protected by the fakeBinance.marketsMtx. 256 markets map[string]struct{} 257 } 258 259 type userOrder struct { 260 slug string 261 sell bool 262 rate float64 263 qty float64 264 apiKey string 265 stamp time.Time 266 status string 267 } 268 269 type fakeBinance struct { 270 ctx context.Context 271 srv *comms.Server 272 fiatRates map[uint32]float64 273 274 withdrawalHistoryMtx sync.RWMutex 275 withdrawalHistory map[string]*withdrawal 276 277 balancesMtx sync.RWMutex 278 balances map[string]*bntypes.Balance 279 280 accountSubscribersMtx sync.RWMutex 281 accountSubscribers map[string]*ws.WSLink 282 283 marketsMtx sync.RWMutex 284 markets map[string]*market 285 marketSubscribers map[string]*marketSubscriber 286 287 walletMtx sync.RWMutex 288 wallets map[string]Wallet 289 290 bookedOrdersMtx sync.RWMutex 291 bookedOrders map[string]*userOrder 292 } 293 294 func newFakeBinanceServer(ctx context.Context) (*fakeBinance, error) { 295 log.Trace("Fetching coinpaprika prices") 296 fiatRates := fiatrates.FetchCoinpaprikaRates(ctx, coinpapAssets, dex.StdOutLogger("CP", dex.LevelDebug)) 297 if len(fiatRates) < len(coinpapAssets) { 298 return nil, fmt.Errorf("not enough coinpap assets. wanted %d, got %d", len(coinpapAssets), len(fiatRates)) 299 } 300 301 srv, err := comms.NewServer(&comms.RPCConfig{ 302 ListenAddrs: []string{":37346"}, 303 NoTLS: true, 304 }) 305 if err != nil { 306 return nil, fmt.Errorf("Error creating server: %w", err) 307 } 308 309 balances := make(map[string]*bntypes.Balance, len(initialBalances)) 310 for _, bal := range initialBalances { 311 balances[bal.Asset] = bal 312 } 313 314 f := &fakeBinance{ 315 ctx: ctx, 316 srv: srv, 317 withdrawalHistory: make(map[string]*withdrawal, 0), 318 balances: balances, 319 accountSubscribers: make(map[string]*ws.WSLink), 320 wallets: make(map[string]Wallet), 321 fiatRates: fiatRates, 322 markets: make(map[string]*market), 323 marketSubscribers: make(map[string]*marketSubscriber), 324 bookedOrders: make(map[string]*userOrder), 325 } 326 327 mux := srv.Mux() 328 329 mux.Route("/sapi/v1/capital", func(r chi.Router) { 330 r.Get("/config/getall", f.handleWalletCoinsReq) 331 r.Get("/deposit/hisrec", f.handleConfirmDeposit) 332 r.Get("/deposit/address", f.handleGetDepositAddress) 333 r.Post("/withdraw/apply", f.handleWithdrawal) 334 r.Get("/withdraw/history", f.handleWithdrawalHistory) 335 336 }) 337 mux.Route("/api/v3", func(r chi.Router) { 338 r.Get("/exchangeInfo", f.handleExchangeInfo) 339 r.Get("/account", f.handleAccount) 340 r.Get("/depth", f.handleDepth) 341 r.Get("/order", f.handleGetOrder) 342 r.Post("/order", f.handlePostOrder) 343 r.Post("/userDataStream", f.handleListenKeyRequest) 344 r.Put("/userDataStream", f.streamExtend) 345 r.Delete("/order", f.handleDeleteOrder) 346 r.Get("/ticker/24hr", f.handleMarketTicker24) 347 }) 348 349 mux.Get("/ws/{listenKey}", f.handleAccountSubscription) 350 mux.Get("/stream", f.handleMarketStream) 351 mux.Route("/testbinance", func(r chi.Router) { 352 r.Get("/updatebalance", f.handleUpdateBalance) 353 }) 354 355 return f, nil 356 } 357 358 func (f *fakeBinance) handleUpdateBalance(w http.ResponseWriter, r *http.Request) { 359 coin := r.URL.Query().Get("coin") 360 amtStr := r.URL.Query().Get("amt") 361 amt, err := strconv.ParseFloat(amtStr, 64) 362 if err != nil { 363 http.Error(w, fmt.Sprintf("invalid amt %q: %v", amtStr, err), http.StatusBadRequest) 364 return 365 } 366 367 balUpdate := f.updateBalance(coin, amt) 368 if balUpdate == nil { 369 http.Error(w, fmt.Sprintf("no balance to update for %q", coin), http.StatusBadRequest) 370 return 371 } 372 373 f.sendBalanceUpdates([]*bntypes.WSBalance{balUpdate}) 374 w.WriteHeader(http.StatusOK) 375 } 376 377 func (f *fakeBinance) run(ctx context.Context) { 378 // Start a ticker to do book shuffles. 379 380 go func() { 381 runMarketTick := func() { 382 f.marketsMtx.RLock() 383 defer f.marketsMtx.RUnlock() 384 updates := make(map[string]json.RawMessage) 385 for mktID, mkt := range f.markets { 386 mkt.bookMtx.Lock() 387 buys, sells := mkt.shuffle() 388 firstUpdateID := mkt.updateID + 1 389 mkt.updateID += uint64(len(buys) + len(sells)) 390 update, _ := json.Marshal(&bntypes.BookNote{ 391 StreamName: mktID + "@depth", 392 Data: &bntypes.BookUpdate{ 393 Bids: buys, 394 Asks: sells, 395 FirstUpdateID: firstUpdateID, 396 LastUpdateID: mkt.updateID, 397 }, 398 }) 399 updates[mktID] = update 400 mkt.bookMtx.Unlock() 401 } 402 403 if len(f.marketSubscribers) > 0 { 404 log.Tracef("Sending %d market updates to %d subscribers", len(updates), len(f.marketSubscribers)) 405 } 406 for _, sub := range f.marketSubscribers { 407 for symbol := range updates { 408 if _, found := sub.markets[symbol]; found { 409 sub.SendRaw(updates[symbol]) 410 } 411 } 412 } 413 } 414 const marketMinTick, marketTickRange = time.Second * 5, time.Second * 25 415 for { 416 delay := marketMinTick + time.Duration(rand.Float64()*float64(marketTickRange)) 417 select { 418 case <-time.After(delay): 419 case <-ctx.Done(): 420 return 421 } 422 runMarketTick() 423 } 424 }() 425 426 // Start a ticker to fill booked orders 427 go func() { 428 // 50% chance of filling all booked orders every 5 to 30 seconds. 429 const minFillTick, fillTickRange = 5 * time.Second, 25 * time.Second 430 for { 431 select { 432 case <-time.After(minFillTick + time.Duration(rand.Float64()*float64(fillTickRange))): 433 case <-ctx.Done(): 434 return 435 } 436 if rand.Float32() < 0.5 { 437 continue 438 } 439 type filledOrder struct { 440 bntypes.StreamUpdate 441 apiKey string 442 } 443 444 f.bookedOrdersMtx.Lock() 445 fills := make([]*filledOrder, 0) 446 for tradeID, ord := range f.bookedOrders { 447 if ord.status == "FILLED" { 448 if time.Since(ord.stamp) > time.Hour { 449 delete(f.bookedOrders, tradeID) 450 } 451 continue 452 } 453 ord.status = "FILLED" 454 fills = append(fills, &filledOrder{ 455 StreamUpdate: bntypes.StreamUpdate{ 456 EventType: "executionReport", 457 CurrentOrderStatus: "FILLED", 458 // CancelledOrderID 459 ClientOrderID: tradeID, 460 Filled: ord.qty, 461 QuoteFilled: ord.qty * ord.rate, 462 }, 463 apiKey: ord.apiKey, 464 }) 465 } 466 f.bookedOrdersMtx.Unlock() 467 if len(fills) > 0 { 468 log.Tracef("Filling %d booked user orders", len(fills)) 469 } 470 for _, ord := range fills { 471 f.accountSubscribersMtx.RLock() 472 sub, found := f.accountSubscribers[ord.apiKey] 473 f.accountSubscribersMtx.RUnlock() 474 if !found { 475 continue 476 } 477 respB, _ := json.Marshal(ord) 478 sub.SendRaw(respB) 479 } 480 } 481 }() 482 483 // Start a ticker to complete withdrawals. 484 go func() { 485 for { 486 tick := time.After(time.Second * 30) 487 select { 488 case <-tick: 489 case <-ctx.Done(): 490 return 491 } 492 493 f.withdrawalHistoryMtx.Lock() 494 for transferID, withdraw := range f.withdrawalHistory { 495 if withdraw.txID.Load() != nil { 496 continue 497 } 498 wallet, err := f.getWallet(withdraw.network) 499 if err != nil { 500 log.Errorf("No wallet for withdraw coin %s", withdraw.coin) 501 delete(f.withdrawalHistory, transferID) 502 continue 503 } 504 txID, err := wallet.Send(ctx, withdraw.address, withdraw.coin, withdraw.amt) 505 if err != nil { 506 log.Errorf("Error sending %s: %v", withdraw.coin, err) 507 delete(f.withdrawalHistory, transferID) 508 continue 509 } 510 log.Debug("Sent withdraw of %.8f to user %s, coin = %s, txid = %s", withdraw.amt, withdraw.apiKey, withdraw.coin, txID) 511 withdraw.txID.Store(txID) 512 } 513 f.withdrawalHistoryMtx.Unlock() 514 } 515 }() 516 517 if flappyWS { 518 go func() { 519 tick := func() <-chan time.Time { 520 const minDelay = time.Minute 521 const delayRange = time.Minute * 5 522 return time.After(minDelay + time.Duration(rand.Float64()*float64(delayRange))) 523 } 524 for { 525 select { 526 case <-tick(): 527 f.marketsMtx.Lock() 528 for addr, sub := range f.marketSubscribers { 529 sub.Disconnect() 530 delete(f.marketSubscribers, addr) 531 } 532 f.marketsMtx.Unlock() 533 f.accountSubscribersMtx.Lock() 534 for apiKey, sub := range f.accountSubscribers { 535 sub.Disconnect() 536 delete(f.accountSubscribers, apiKey) 537 } 538 f.accountSubscribersMtx.Unlock() 539 case <-ctx.Done(): 540 return 541 } 542 } 543 }() 544 } 545 546 f.srv.Run(ctx) 547 } 548 549 func (f *fakeBinance) newWSLink(w http.ResponseWriter, r *http.Request, handler func([]byte)) (_ *ws.WSLink, _ *dex.ConnectionMaster) { 550 wsConn, err := ws.NewConnection(w, r, pongWait) 551 if err != nil { 552 log.Errorf("ws.NewConnection error: %v", err) 553 http.Error(w, "error initializing connection", http.StatusInternalServerError) 554 return 555 } 556 557 ip := dex.NewIPKey(r.RemoteAddr) 558 559 conn := ws.NewWSLink(ip.String(), wsConn, pingPeriod, func(msg *msgjson.Message) *msgjson.Error { 560 return nil 561 }, dex.StdOutLogger(fmt.Sprintf("CL[%s]", ip), dex.LevelDebug)) 562 conn.RawHandler = handler 563 564 cm := dex.NewConnectionMaster(conn) 565 if err = cm.ConnectOnce(f.ctx); err != nil { 566 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 567 return 568 } 569 570 return conn, cm 571 } 572 573 func (f *fakeBinance) handleAccountSubscription(w http.ResponseWriter, r *http.Request) { 574 apiKey := extractAPIKey(r) 575 576 conn, cm := f.newWSLink(w, r, func(b []byte) { 577 log.Errorf("Message received from api key %s over account update channel: %s", apiKey, string(b)) 578 }) 579 if conn == nil { // Already logged. 580 return 581 } 582 583 log.Tracef("User subscribed to account stream with API key %s", apiKey) 584 585 f.accountSubscribersMtx.Lock() 586 f.accountSubscribers[apiKey] = conn 587 f.accountSubscribersMtx.Unlock() 588 589 go func() { 590 cm.Wait() 591 f.accountSubscribersMtx.Lock() 592 delete(f.accountSubscribers, apiKey) 593 f.accountSubscribersMtx.Unlock() 594 log.Tracef("Account stream connection ended for API key %s", apiKey) 595 }() 596 } 597 598 type listSubsResp struct { 599 ID uint64 `json:"id"` 600 Result []string `json:"result"` 601 } 602 603 func (f *fakeBinance) handleMarketStream(w http.ResponseWriter, r *http.Request) { 604 streamsStr := r.URL.Query().Get("streams") 605 if streamsStr == "" { 606 log.Error("Client connected to market stream without providing a 'streams' query parameter") 607 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 608 return 609 } 610 rawStreams := strings.Split(streamsStr, "/") 611 marketIDs := make(map[string]struct{}, len(rawStreams)) 612 for _, raw := range rawStreams { 613 parts := strings.Split(raw, "@") 614 if len(parts) < 2 { 615 http.Error(w, fmt.Sprintf("stream encoding incorrect %q", raw), http.StatusBadRequest) 616 return 617 } 618 marketIDs[strings.ToUpper(parts[0])] = struct{}{} 619 } 620 621 cl := &marketSubscriber{ 622 markets: marketIDs, 623 } 624 625 subscribe := func(streamIDs []string) { 626 f.marketsMtx.Lock() 627 defer f.marketsMtx.Unlock() 628 for _, streamID := range streamIDs { 629 parts := strings.Split(streamID, "@") 630 if len(parts) < 2 { 631 log.Errorf("SUBSCRIBE stream encoding incorrect: %q", streamID) 632 return 633 } 634 cl.markets[strings.ToUpper(parts[0])] = struct{}{} 635 } 636 } 637 638 unsubscribe := func(streamIDs []string) { 639 f.marketsMtx.Lock() 640 defer f.marketsMtx.Unlock() 641 for _, streamID := range streamIDs { 642 parts := strings.Split(streamID, "@") 643 if len(parts) < 2 { 644 log.Errorf("UNSUBSCRIBE stream encoding incorrect: %q", streamID) 645 return 646 } 647 delete(cl.markets, strings.ToUpper(parts[0])) 648 } 649 f.cleanMarkets() 650 } 651 652 listSubscriptions := func(id uint64) { 653 f.marketsMtx.Lock() 654 defer f.marketsMtx.Unlock() 655 var streams []string 656 for mktID := range cl.markets { 657 streams = append(streams, fmt.Sprintf("%s@depth", mktID)) 658 } 659 resp := listSubsResp{ 660 ID: id, 661 Result: streams, 662 } 663 b, err := json.Marshal(resp) 664 if err != nil { 665 log.Errorf("LIST_SUBSCRIBE marshal error: %v", err) 666 } 667 cl.WSLink.SendRaw(b) 668 f.cleanMarkets() 669 } 670 671 conn, cm := f.newWSLink(w, r, func(b []byte) { 672 var req bntypes.StreamSubscription 673 if err := json.Unmarshal(b, &req); err != nil { 674 log.Errorf("Error unmarshalling markets stream message: %v", err) 675 return 676 } 677 switch req.Method { 678 case "SUBSCRIBE": 679 subscribe(req.Params) 680 case "UNSUBSCRIBE": 681 unsubscribe(req.Params) 682 case "LIST_SUBSCRIPTIONS": 683 listSubscriptions(req.ID) 684 } 685 }) 686 if conn == nil { 687 return 688 } 689 690 cl.WSLink = conn 691 692 addr := conn.Addr() 693 log.Tracef("Websocket client %s connected to market stream for markets %+v", addr, marketIDs) 694 695 f.marketsMtx.Lock() 696 f.marketSubscribers[addr] = cl 697 f.marketsMtx.Unlock() 698 699 go func() { 700 cm.Wait() 701 log.Tracef("Market stream client %s disconnected", addr) 702 f.marketsMtx.Lock() 703 delete(f.marketSubscribers, addr) 704 f.cleanMarkets() 705 f.marketsMtx.Unlock() 706 }() 707 } 708 709 // Call with f.marketsMtx locked 710 func (f *fakeBinance) cleanMarkets() { 711 marketSubCount := make(map[string]int) 712 for _, cl := range f.marketSubscribers { 713 for mktID := range cl.markets { 714 marketSubCount[mktID]++ 715 } 716 } 717 for mktID := range f.markets { 718 if marketSubCount[mktID] == 0 { 719 delete(f.markets, mktID) 720 } 721 } 722 } 723 724 func (f *fakeBinance) handleWalletCoinsReq(w http.ResponseWriter, r *http.Request) { 725 respB, _ := json.Marshal(coinInfos) 726 writeBytesWithStatus(w, respB, http.StatusOK) 727 } 728 729 func (f *fakeBinance) sendBalanceUpdates(bals []*bntypes.WSBalance) { 730 update := &bntypes.StreamUpdate{ 731 EventType: "outboundAccountPosition", 732 Balances: bals, 733 } 734 updateB, _ := json.Marshal(update) 735 f.accountSubscribersMtx.Lock() 736 if len(f.accountSubscribers) > 0 { 737 log.Tracef("Sending balance updates to %d subscribers", len(f.accountSubscribers)) 738 } 739 for _, sub := range f.accountSubscribers { 740 sub.SendRaw(updateB) 741 } 742 f.accountSubscribersMtx.Unlock() 743 } 744 745 func (f *fakeBinance) handleConfirmDeposit(w http.ResponseWriter, r *http.Request) { 746 q := r.URL.Query() 747 txID := q.Get("txid") 748 amtStr := q.Get("amt") 749 amt, err := strconv.ParseFloat(amtStr, 64) 750 if err != nil { 751 log.Errorf("Error parsing deposit amount string %q: %v", amtStr, err) 752 http.Error(w, "error parsing amount", http.StatusBadRequest) 753 return 754 } 755 coin := q.Get("coin") 756 network := q.Get("network") 757 wallet, err := f.getWallet(network) 758 if err != nil { 759 log.Errorf("Error creating deposit wallet for %s: %v", coin, err) 760 http.Error(w, "error creating wallet", http.StatusBadRequest) 761 return 762 } 763 confs, err := wallet.Confirmations(f.ctx, txID) 764 if err != nil { 765 log.Errorf("Error getting deposit confirmations for %s -> %s: %v", coin, txID, err) 766 http.Error(w, "error getting confirmations", http.StatusInternalServerError) 767 return 768 } 769 apiKey := extractAPIKey(r) 770 status := bntypes.DepositStatusPending 771 if confs >= depositConfs { 772 status = bntypes.DepositStatusCredited 773 log.Debugf("Confirmed deposit for %s of %.8f %s", apiKey, amt, coin) 774 f.balancesMtx.Lock() 775 var bal *bntypes.WSBalance 776 for _, b := range f.balances { 777 if b.Asset == coin { 778 bal = (*bntypes.WSBalance)(b) 779 b.Free += amt 780 break 781 } 782 } 783 f.balancesMtx.Unlock() 784 if bal != nil { 785 f.sendBalanceUpdates([]*bntypes.WSBalance{bal}) 786 } 787 } else { 788 log.Tracef("Updating user %s on deposit status for %.8f %s. Confs = %d", apiKey, amt, coin, confs) 789 } 790 resp := []*bntypes.PendingDeposit{{ 791 Amount: amt, 792 Status: status, 793 TxID: txID, 794 Coin: coin, 795 Network: network, 796 }} 797 writeJSONWithStatus(w, resp, http.StatusOK) 798 } 799 800 func (f *fakeBinance) getWallet(network string) (Wallet, error) { 801 symbol := strings.ToLower(network) 802 f.walletMtx.Lock() 803 defer f.walletMtx.Unlock() 804 wallet, exists := f.wallets[symbol] 805 if exists { 806 return wallet, nil 807 } 808 wallet, err := newWallet(f.ctx, symbol) 809 if err != nil { 810 return nil, err 811 } 812 f.wallets[symbol] = wallet 813 return wallet, nil 814 } 815 816 func (f *fakeBinance) handleGetDepositAddress(w http.ResponseWriter, r *http.Request) { 817 coin := r.URL.Query().Get("coin") 818 network := r.URL.Query().Get("network") 819 820 wallet, err := f.getWallet(network) 821 if err != nil { 822 log.Errorf("Error creating wallet for %s: %v", coin, err) 823 http.Error(w, "error creating wallet", http.StatusBadRequest) 824 return 825 } 826 827 resp := struct { 828 Address string `json:"address"` 829 }{ 830 Address: wallet.DepositAddress(), 831 } 832 833 log.Tracef("User %s requested deposit address %s", extractAPIKey(r), resp.Address) 834 835 writeJSONWithStatus(w, resp, http.StatusOK) 836 } 837 838 func (f *fakeBinance) updateBalance(coin string, amt float64) *bntypes.WSBalance { 839 f.balancesMtx.Lock() 840 defer f.balancesMtx.Unlock() 841 842 var balUpdate *bntypes.WSBalance 843 844 for _, b := range f.balances { 845 if b.Asset == coin { 846 if amt+b.Free < 0 { 847 b.Free = 0 848 } else { 849 b.Free += amt 850 } 851 balUpdate = (*bntypes.WSBalance)(b) 852 break 853 } 854 } 855 856 return balUpdate 857 } 858 859 func (f *fakeBinance) handleWithdrawal(w http.ResponseWriter, r *http.Request) { 860 defer r.Body.Close() 861 apiKey := extractAPIKey(r) 862 err := r.ParseForm() 863 if err != nil { 864 log.Errorf("Error parsing form for user %s: ", apiKey, err) 865 http.Error(w, "Error parsing form", http.StatusBadRequest) 866 return 867 } 868 869 amountStr := r.Form.Get("amount") 870 amt, err := strconv.ParseFloat(amountStr, 64) 871 if err != nil { 872 log.Errorf("Error parsing amount for user %s: ", apiKey, err) 873 http.Error(w, "Error parsing amount", http.StatusBadRequest) 874 return 875 } 876 877 coin := r.Form.Get("coin") 878 network := r.Form.Get("network") 879 address := r.Form.Get("address") 880 881 withdrawalID := hex.EncodeToString(encode.RandomBytes(32)) 882 log.Debugf("Withdraw of %.8f %s initiated for user %s", amt, coin, apiKey) 883 884 f.withdrawalHistoryMtx.Lock() 885 f.withdrawalHistory[withdrawalID] = &withdrawal{ 886 amt: amt * 0.99, 887 coin: coin, 888 network: network, 889 address: address, 890 apiKey: apiKey, 891 } 892 f.withdrawalHistoryMtx.Unlock() 893 894 balUpdate := f.updateBalance(coin, -amt) 895 f.sendBalanceUpdates([]*bntypes.WSBalance{balUpdate}) 896 897 resp := struct { 898 ID string `json:"id"` 899 }{withdrawalID} 900 writeJSONWithStatus(w, resp, http.StatusOK) 901 } 902 903 type withdrawalHistoryStatus struct { 904 ID string `json:"id"` 905 Amount float64 `json:"amount,string"` 906 Status int `json:"status"` 907 TxID string `json:"txId"` 908 } 909 910 func (f *fakeBinance) handleWithdrawalHistory(w http.ResponseWriter, r *http.Request) { 911 defer r.Body.Close() 912 913 const withdrawalCompleteStatus = 6 914 withdrawalHistory := make([]*withdrawalHistoryStatus, 0) 915 916 f.withdrawalHistoryMtx.RLock() 917 for transferID, w := range f.withdrawalHistory { 918 var status int 919 txIDPtr := w.txID.Load() 920 var txID string 921 if txIDPtr == nil { 922 status = 2 923 } else { 924 txID = txIDPtr.(string) 925 status = withdrawalCompleteStatus 926 } 927 withdrawalHistory = append(withdrawalHistory, &withdrawalHistoryStatus{ 928 ID: transferID, 929 Amount: w.amt, 930 Status: status, 931 TxID: txID, 932 }) 933 } 934 f.withdrawalHistoryMtx.RUnlock() 935 936 log.Tracef("Sending %d withdraws to user %s", len(withdrawalHistory), extractAPIKey(r)) 937 writeJSONWithStatus(w, withdrawalHistory, http.StatusOK) 938 } 939 940 func (f *fakeBinance) handleExchangeInfo(w http.ResponseWriter, r *http.Request) { 941 writeJSONWithStatus(w, xcInfo, http.StatusOK) 942 } 943 944 func (f *fakeBinance) handleAccount(w http.ResponseWriter, r *http.Request) { 945 f.balancesMtx.RLock() 946 defer f.balancesMtx.RUnlock() 947 writeJSONWithStatus(w, &bntypes.Account{Balances: utils.MapItems(f.balances)}, http.StatusOK) 948 } 949 950 func (f *fakeBinance) handleDepth(w http.ResponseWriter, r *http.Request) { 951 slug := r.URL.Query().Get("symbol") 952 var mkt *bntypes.Market 953 for _, m := range xcInfo.Symbols { 954 if m.Symbol == slug { 955 mkt = m 956 break 957 } 958 } 959 if mkt == nil { 960 log.Errorf("No market definition found for market %q", slug) 961 http.Error(w, "no market "+slug, http.StatusBadRequest) 962 return 963 } 964 f.marketsMtx.Lock() 965 m, found := f.markets[slug] 966 if !found { 967 baseFiatRate := f.fiatRates[parseAssetID(mkt.BaseAsset)] 968 quoteFiatRate := f.fiatRates[parseAssetID(mkt.QuoteAsset)] 969 m = newMarket(slug, mkt.BaseAsset, mkt.QuoteAsset, baseFiatRate, quoteFiatRate) 970 f.markets[slug] = m 971 } 972 f.marketsMtx.Unlock() 973 974 var resp bntypes.OrderbookSnapshot 975 m.bookMtx.RLock() 976 for _, ord := range m.buys { 977 resp.Bids = append(resp.Bids, [2]json.Number{json.Number(floatString(ord.rate)), json.Number(floatString(ord.qty))}) 978 } 979 for _, ord := range m.sells { 980 resp.Asks = append(resp.Asks, [2]json.Number{json.Number(floatString(ord.rate)), json.Number(floatString(ord.qty))}) 981 } 982 resp.LastUpdateID = m.updateID 983 m.bookMtx.RUnlock() 984 writeJSONWithStatus(w, &resp, http.StatusOK) 985 } 986 987 func (f *fakeBinance) handleGetOrder(w http.ResponseWriter, r *http.Request) { 988 tradeID := r.URL.Query().Get("origClientOrderId") 989 var status string 990 f.bookedOrdersMtx.RLock() 991 ord, found := f.bookedOrders[tradeID] 992 if found { 993 status = ord.status 994 } 995 f.bookedOrdersMtx.RUnlock() 996 if !found { 997 log.Errorf("User %s requested unknown order %s", extractAPIKey(r), tradeID) 998 http.Error(w, "order not found", http.StatusBadRequest) 999 return 1000 } 1001 resp := &bntypes.BookedOrder{ 1002 Symbol: ord.slug, 1003 // OrderID: , 1004 ClientOrderID: tradeID, 1005 Price: ord.rate, 1006 OrigQty: ord.qty, 1007 ExecutedQty: 0, 1008 CumulativeQuoteQty: 0, 1009 Status: status, 1010 TimeInForce: "GTC", 1011 } 1012 writeJSONWithStatus(w, &resp, http.StatusOK) 1013 } 1014 1015 func (f *fakeBinance) updateOrderBalances(symbol string, sell bool, qty, rate float64) { 1016 f.marketsMtx.RLock() 1017 mkt := f.markets[symbol] 1018 f.marketsMtx.RUnlock() 1019 fromSlug, toSlug, fromQty, toQty := mkt.quoteSlug, mkt.baseSlug, qty*rate, qty 1020 if sell { 1021 fromSlug, toSlug, fromQty, toQty = toSlug, fromSlug, toQty, fromQty 1022 } 1023 f.balancesMtx.Lock() 1024 f.balances[toSlug].Free += toQty 1025 f.balances[fromSlug].Free -= fromQty 1026 f.balancesMtx.Unlock() 1027 } 1028 1029 func (f *fakeBinance) handlePostOrder(w http.ResponseWriter, r *http.Request) { 1030 apiKey := extractAPIKey(r) 1031 q := r.URL.Query() 1032 slug := q.Get("symbol") 1033 side := q.Get("side") 1034 tradeID := q.Get("newClientOrderId") 1035 qty, err := strconv.ParseFloat(q.Get("quantity"), 64) 1036 if err != nil { 1037 log.Errorf("Error parsing quantity %q for order from user %s: %v", q.Get("quantity"), apiKey, err) 1038 http.Error(w, "Bad quantity formatting", http.StatusBadRequest) 1039 return 1040 } 1041 price, err := strconv.ParseFloat(q.Get("price"), 64) 1042 if err != nil { 1043 log.Errorf("Error parsing price %q for order from user %s: %v", q.Get("price"), apiKey, err) 1044 http.Error(w, "Missing price formatting", http.StatusBadRequest) 1045 return 1046 } 1047 1048 resp := &bntypes.OrderResponse{ 1049 Symbol: slug, 1050 Price: price, 1051 OrigQty: qty, 1052 OrigQuoteQty: qty * price, 1053 } 1054 1055 bookIt := rand.Float32() < 0.2 1056 if bookIt { 1057 resp.Status = "NEW" 1058 log.Tracef("Booking %s order on %s for %.8f for user %s", side, slug, qty, apiKey) 1059 1060 } else { 1061 log.Tracef("Filled %s order on %s for %.8f for user %s", side, slug, qty, apiKey) 1062 resp.Status = "FILLED" 1063 resp.ExecutedQty = qty 1064 resp.CumulativeQuoteQty = qty * price 1065 } 1066 1067 f.bookedOrdersMtx.Lock() 1068 f.bookedOrders[tradeID] = &userOrder{ 1069 slug: slug, 1070 sell: side == "SELL", 1071 rate: price, 1072 qty: qty, 1073 apiKey: apiKey, 1074 stamp: time.Now(), 1075 status: resp.Status, 1076 } 1077 f.bookedOrdersMtx.Unlock() 1078 1079 writeJSONWithStatus(w, &resp, http.StatusOK) 1080 } 1081 1082 func (f *fakeBinance) streamExtend(w http.ResponseWriter, r *http.Request) { 1083 w.WriteHeader(http.StatusOK) 1084 } 1085 1086 func (f *fakeBinance) handleListenKeyRequest(w http.ResponseWriter, r *http.Request) { 1087 resp := &bntypes.DataStreamKey{ 1088 ListenKey: extractAPIKey(r), 1089 } 1090 writeJSONWithStatus(w, resp, http.StatusOK) 1091 } 1092 1093 func (f *fakeBinance) handleDeleteOrder(w http.ResponseWriter, r *http.Request) { 1094 tradeID := r.URL.Query().Get("origClientOrderId") 1095 apiKey := extractAPIKey(r) 1096 f.bookedOrdersMtx.Lock() 1097 ord, found := f.bookedOrders[tradeID] 1098 if found { 1099 if ord.status == "CANCELED" { 1100 log.Errorf("Detected cancellation of an already cancelled order %s", tradeID) 1101 } 1102 ord.status = "CANCELED" 1103 } 1104 f.bookedOrdersMtx.Unlock() 1105 writeJSONWithStatus(w, &struct{}{}, http.StatusOK) 1106 if !found { 1107 log.Errorf("DELETE request received from user %s for unknown order %s", apiKey, tradeID) 1108 return 1109 } 1110 1111 log.Tracef("Deleting order %s on %s for user %s", tradeID, ord.slug, apiKey) 1112 f.accountSubscribersMtx.RLock() 1113 sub, found := f.accountSubscribers[ord.apiKey] 1114 f.accountSubscribersMtx.RUnlock() 1115 if !found { 1116 return 1117 } 1118 update := &bntypes.StreamUpdate{ 1119 EventType: "executionReport", 1120 CurrentOrderStatus: "CANCELED", 1121 ClientOrderID: hex.EncodeToString(encode.RandomBytes(20)), 1122 CancelledOrderID: tradeID, 1123 Filled: 0, 1124 QuoteFilled: 0, 1125 } 1126 updateB, _ := json.Marshal(update) 1127 sub.SendRaw(updateB) 1128 } 1129 1130 func (f *fakeBinance) handleMarketTicker24(w http.ResponseWriter, r *http.Request) { 1131 resp := make([]*bntypes.MarketTicker24, 0, len(xcInfo.Symbols)) 1132 for _, mkt := range xcInfo.Symbols { 1133 baseFiatRate := f.fiatRates[parseAssetID(mkt.BaseAsset)] 1134 quoteFiatRate := f.fiatRates[parseAssetID(mkt.QuoteAsset)] 1135 m := newMarket(mkt.Symbol, mkt.BaseAsset, mkt.QuoteAsset, baseFiatRate, quoteFiatRate) 1136 var buyPrice, sellPrice float64 1137 if len(m.buys) > 0 { 1138 buyPrice = m.buys[0].rate 1139 } 1140 if len(m.sells) > 0 { 1141 sellPrice = m.sells[0].rate 1142 } 1143 vol24USD := math.Pow(10, float64(rand.Intn(4)+2)) 1144 vol24Base := vol24USD / baseFiatRate 1145 vol24Quote := vol24USD / quoteFiatRate 1146 lastPrice := m.basisRate 1147 highPrice := lastPrice * (1 + rand.Float64()*0.15) 1148 lowPrice := lastPrice / (1 + rand.Float64()*0.15) 1149 openPrice := lowPrice + ((highPrice - lowPrice) * rand.Float64()) 1150 priceChange := lastPrice - openPrice 1151 priceChangePct := priceChange / openPrice * 100 1152 1153 avgPrice := (openPrice + lastPrice + highPrice + lowPrice) / 4 1154 1155 resp = append(resp, &bntypes.MarketTicker24{ 1156 Symbol: mkt.Symbol, 1157 PriceChange: priceChange, 1158 PriceChangePercent: priceChangePct, 1159 BidPrice: buyPrice, 1160 AskPrice: sellPrice, 1161 Volume: vol24Base, 1162 QuoteVolume: vol24Quote, 1163 WeightedAvgPrice: avgPrice, 1164 LastPrice: lastPrice, 1165 OpenPrice: openPrice, 1166 HighPrice: highPrice, 1167 LowPrice: lowPrice, 1168 }) 1169 } 1170 writeJSONWithStatus(w, &resp, http.StatusOK) 1171 } 1172 1173 type rateQty struct { 1174 rate float64 1175 qty float64 1176 } 1177 1178 type market struct { 1179 symbol, baseSlug, quoteSlug string 1180 baseFiatRate, quoteFiatRate, basisRate float64 1181 minRate, maxRate float64 1182 1183 rate atomic.Uint64 1184 1185 bookMtx sync.RWMutex 1186 updateID uint64 1187 buys, sells []*rateQty 1188 } 1189 1190 func newMarket(symbol, baseSlug, quoteSlug string, baseFiatRate, quoteFiatRate float64) *market { 1191 const maxVariation = 0.1 1192 basisRate := baseFiatRate / quoteFiatRate 1193 minRate, maxRate := basisRate*(1/(1+maxVariation)), basisRate*(1+maxVariation) 1194 m := &market{ 1195 symbol: symbol, 1196 baseSlug: baseSlug, 1197 quoteSlug: quoteSlug, 1198 baseFiatRate: baseFiatRate, 1199 quoteFiatRate: quoteFiatRate, 1200 basisRate: basisRate, 1201 minRate: minRate, 1202 maxRate: maxRate, 1203 buys: make([]*rateQty, 0), 1204 sells: make([]*rateQty, 0), 1205 } 1206 m.rate.Store(math.Float64bits(basisRate)) 1207 log.Tracef("Market %s intitialized with base fiat rate = %.4f, quote fiat rate = %.4f "+ 1208 "basis rate = %.8f. Mid-gap rate will randomly walk between %.8f and %.8f", 1209 symbol, baseFiatRate, quoteFiatRate, basisRate, minRate, maxRate) 1210 m.shuffle() 1211 return m 1212 } 1213 1214 // Randomize the order book. booksMtx must be locked. 1215 func (m *market) shuffle() (buys, sells [][2]json.Number) { 1216 maxChangeRatio := defaultWalkingSpeed * walkingSpeedAdj 1217 maxShift := m.basisRate * maxChangeRatio 1218 oldRate := math.Float64frombits(m.rate.Load()) 1219 if rand.Float64() < 0.5 { 1220 maxShift *= -1 1221 } 1222 shiftRoll := rand.Float64() 1223 shift := maxShift * shiftRoll 1224 newRate := oldRate + shift 1225 1226 if newRate < m.minRate { 1227 newRate = m.minRate 1228 } 1229 if newRate > m.maxRate { 1230 newRate = m.maxRate 1231 } 1232 1233 m.rate.Store(math.Float64bits(newRate)) 1234 log.Tracef("%s: A randomized (max %.1f%%) shift of %.8f (%.3f%%) was applied to the old rate of %.8f, "+ 1235 "resulting in a new mid-gap of %.8f", 1236 m.symbol, maxChangeRatio*100, shift, shiftRoll*maxChangeRatio*100, oldRate, newRate, 1237 ) 1238 1239 halfGapRoll := rand.Float64() 1240 const minHalfGap = 0.002 // 0.2% 1241 halfGapRange := gapRange / 2 1242 halfGapFactor := minHalfGap + halfGapRoll*halfGapRange 1243 bestBuy, bestSell := newRate/(1+halfGapFactor), newRate*(1+halfGapFactor) 1244 1245 levelSpacingRoll := rand.Float64() 1246 const minLevelSpacing, levelSpacingRange = 0.002, 0.01 1247 levelSpacing := (minLevelSpacing + levelSpacingRoll*levelSpacingRange) * newRate 1248 1249 log.Tracef("%s: Half-gap roll of %.4f%% resulted in a half-gap factor of %.4f%%, range %.8f to %0.8f. "+ 1250 "Level-spacing roll of %.4f%% resulted in a level spacing of %.8f", 1251 m.symbol, halfGapRoll*100, halfGapFactor*100, bestBuy, bestSell, levelSpacingRoll*100, levelSpacing, 1252 ) 1253 1254 zeroBookSide := func(ords []*rateQty) map[string]string { 1255 bin := make(map[string]string, len(ords)) 1256 for _, ord := range ords { 1257 bin[floatString(ord.rate)] = "0" 1258 } 1259 return bin 1260 } 1261 jsBuys, jsSells := zeroBookSide(m.buys), zeroBookSide(m.sells) 1262 1263 makeOrders := func(bestRate, direction float64, jsSide map[string]string) []*rateQty { 1264 nLevels := rand.Intn(20) + 5 1265 ords := make([]*rateQty, nLevels) 1266 for i := 0; i < nLevels; i++ { 1267 rate := bestRate + levelSpacing*direction*float64(i) 1268 // Each level has between 1 and 10,001 USD equivalent. 1269 const minQtyUSD, qtyUSDRange = 1, 10_000 1270 qtyUSD := minQtyUSD + qtyUSDRange*rand.Float64() 1271 qty := qtyUSD / m.baseFiatRate 1272 jsSide[floatString(rate)] = floatString(qty) 1273 ords[i] = &rateQty{ 1274 rate: rate, 1275 qty: qty, 1276 } 1277 } 1278 return ords 1279 } 1280 m.buys = makeOrders(bestBuy, -1, jsBuys) 1281 m.sells = makeOrders(bestSell, 1, jsSells) 1282 1283 log.Tracef("%s: Shuffle resulted in %d buy orders and %d sell orders being placed", m.symbol, len(m.buys), len(m.sells)) 1284 1285 convertSide := func(side map[string]string) [][2]json.Number { 1286 updates := make([][2]json.Number, 0, len(side)) 1287 for r, q := range side { 1288 updates = append(updates, [2]json.Number{json.Number(r), json.Number(q)}) 1289 } 1290 return updates 1291 } 1292 1293 return convertSide(jsBuys), convertSide(jsSells) 1294 } 1295 1296 // writeJSON marshals the provided interface and writes the bytes to the 1297 // ResponseWriter with the specified response code. 1298 func writeJSONWithStatus(w http.ResponseWriter, thing interface{}, code int) { 1299 w.Header().Set("Content-Type", "application/json; charset=utf-8") 1300 b, err := json.Marshal(thing) 1301 if err != nil { 1302 w.WriteHeader(http.StatusInternalServerError) 1303 log.Errorf("JSON encode error: %v", err) 1304 return 1305 } 1306 writeBytesWithStatus(w, b, code) 1307 } 1308 1309 func writeBytesWithStatus(w http.ResponseWriter, b []byte, code int) { 1310 w.WriteHeader(code) 1311 _, err := w.Write(append(b, byte('\n'))) 1312 if err != nil { 1313 log.Errorf("Write error: %v", err) 1314 } 1315 } 1316 1317 func extractAPIKey(r *http.Request) string { 1318 return r.Header.Get("X-MBX-APIKEY") 1319 } 1320 1321 func floatString(v float64) string { 1322 return strconv.FormatFloat(v, 'f', 8, 64) 1323 }