From e87b19f270d67586ea9711e33e4e4d1c00b50968 Mon Sep 17 00:00:00 2001 From: buck54321 Date: Tue, 30 Apr 2024 09:48:25 -0500 Subject: [PATCH] mm: implement full cex api for simnet binance (#2737) * implement full binance api in fakebinance * use binary search to determine orderable size. --- client/cmd/testbinance/harness_test.go | 275 +++ client/cmd/testbinance/main.go | 1840 ++++++++++--------- client/cmd/testbinance/wallets.go | 185 ++ client/core/core.go | 22 +- client/mm/event_log.go | 7 +- client/mm/exchange_adaptor.go | 117 +- client/mm/exchange_adaptor_test.go | 1 + client/mm/libxc/binance.go | 427 +++-- client/mm/libxc/binance_live_test.go | 34 +- client/mm/libxc/binance_types.go | 94 - client/mm/libxc/bntypes/types.go | 163 ++ client/mm/libxc/interface.go | 9 +- client/mm/mm.go | 12 +- client/mm/mm_arb_market_maker.go | 31 +- client/mm/mm_arb_market_maker_test.go | 6 +- client/mm/mm_test.go | 15 +- client/webserver/site/src/js/app.ts | 2 +- client/webserver/site/src/js/dexsettings.ts | 6 +- client/webserver/site/src/js/forms.ts | 23 +- client/webserver/site/src/js/mmsettings.ts | 26 +- client/webserver/site/src/js/register.ts | 3 +- dex/networks/eth/tokens.go | 13 +- dex/testing/walletpair/walletpair.sh | 6 +- server/comms/server.go | 2 - 24 files changed, 1988 insertions(+), 1331 deletions(-) create mode 100644 client/cmd/testbinance/harness_test.go create mode 100644 client/cmd/testbinance/wallets.go delete mode 100644 client/mm/libxc/binance_types.go create mode 100644 client/mm/libxc/bntypes/types.go diff --git a/client/cmd/testbinance/harness_test.go b/client/cmd/testbinance/harness_test.go new file mode 100644 index 0000000000..3d13f77ec1 --- /dev/null +++ b/client/cmd/testbinance/harness_test.go @@ -0,0 +1,275 @@ +//go:build harness + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "decred.org/dcrdex/client/comms" + "decred.org/dcrdex/client/mm/libxc/bntypes" + "decred.org/dcrdex/dex" +) + +const testAPIKey = "Test Client 3000" + +func TestMain(m *testing.M) { + log = dex.StdOutLogger("T", dex.LevelTrace) + m.Run() +} + +func TestUtxoWallet(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + w, err := newUtxoWallet(ctx, "btc") + if err != nil { + t.Fatalf("newUtxoWallet error: %v", err) + } + testWallet(t, ctx, w) +} + +func TestEvmWallet(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + w, err := newEvmWallet(ctx, "eth") + if err != nil { + t.Fatalf("newUtxoWallet error: %v", err) + } + testWallet(t, ctx, w) +} + +func testWallet(t *testing.T, ctx context.Context, w Wallet) { + addr := w.DepositAddress() + fmt.Println("##### Deposit address:", addr) + txID, err := w.Send(ctx, addr, 0.1) + if err != nil { + t.Fatalf("Send error: %v", err) + } + fmt.Println("##### Self-send tx ID:", txID) + for i := 0; i < 3; i++ { + confs, err := w.Confirmations(ctx, txID) + if err != nil { + fmt.Println("##### Confirmations error:", err) + if i < 2 { + fmt.Println("##### Trying again in 15 seconds") + time.Sleep(time.Second * 15) + } + } else { + fmt.Println("##### Confirmations:", confs) + return + } + } + t.Fatal("Failed to get confirmations") +} + +func getInto(method, endpoint string, thing interface{}) error { + req, err := http.NewRequest(method, "http://localhost:37346"+endpoint, nil) + if err != nil { + return err + } + return requestInto(req, thing) +} + +func requestInto(req *http.Request, thing interface{}) error { + req.Header.Add("X-MBX-APIKEY", testAPIKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil || thing == nil { + return err + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return json.Unmarshal(b, thing) +} + +func printThing(name string, thing interface{}) { + b, _ := json.MarshalIndent(thing, "", " ") + fmt.Println("#####", name, ":", string(b)) +} + +func newWebsocketClient(ctx context.Context, uri string, handler func([]byte)) (comms.WsConn, *dex.ConnectionMaster, error) { + wsClient, err := comms.NewWsConn(&comms.WsCfg{ + URL: uri, + PingWait: pongWait, + Logger: dex.StdOutLogger("W", log.Level()), + RawHandler: handler, + ConnectHeaders: http.Header{"X-MBX-APIKEY": []string{testAPIKey}}, + }) + if err != nil { + return nil, nil, fmt.Errorf("Error creating websocket client: %v", err) + } + wsCM := dex.NewConnectionMaster(wsClient) + if err = wsCM.ConnectOnce(ctx); err != nil { + return nil, nil, fmt.Errorf("Error connecting websocket client: %v", err) + } + return wsClient, wsCM, nil +} + +func TestMarketFeed(t *testing.T) { + // httpURL := "http://localhost:37346" + wsURL := "ws://localhost:37346" + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var xcInfo bntypes.ExchangeInfo + if err := getInto("GET", "/api/v3/exchangeInfo", &xcInfo); err != nil { + t.Fatalf("Error getting exchange info: %v", err) + } + printThing("ExchangeInfo", xcInfo) + + depthStreamID := func(mkt *bntypes.Market) string { + return fmt.Sprintf("%s@depth", strings.ToLower(mkt.Symbol)) + } + + mkt0, mkt1 := xcInfo.Symbols[0], xcInfo.Symbols[1] + + streamHandler := func(b []byte) { + fmt.Printf("\n##### Market stream -> %s \n\n", string(b)) + } + + uri := fmt.Sprintf(wsURL+"/stream?streams=%s", depthStreamID(mkt0)) + wsClient, wsCM, err := newWebsocketClient(ctx, uri, streamHandler) + if err != nil { + t.Fatal(err) + } + defer wsCM.Disconnect() + + var book bntypes.OrderbookSnapshot + if err := getInto("GET", fmt.Sprintf("/api/v3/depth?symbol=%s", mkt0.Symbol), &book); err != nil { + t.Fatalf("Error getting depth for symbol %s: %v", mkt0.Symbol, err) + } + + printThing("First order book", &book) + + addSubB, _ := json.Marshal(&bntypes.StreamSubscription{ + Method: "SUBSCRIBE", + Params: []string{depthStreamID(mkt1)}, + ID: 1, + }) + + fmt.Println("##### Sending second market subscription in 30 seconds") + + select { + case <-time.After(time.Second * 30): + if err := wsClient.SendRaw(addSubB); err != nil { + t.Fatalf("error sending subscription stream request: %v", err) + } + fmt.Println("##### Sent second market subscription") + var book bntypes.OrderbookSnapshot + if err := getInto("GET", fmt.Sprintf("/api/v3/depth?symbol=%s", mkt1.Symbol), &book); err != nil { + t.Fatalf("Error getting depth for symbol %s: %v", mkt1.Symbol, err) + } + printThing("Second order book", &book) + case <-ctx.Done(): + return + } + + unubB, _ := json.Marshal(&bntypes.StreamSubscription{ + Method: "UNSUBSCRIBE", + Params: []string{depthStreamID(mkt0), depthStreamID(mkt1)}, + ID: 2, + }) + + fmt.Println("##### Monitoring stream for 5 minutes") + select { + case <-time.After(time.Minute * 5): + if err := wsClient.SendRaw(unubB); err != nil { + t.Fatalf("error sending unsubscribe request: %v", err) + } + fmt.Println("##### All done") + case <-ctx.Done(): + return + } +} + +func TestAccountFeed(t *testing.T) { + // httpURL := "http://localhost:37346" + wsURL := "ws://localhost:37346" + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var addrResp struct { + Address string `json:"address"` + } + if err := getInto("GET", "/sapi/v1/capital/deposit/address?coin=BTC", &addrResp); err != nil { + t.Fatalf("Error getting deposit address: %v", err) + } + + printThing("Deposit address", addrResp) + + w, err := newUtxoWallet(ctx, "btc") + if err != nil { + t.Fatalf("Error constructing btc wallet: %v", err) + } + txID, err := w.Send(ctx, addrResp.Address, 0.1) + if err != nil { + t.Fatalf("Send error: %v", err) + } + + streamHandler := func(b []byte) { + fmt.Printf("\n##### Account stream -> %s \n\n", string(b)) + } + + _, wsCM, err := newWebsocketClient(ctx, wsURL+"/ws/testListenKey", streamHandler) + if err != nil { + t.Fatal(err) + } + defer wsCM.Disconnect() + + endpoint := "/sapi/v1/capital/deposit/hisrec?amt=0.1&coin=BTC&network=BTC&txid=" + txID + for { + var resp []*bntypes.PendingDeposit + if err := getInto("GET", endpoint, &resp); err != nil { + t.Fatalf("Error getting deposit confirmations: %v", err) + } + printThing("Deposit Status", resp) + if len(resp) > 0 && resp[0].Status == bntypes.DepositStatusCredited { + fmt.Println("##### Deposit confirmed") + break + } + fmt.Println("##### Checking unconfirmed deposit again in 15 seconds") + select { + case <-time.After(time.Second * 15): + case <-ctx.Done(): + return + } + } + + withdrawAddr := w.DepositAddress() + form := make(url.Values) + form.Add("coin", "BTC") + form.Add("network", "BTC") + form.Add("address", withdrawAddr) + form.Add("amount", "0.1") + bodyString := form.Encode() + req, err := http.NewRequest(http.MethodPost, "http://localhost:37346/sapi/v1/capital/withdraw/apply", bytes.NewBufferString(bodyString)) + if err != nil { + t.Fatalf("Error constructing withdraw request: %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + var withdrawResp struct { + ID string `json:"id"` + } + if err := requestInto(req, &withdrawResp); err != nil { + t.Fatalf("Error requesting withdraw: %v", err) + } + + printThing("Withdraw response", &withdrawResp) + + var withdraws []*withdrawalHistoryStatus + if err := getInto("GET", "/sapi/v1/capital/withdraw/history?coin=BTC", &withdraws); err != nil { + t.Fatalf("Error fetching withdraw history: %v", err) + } + printThing("Withdraw history", withdraws) + +} diff --git a/client/cmd/testbinance/main.go b/client/cmd/testbinance/main.go index 0ee2114d5b..2499b1b5ae 100644 --- a/client/cmd/testbinance/main.go +++ b/client/cmd/testbinance/main.go @@ -7,935 +7,709 @@ package main */ import ( + "context" "encoding/hex" "encoding/json" + "flag" "fmt" + "math" + "math/rand" "net/http" "os" + "os/signal" "strconv" + "strings" "sync" + "sync/atomic" + "time" - "decred.org/dcrdex/client/websocket" + "decred.org/dcrdex/client/mm/libxc/bntypes" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/dex/fiatrates" + "decred.org/dcrdex/dex/msgjson" + "decred.org/dcrdex/dex/ws" + "decred.org/dcrdex/server/comms" + "github.com/go-chi/chi/v5" ) -var ( - log = dex.StdOutLogger("TBNC", dex.LevelDebug) +const ( + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + depositConfs = 3 + + // maxWalkingSpeed is that maximum amount the mid-gap can change per shuffle. + // Default about 3% of the basis price, but can be scaled by walkingspeed + // flag. The actual mid-gap shift during a shuffle is randomized in the + // range [0, defaultWalkingSpeed*walkingSpeedAdj]. + defaultWalkingSpeed = 0.03 ) -func printUsage() { - fmt.Println("Commands:") - fmt.Println(" runserver") - fmt.Println(" complete-withdrawal ") - fmt.Println(" complete-deposit ") -} +var ( + log dex.Logger -func main() { - if len(os.Args) < 2 { - printUsage() - os.Exit(1) + walkingSpeedAdj float64 + + xcInfo = &bntypes.ExchangeInfo{ + Timezone: "UTC", + ServerTime: time.Now().Unix(), + RateLimits: []*bntypes.RateLimit{}, + Symbols: []*bntypes.Market{ + makeMarket("dcr", "btc"), + makeMarket("eth", "btc"), + }, } - cmd := os.Args[1] + coinInfos = []*bntypes.CoinInfo{ + makeCoinInfo("BTC", "BTC", true, true, 0.00000610), + makeCoinInfo("ETH", "ETH", true, true, 0.00035), + makeCoinInfo("DCR", "DCR", true, true, 0.00001000), + } - switch cmd { - case "runserver": - if err := runServer(); err != nil { - fmt.Println(err) - os.Exit(1) - } - os.Exit(0) - case "complete-withdrawal": - if len(os.Args) < 4 { - printUsage() - os.Exit(1) - } - id := os.Args[2] - txid := os.Args[3] - if err := completeWithdrawal(id, txid); err != nil { - fmt.Println(err) - os.Exit(1) - } - case "complete-deposit": - if len(os.Args) < 6 { - printUsage() - os.Exit(1) - } - txid := os.Args[2] - amtStr := os.Args[3] - amt, err := strconv.ParseFloat(amtStr, 64) - if err != nil { - fmt.Println("Error parsing amount: ", err) - os.Exit(1) - } - coin := os.Args[4] - network := os.Args[5] - if err := completeDeposit(txid, amt, coin, network); err != nil { - fmt.Println(err) - os.Exit(1) - } - default: - fmt.Printf("Unknown command: %s\n", cmd) - printUsage() + coinpapAssets = []*fiatrates.CoinpaprikaAsset{ + makeCoinpapAsset(0, "btc", "Bitcoin"), + makeCoinpapAsset(42, "dcr", "Decred"), + makeCoinpapAsset(60, "eth", "Ethereum"), } -} -func runServer() error { - f := &fakeBinance{ - wsServer: websocket.New(nil, log.SubLogger("WS")), - withdrawalHistory: make([]*transfer, 0), - depositHistory: make([]*transfer, 0), + initialBalances = []*bntypes.Balance{ + makeBalance("btc", 0.1), + makeBalance("dcr", 100), + makeBalance("eth", 5), } +) + +func parseAssetID(s string) uint32 { + s = strings.ToLower(s) + assetID, _ := dex.BipSymbolID(s) + return assetID +} - // Fake binance handlers - http.HandleFunc("/sapi/v1/capital/config/getall", f.handleWalletCoinsReq) - http.HandleFunc("/sapi/v1/capital/deposit/hisrec", f.handleConfirmDeposit) - http.HandleFunc("/sapi/v1/capital/deposit/address", f.handleGetDepositAddress) - http.HandleFunc("/sapi/v1/capital/withdraw/apply", f.handleWithdrawal) - http.HandleFunc("/sapi/v1/capital/withdraw/history", f.handleWithdrawalHistory) +func makeMarket(baseSymbol, quoteSymbol string) *bntypes.Market { + baseSymbol, quoteSymbol = strings.ToUpper(baseSymbol), strings.ToUpper(quoteSymbol) + return &bntypes.Market{ + Symbol: baseSymbol + quoteSymbol, + Status: "TRADING", + BaseAsset: baseSymbol, + BaseAssetPrecision: 8, + QuoteAsset: quoteSymbol, + QuoteAssetPrecision: 8, + OrderTypes: []string{ + "LIMIT", + "LIMIT_MAKER", + "MARKET", + "STOP_LOSS", + "STOP_LOSS_LIMIT", + "TAKE_PROFIT", + "TAKE_PROFIT_LIMIT", + }, + } +} - // Handlers for updating fake binance state - http.HandleFunc("/completewithdrawal", f.handleCompleteWithdrawal) - http.HandleFunc("/completedeposit", f.handleCompleteDeposit) +func makeBalance(symbol string, bal float64) *bntypes.Balance { + return &bntypes.Balance{ + Asset: strings.ToUpper(symbol), + Free: bal, + } +} - return http.ListenAndServe(":37346", nil) +func makeCoinInfo(coin, network string, withdrawsEnabled, depositsEnabled bool, withdrawFee float64) *bntypes.CoinInfo { + return &bntypes.CoinInfo{ + Coin: coin, + NetworkList: []*bntypes.NetworkInfo{{ + Coin: coin, + Network: network, + WithdrawEnable: withdrawsEnabled, + DepositEnable: depositsEnabled, + WithdrawFee: withdrawFee, + }}, + } } -// completeWithdrawal sends a request to the fake binance server to update a -// withdrawal's status to complete. -func completeWithdrawal(id, txid string) error { - // Send complete withdrawal request - req, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:37346/completewithdrawal?id=%s&txid=%s", id, txid), nil) - if err != nil { - return err +func makeCoinpapAsset(assetID uint32, symbol, name string) *fiatrates.CoinpaprikaAsset { + return &fiatrates.CoinpaprikaAsset{ + AssetID: assetID, + Symbol: symbol, + Name: name, } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("error sending request: %v", err) +} + +func main() { + var logDebug, logTrace bool + flag.Float64Var(&walkingSpeedAdj, "walkspeed", 1.0, "scale the maximum walking speed. default of 1.0 is about 3%") + flag.BoolVar(&logDebug, "debug", false, "use debug logging") + flag.BoolVar(&logTrace, "trace", false, "use trace logging") + flag.Parse() + + switch { + case logTrace: + log = dex.StdOutLogger("TB", dex.LevelTrace) + comms.UseLogger(dex.StdOutLogger("C", dex.LevelTrace)) + case logDebug: + log = dex.StdOutLogger("TB", dex.LevelDebug) + comms.UseLogger(dex.StdOutLogger("C", dex.LevelDebug)) + default: + log = dex.StdOutLogger("TB", dex.LevelInfo) + comms.UseLogger(dex.StdOutLogger("C", dex.LevelInfo)) } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("request failed with status: %d", resp.StatusCode) + + if err := mainErr(); err != nil { + fmt.Fprint(os.Stderr, err) + os.Exit(1) } - return nil + os.Exit(0) } -// completeDeposit sends a request to the fake binance server to update a -// deposit's status to complete. -func completeDeposit(txid string, amt float64, coin, network string) error { - // Send complete deposit request - req, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:37346/completedeposit?txid=%s&amt=%f&coin=%s&network=%s", txid, amt, coin, network), nil) - if err != nil { - return err +func mainErr() error { + if walkingSpeedAdj < 0.001 || walkingSpeedAdj > 10 { + return fmt.Errorf("invalid walkspeed must be in [0.001, 10]") } - resp, err := http.DefaultClient.Do(req) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + killChan := make(chan os.Signal, 1) + signal.Notify(killChan, os.Interrupt) + go func() { + <-killChan + log.Info("Shutting down...") + cancel() + }() + + bnc, err := newFakeBinanceServer(ctx) if err != nil { - return fmt.Errorf("error sending request: %v", err) - } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("request failed with status: %d", resp.StatusCode) + return err } - return nil -} -type balance struct { - free float64 - locked float64 + bnc.run(ctx) + + return nil } -type transfer struct { - id string +type withdrawal struct { amt float64 - txID string + txID atomic.Value // string coin string network string + address string + apiKey string +} + +type marketSubscriber struct { + *ws.WSLink + + // markets is protected by the fakeBinance.marketsMtx. + markets map[string]struct{} +} + +type userOrder struct { + slug string + sell bool + rate float64 + qty float64 + apiKey string + cancelled atomic.Bool } type fakeBinance struct { - wsServer *websocket.Server + ctx context.Context + srv *comms.Server + fiatRates map[uint32]float64 withdrawalHistoryMtx sync.RWMutex - withdrawalHistory []*transfer + withdrawalHistory map[string]*withdrawal + + balancesMtx sync.RWMutex + balances []*bntypes.Balance + + accountSubscribersMtx sync.RWMutex + accountSubscribers map[string]*ws.WSLink - depositHistoryMtx sync.RWMutex - depositHistory []*transfer + marketsMtx sync.RWMutex + markets map[string]*market + marketSubscribers map[string]*marketSubscriber + + walletMtx sync.RWMutex + wallets map[string]Wallet + + bookedOrdersMtx sync.RWMutex + bookedOrders map[string]*userOrder } -func (f *fakeBinance) handleWalletCoinsReq(w http.ResponseWriter, r *http.Request) { - // Returning configs for eth, btc, ltc, bch, zec, usdc, matic. - // Balances do not use a sapi endpoint, so they do not need to be handled - // here. - resp := []byte(`[ - { - "coin": "MATIC", - "depositAllEnable": true, - "free": "0", - "freeze": "0", - "ipoable": "0", - "ipoing": "0", - "isLegalMoney": false, - "locked": "0", - "name": "Polygon", - "storage": "0", - "trading": true, - "withdrawAllEnable": true, - "withdrawing": "0", - "networkList": [ - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "MATIC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 15, - "name": "BNB Smart Chain (BEP20)", - "network": "BSC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 0, - "withdrawEnable": true, - "withdrawFee": "0.8", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "9999999", - "withdrawMin": "20", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "MATIC", - "depositEnable": true, - "isDefault": true, - "memoRegex": "", - "minConfirm": 6, - "name": "Ethereum (ERC20)", - "network": "ETH", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 64, - "withdrawEnable": true, - "withdrawFee": "15.8", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "31.6", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "MATIC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 300, - "name": "Polygon", - "network": "MATIC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 800, - "withdrawEnable": true, - "withdrawFee": "0.1", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "9999999", - "withdrawMin": "20", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - } - ] - }, - { - "coin": "ETH", - "depositAllEnable": true, - "free": "0", - "freeze": "0", - "ipoable": "0", - "ipoing": "0", - "isLegalMoney": false, - "locked": "0", - "name": "Ethereum", - "storage": "0", - "trading": true, - "withdrawAllEnable": true, - "withdrawing": "0", - "networkList": [ - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "ETH", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 100, - "name": "Arbitrum One", - "network": "ARBITRUM", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 0, - "withdrawEnable": true, - "withdrawFee": "0.00035", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "9999999", - "withdrawMin": "0.0008", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(bnb1)[0-9a-z]{38}$", - "coin": "ETH", - "depositEnable": true, - "isDefault": false, - "memoRegex": "^[0-9A-Za-z\\-_]{1,120}$", - "minConfirm": 1, - "name": "BNB Beacon Chain (BEP2)", - "network": "BNB", - "resetAddressStatus": false, - "specialTips": "Both a MEMO and an Address are required to successfully deposit your BEP2 tokens to Binance US.", - "unLockConfirm": 0, - "withdrawEnable": false, - "withdrawFee": "0.000086", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.0005", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "ETH", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 15, - "name": "BNB Smart Chain (BEP20)", - "network": "BSC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 0, - "withdrawEnable": true, - "withdrawFee": "0.000057", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.00011", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "ETH", - "depositEnable": true, - "isDefault": true, - "memoRegex": "", - "minConfirm": 6, - "name": "Ethereum (ERC20)", - "network": "ETH", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 64, - "withdrawEnable": true, - "withdrawFee": "0.00221", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.00442", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "ETH", - "depositEnable": true, - "isDefault": true, - "memoRegex": "", - "minConfirm": 6, - "name": "Polygon (ERC20)", - "network": "MATIC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 64, - "withdrawEnable": true, - "withdrawFee": "0.00221", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.00442", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "ETH", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 100, - "name": "Optimism", - "network": "OPTIMISM", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 120, - "withdrawEnable": true, - "withdrawFee": "0.00035", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.001", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false +func newFakeBinanceServer(ctx context.Context) (*fakeBinance, error) { + log.Trace("Fetching coinpaprika prices") + fiatRates := fiatrates.FetchCoinpaprikaRates(ctx, coinpapAssets, dex.StdOutLogger("CP", dex.LevelDebug)) + if len(fiatRates) < len(coinpapAssets) { + return nil, fmt.Errorf("not enough coinpap assets. wanted %d, got %d", len(coinpapAssets), len(fiatRates)) + } + + srv, err := comms.NewServer(&comms.RPCConfig{ + ListenAddrs: []string{":37346"}, + NoTLS: true, + }) + if err != nil { + return nil, fmt.Errorf("Error creating server: %w", err) + } + + f := &fakeBinance{ + ctx: ctx, + srv: srv, + withdrawalHistory: make(map[string]*withdrawal, 0), + balances: initialBalances, + accountSubscribers: make(map[string]*ws.WSLink), + wallets: make(map[string]Wallet), + fiatRates: fiatRates, + markets: make(map[string]*market), + marketSubscribers: make(map[string]*marketSubscriber), + bookedOrders: make(map[string]*userOrder), + } + + mux := srv.Mux() + + mux.Route("/sapi/v1/capital", func(r chi.Router) { + r.Get("/config/getall", f.handleWalletCoinsReq) + r.Get("/deposit/hisrec", f.handleConfirmDeposit) + r.Get("/deposit/address", f.handleGetDepositAddress) + r.Post("/withdraw/apply", f.handleWithdrawal) + r.Get("/withdraw/history", f.handleWithdrawalHistory) + + }) + mux.Route("/api/v3", func(r chi.Router) { + r.Get("/exchangeInfo", f.handleExchangeInfo) + r.Get("/account", f.handleAccount) + r.Get("/depth", f.handleDepth) + r.Get("/order", f.handleGetOrder) + r.Post("/order", f.handlePostOrder) + r.Post("/userDataStream", f.handleListenKeyRequest) + r.Put("/userDataStream", f.streamExtend) + r.Delete("/order", f.handleDeleteOrder) + }) + + mux.Get("/ws/{listenKey}", f.handleAccountSubscription) + mux.Get("/stream", f.handleMarketStream) + + return f, nil +} + +func (f *fakeBinance) run(ctx context.Context) { + // Start a ticker to do book shuffles. + + go func() { + runMarketTick := func() { + f.marketsMtx.RLock() + defer f.marketsMtx.RUnlock() + updates := make(map[string]json.RawMessage) + for mktID, mkt := range f.markets { + mkt.bookMtx.Lock() + buys, sells := mkt.shuffle() + firstUpdateID := mkt.updateID + 1 + mkt.updateID += uint64(len(buys) + len(sells)) + update, _ := json.Marshal(&bntypes.BookNote{ + StreamName: mktID + "@depth", + Data: &bntypes.BookUpdate{ + Bids: buys, + Asks: sells, + FirstUpdateID: firstUpdateID, + LastUpdateID: mkt.updateID, + }, + }) + updates[mktID] = update + mkt.bookMtx.Unlock() + } + + if len(f.marketSubscribers) > 0 { + log.Tracef("Sending %d market updates to %d subscribers", len(updates), len(f.marketSubscribers)) + } + for _, sub := range f.marketSubscribers { + for symbol := range updates { + if _, found := sub.markets[symbol]; found { + sub.SendRaw(updates[symbol]) + } } - ] - }, - { - "coin": "ZEC", - "depositAllEnable": true, - "free": "0", - "freeze": "0", - "ipoable": "0", - "ipoing": "0", - "isLegalMoney": false, - "locked": "0", - "name": "Zcash", - "storage": "0", - "trading": true, - "withdrawAllEnable": true, - "withdrawing": "0", - "networkList": [ - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "ZEC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 15, - "name": "BNB Smart Chain (BEP20)", - "network": "BSC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 0, - "withdrawEnable": false, - "withdrawFee": "0.0041", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.0082", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(t)[A-Za-z0-9]{34}$", - "coin": "ZEC", - "depositEnable": true, - "isDefault": true, - "memoRegex": "", - "minConfirm": 15, - "name": "Zcash", - "network": "ZEC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 20, - "withdrawEnable": true, - "withdrawFee": "0.005", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.01", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false + } + } + const marketMinTick, marketTickRange = time.Second * 5, time.Second * 25 + for { + delay := marketMinTick + time.Duration(rand.Float64()*float64(marketTickRange)) + select { + case <-time.After(delay): + case <-ctx.Done(): + return + } + runMarketTick() + } + }() + + // Start a ticker to fill booked orders + go func() { + // 50% chance of filling all booked orders every 5 to 30 seconds. + const minFillTick, fillTickRange = 5 * time.Second, 25 * time.Second + for { + select { + case <-time.After(minFillTick + time.Duration(rand.Float64()*float64(fillTickRange))): + case <-ctx.Done(): + return + } + if rand.Float32() < 0.5 { + continue + } + f.bookedOrdersMtx.Lock() + ords := f.bookedOrders + f.bookedOrders = make(map[string]*userOrder) + f.bookedOrdersMtx.Unlock() + if len(ords) > 0 { + log.Tracef("Filling %d booked user orders", len(ords)) + } + for tradeID, ord := range ords { + f.accountSubscribersMtx.RLock() + sub, found := f.accountSubscribers[ord.apiKey] + f.accountSubscribersMtx.RUnlock() + if !found { + continue } - ] - }, - { - "coin": "BTC", - "depositAllEnable": true, - "free": "0", - "freeze": "0", - "ipoable": "0", - "ipoing": "0", - "isLegalMoney": false, - "locked": "0", - "name": "Bitcoin", - "storage": "0", - "trading": true, - "withdrawAllEnable": true, - "withdrawing": "0", - "networkList": [ - { - "addressRegex": "^(bnb1)[0-9a-z]{38}$", - "coin": "BTC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "^[0-9A-Za-z\\-_]{1,120}$", - "minConfirm": 1, - "name": "BNB Beacon Chain (BEP2)", - "network": "BNB", - "resetAddressStatus": false, - "specialTips": "Both a MEMO and an Address are required to successfully deposit your BEP2-BTCB tokens to Binance.", - "unLockConfirm": 0, - "withdrawEnable": false, - "withdrawFee": "0.0000061", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.000012", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "BTC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 15, - "name": "BNB Smart Chain (BEP20)", - "network": "BSC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 0, - "withdrawEnable": true, - "withdrawFee": "0.000003", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.000006", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^[(bc1q)|(bc1p)][0-9A-Za-z]{37,62}$", - "coin": "BTC", - "depositEnable": true, - "isDefault": true, - "memoRegex": "", - "minConfirm": 1, - "name": "Bitcoin", - "network": "BTC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 2, - "withdrawEnable": true, - "withdrawFee": "0.00025", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.0005", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false + update := &bntypes.StreamUpdate{ + EventType: "executionReport", + CurrentOrderStatus: "FILLED", + // CancelledOrderID + ClientOrderID: tradeID, + Filled: ord.qty, + QuoteFilled: ord.qty * ord.rate, } - ] - }, - { - "coin": "LTC", - "depositAllEnable": true, - "free": "0", - "freeze": "0", - "ipoable": "0", - "ipoing": "0", - "isLegalMoney": false, - "locked": "0", - "name": "Litecoin", - "storage": "0", - "trading": true, - "withdrawAllEnable": true, - "withdrawing": "0", - "networkList": [ - { - "addressRegex": "^(bnb1)[0-9a-z]{38}$", - "coin": "LTC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "^[0-9A-Za-z\\-_]{1,120}$", - "minConfirm": 1, - "name": "BNB Beacon Chain (BEP2)", - "network": "BNB", - "resetAddressStatus": false, - "specialTips": "Both a MEMO and an Address are required to successfully deposit your LTC BEP2 tokens to Binance.", - "unLockConfirm": 0, - "withdrawEnable": false, - "withdrawFee": "0.0035", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.007", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "LTC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 15, - "name": "BNB Smart Chain (BEP20)", - "network": "BSC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 0, - "withdrawEnable": true, - "withdrawFee": "0.0017", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.0034", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(L|M)[A-Za-z0-9]{33}$|^(ltc1)[0-9A-Za-z]{39}$", - "coin": "LTC", - "depositEnable": true, - "isDefault": true, - "memoRegex": "", - "minConfirm": 3, - "name": "Litecoin", - "network": "LTC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 4, - "withdrawEnable": true, - "withdrawFee": "0.00125", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.002", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false + respB, _ := json.Marshal(update) + sub.SendRaw(respB) + } + } + }() + + // Start a ticker to complete withdrawals. + go func() { + for { + tick := time.After(time.Second * 30) + select { + case <-tick: + case <-ctx.Done(): + return + } + debits := make(map[string]float64) + f.withdrawalHistoryMtx.Lock() + for transferID, withdraw := range f.withdrawalHistory { + if withdraw.txID.Load() != nil { + continue } - ] - }, - { - "coin": "BCH", - "depositAllEnable": true, - "free": "0", - "freeze": "0", - "ipoable": "0", - "ipoing": "0", - "isLegalMoney": false, - "locked": "0", - "name": "Bitcoin Cash", - "storage": "0", - "trading": true, - "withdrawAllEnable": true, - "withdrawing": "0", - "networkList": [ - { - "addressRegex": "^[1][a-km-zA-HJ-NP-Z1-9]{25,34}$|^[0-9a-z]{42,42}$", - "coin": "BCH", - "depositEnable": true, - "isDefault": true, - "memoRegex": "", - "minConfirm": 2, - "name": "Bitcoin Cash", - "network": "BCH", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 6, - "withdrawEnable": true, - "withdrawFee": "0.0008", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.002", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(bnb1)[0-9a-z]{38}$", - "coin": "BCH", - "depositEnable": false, - "isDefault": false, - "memoRegex": "^[0-9A-Za-z\\-_]{1,120}$", - "minConfirm": 1, - "name": "BNB Beacon Chain (BEP2)", - "network": "BNB", - "resetAddressStatus": false, - "specialTips": "Both a MEMO and an Address are required to successfully deposit your BCH BEP2 tokens to Binance.", - "unLockConfirm": 0, - "withdrawEnable": false, - "withdrawFee": "0.0011", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.0022", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "BCH", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 15, - "name": "BNB Smart Chain (BEP20)", - "network": "BSC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 0, - "withdrawEnable": true, - "withdrawFee": "0.00054", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.0011", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false + wallet, err := f.getWallet(withdraw.coin) + if err != nil { + log.Errorf("No wallet for withdraw coin %s", withdraw.coin) + delete(f.withdrawalHistory, transferID) + continue } - ] - }, - { - "coin": "USDC", - "depositAllEnable": true, - "free": "0", - "freeze": "0", - "ipoable": "0", - "ipoing": "0", - "isLegalMoney": false, - "locked": "0", - "name": "USD Coin", - "storage": "0", - "trading": true, - "withdrawAllEnable": true, - "withdrawing": "0", - "networkList": [ - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "USDC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 12, - "name": "AVAX C-Chain", - "network": "AVAXC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 0, - "withdrawEnable": true, - "withdrawFee": "1", - "withdrawIntegerMultiple": "0.000001", - "withdrawMax": "9999999", - "withdrawMin": "50", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "USDC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 15, - "name": "BNB Smart Chain (BEP20)", - "network": "BSC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 0, - "withdrawEnable": true, - "withdrawFee": "0.29", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "10", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "USDC", - "depositEnable": true, - "isDefault": true, - "memoRegex": "", - "minConfirm": 6, - "name": "Ethereum (ERC20)", - "network": "ETH", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 64, - "withdrawEnable": true, - "withdrawFee": "15.16", - "withdrawIntegerMultiple": "0.000001", - "withdrawMax": "10000000000", - "withdrawMin": "30.32", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "USDC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 300, - "name": "Polygon", - "network": "MATIC", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 800, - "withdrawEnable": true, - "withdrawFee": "1", - "withdrawIntegerMultiple": "0.000001", - "withdrawMax": "9999999", - "withdrawMin": "10", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^[1-9A-HJ-NP-Za-km-z]{32,44}$", - "coin": "USDC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 1, - "name": "Solana", - "network": "SOL", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 0, - "withdrawEnable": true, - "withdrawFee": "1", - "withdrawIntegerMultiple": "0.000001", - "withdrawMax": "250000100", - "withdrawMin": "10", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false - }, - { - "addressRegex": "^T[1-9A-HJ-NP-Za-km-z]{33}$", - "coin": "USDC", - "depositEnable": true, - "isDefault": false, - "memoRegex": "", - "minConfirm": 1, - "name": "Tron (TRC20)", - "network": "TRX", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 0, - "withdrawEnable": true, - "withdrawFee": "1.5", - "withdrawIntegerMultiple": "0.000001", - "withdrawMax": "10000000000", - "withdrawMin": "10", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false + txID, err := wallet.Send(ctx, withdraw.address, withdraw.amt) + if err != nil { + log.Errorf("Error sending %s: %v", withdraw.coin, err) + delete(f.withdrawalHistory, transferID) + continue } - ] - }, - { - "coin": "WBTC", - "depositAllEnable": true, - "free": "0", - "freeze": "0", - "ipoable": "0", - "ipoing": "0", - "isLegalMoney": false, - "locked": "0", - "name": "Wrapped Bitcoin", - "storage": "0", - "trading": true, - "withdrawAllEnable": true, - "withdrawing": "0", - "networkList": [ - { - "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", - "coin": "WBTC", - "depositEnable": true, - "isDefault": true, - "memoRegex": "", - "minConfirm": 6, - "name": "Ethereum (ERC20)", - "network": "ETH", - "resetAddressStatus": false, - "specialTips": "", - "unLockConfirm": 64, - "withdrawEnable": true, - "withdrawFee": "0.0003", - "withdrawIntegerMultiple": "1e-8", - "withdrawMax": "10000000000", - "withdrawMin": "0.0006", - "sameAddress": false, - "estimatedArrivalTime": 0, - "busy": false + log.Debug("Sent withdraw of %.8f to user %s, coin = %s, txid = %s", withdraw.amt, withdraw.apiKey, withdraw.coin, txID) + withdraw.txID.Store(txID) + debits[withdraw.coin] += withdraw.amt + } + f.withdrawalHistoryMtx.Unlock() + var balUpdates []*bntypes.WSBalance + debitBalance := func(coin string, amt float64) { + for _, b := range f.balances { + if b.Asset == coin { + if amt > b.Free { + b.Free = 0 + } else { + b.Free -= amt + } + balUpdates = append(balUpdates, (*bntypes.WSBalance)(b)) + break + } } - ] + } + + f.balancesMtx.Lock() + for coin, debit := range debits { + debitBalance(coin, debit) + } + f.balancesMtx.Unlock() + if len(balUpdates) > 0 { + f.sendBalanceUpdates(balUpdates) + } } - ]`) + }() - writeBytesWithStatus(w, resp, http.StatusOK) + f.srv.Run(ctx) } -func (f *fakeBinance) handleConfirmDeposit(w http.ResponseWriter, r *http.Request) { - var resp []struct { - Amount float64 `json:"amount,string"` - Coin string `json:"coin"` - Network string `json:"network"` - Status int `json:"status"` - TxID string `json:"txId"` - } - - f.depositHistoryMtx.RLock() - for _, d := range f.depositHistory { - resp = append(resp, struct { - Amount float64 `json:"amount,string"` - Coin string `json:"coin"` - Network string `json:"network"` - Status int `json:"status"` - TxID string `json:"txId"` - }{ - Amount: d.amt, - Status: 6, - TxID: d.txID, - Coin: d.coin, - Network: d.network, - }) +func (f *fakeBinance) newWSLink(w http.ResponseWriter, r *http.Request, handler func([]byte)) (_ *ws.WSLink, _ *dex.ConnectionMaster) { + wsConn, err := ws.NewConnection(w, r, pongWait) + if err != nil { + log.Errorf("ws.NewConnection error: %v", err) + http.Error(w, "error initializing connection", http.StatusInternalServerError) + return } - f.depositHistoryMtx.RUnlock() - fmt.Println("\n\nSending deposit history: ") - for _, d := range resp { - fmt.Printf("%+v\n", d) + ip := dex.NewIPKey(r.RemoteAddr) + + conn := ws.NewWSLink(ip.String(), wsConn, pingPeriod, func(msg *msgjson.Message) *msgjson.Error { + return nil + }, dex.StdOutLogger(fmt.Sprintf("CL[%s]", ip), dex.LevelDebug)) + conn.RawHandler = handler + + cm := dex.NewConnectionMaster(conn) + if err = cm.ConnectOnce(f.ctx); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + return conn, cm +} + +func (f *fakeBinance) handleAccountSubscription(w http.ResponseWriter, r *http.Request) { + apiKey := extractAPIKey(r) + + conn, cm := f.newWSLink(w, r, func(b []byte) { + log.Errorf("Message received from api key %s over account update channel: %s", apiKey, string(b)) + }) + if conn == nil { // Already logged. + return + } + + log.Tracef("User subscribed to account stream with API key %s", apiKey) + + f.accountSubscribersMtx.Lock() + f.accountSubscribers[apiKey] = conn + f.accountSubscribersMtx.Unlock() + + go func() { + cm.Wait() + f.accountSubscribersMtx.Lock() + delete(f.accountSubscribers, apiKey) + f.accountSubscribersMtx.Unlock() + log.Tracef("Account stream connection ended for API key %s", apiKey) + }() +} + +func (f *fakeBinance) handleMarketStream(w http.ResponseWriter, r *http.Request) { + streamsStr := r.URL.Query().Get("streams") + if streamsStr == "" { + log.Error("Client connected to market stream without providing a 'streams' query parameter") + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + rawStreams := strings.Split(streamsStr, "/") + marketIDs := make(map[string]struct{}, len(rawStreams)) + for _, raw := range rawStreams { + parts := strings.Split(raw, "@") + if len(parts) < 2 { + http.Error(w, fmt.Sprintf("stream encoding incorrect %q", raw), http.StatusBadRequest) + return + } + marketIDs[strings.ToUpper(parts[0])] = struct{}{} + } + + cl := &marketSubscriber{ + markets: marketIDs, + } + + subscribe := func(streamIDs []string) { + f.marketsMtx.Lock() + defer f.marketsMtx.Unlock() + for _, streamID := range streamIDs { + parts := strings.Split(streamID, "@") + if len(parts) < 2 { + log.Errorf("SUBSCRIBE stream encoding incorrect: %q", streamID) + return + } + cl.markets[strings.ToUpper(parts[0])] = struct{}{} + } } + unsubscribe := func(streamIDs []string) { + f.marketsMtx.Lock() + defer f.marketsMtx.Unlock() + for _, streamID := range streamIDs { + parts := strings.Split(streamID, "@") + if len(parts) < 2 { + log.Errorf("UNSUBSCRIBE stream encoding incorrect: %q", streamID) + return + } + delete(cl.markets, strings.ToUpper(parts[0])) + } + f.cleanMarkets() + } + + conn, cm := f.newWSLink(w, r, func(b []byte) { + var req bntypes.StreamSubscription + if err := json.Unmarshal(b, &req); err != nil { + log.Errorf("Error unmarshalling markets stream message: %v", err) + return + } + switch req.Method { + case "SUBSCRIBE": + subscribe(req.Params) + case "UNSUBSCRIBE": + unsubscribe(req.Params) + } + }) + if conn == nil { + return + } + + cl.WSLink = conn + + addr := conn.Addr() + log.Tracef("Websocket client %s connected to market stream for markets %+v", addr, marketIDs) + + f.marketsMtx.Lock() + f.marketSubscribers[addr] = cl + f.marketsMtx.Unlock() + + go func() { + cm.Wait() + log.Tracef("Market stream client %s disconnected", addr) + f.marketsMtx.Lock() + delete(f.marketSubscribers, addr) + f.cleanMarkets() + f.marketsMtx.Unlock() + }() +} + +// Call with f.marketsMtx locked +func (f *fakeBinance) cleanMarkets() { + marketSubCount := make(map[string]int) + for _, cl := range f.marketSubscribers { + for mktID := range cl.markets { + marketSubCount[mktID]++ + } + } + for mktID := range f.markets { + if marketSubCount[mktID] == 0 { + delete(f.markets, mktID) + } + } +} + +func (f *fakeBinance) handleWalletCoinsReq(w http.ResponseWriter, r *http.Request) { + respB, _ := json.Marshal(coinInfos) + writeBytesWithStatus(w, respB, http.StatusOK) +} + +func (f *fakeBinance) sendBalanceUpdates(bals []*bntypes.WSBalance) { + update := &bntypes.StreamUpdate{ + EventType: "outboundAccountPosition", + Balances: bals, + } + updateB, _ := json.Marshal(update) + f.accountSubscribersMtx.Lock() + if len(f.accountSubscribers) > 0 { + log.Tracef("Sending balance updates to %d subscribers", len(f.accountSubscribers)) + } + for _, sub := range f.accountSubscribers { + sub.SendRaw(updateB) + } + f.accountSubscribersMtx.Unlock() +} + +func (f *fakeBinance) handleConfirmDeposit(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + txID := q.Get("txid") + amtStr := q.Get("amt") + amt, err := strconv.ParseFloat(amtStr, 64) + if err != nil { + log.Errorf("Error parsing deposit amount string %q: %v", amtStr, err) + http.Error(w, "error parsing amount", http.StatusBadRequest) + return + } + coin := q.Get("coin") + network := q.Get("network") + wallet, err := f.getWallet(coin) + if err != nil { + log.Errorf("Error creating deposit wallet for %s: %v", coin, err) + http.Error(w, "error creating wallet", http.StatusBadRequest) + return + } + confs, err := wallet.Confirmations(f.ctx, txID) + if err != nil { + log.Errorf("Error getting deposit confirmations for %s -> %s: %v", coin, txID, err) + http.Error(w, "error getting confirmations", http.StatusInternalServerError) + return + } + apiKey := extractAPIKey(r) + status := bntypes.DepositStatusPending + if confs >= depositConfs { + status = bntypes.DepositStatusCredited + log.Debugf("Confirmed deposit for %s of %.8f %s", apiKey, amt, coin) + f.balancesMtx.Lock() + var bal *bntypes.WSBalance + for _, b := range f.balances { + if b.Asset == coin { + bal = (*bntypes.WSBalance)(b) + b.Free += amt + break + } + } + f.balancesMtx.Unlock() + if bal != nil { + f.sendBalanceUpdates([]*bntypes.WSBalance{bal}) + } + } else { + log.Tracef("Updating user %s on deposit status for %.8f %s. Confs = %d", apiKey, amt, coin, confs) + } + resp := []*bntypes.PendingDeposit{{ + Amount: amt, + Status: status, + TxID: txID, + Coin: coin, + Network: network, + }} writeJSONWithStatus(w, resp, http.StatusOK) } +func (f *fakeBinance) getWallet(coin string) (Wallet, error) { + symbol := strings.ToLower(coin) + f.walletMtx.Lock() + defer f.walletMtx.Unlock() + wallet, exists := f.wallets[symbol] + if exists { + return wallet, nil + } + wallet, err := newWallet(f.ctx, symbol) + if err != nil { + return nil, err + } + f.wallets[symbol] = wallet + return wallet, nil +} + func (f *fakeBinance) handleGetDepositAddress(w http.ResponseWriter, r *http.Request) { - fmt.Printf("Get deposit address called %s\n", r.URL) coin := r.URL.Query().Get("coin") - var address string - switch coin { - case "ETH": - address = "0xab5801a7d398351b8be11c439e05c5b3259aec9b" - case "BTC": - address = "bcrt1qm8m7mqpc0k3wpdt6ljfm0lf2qmhvc0uh8mteh3" + wallet, err := f.getWallet(coin) + if err != nil { + log.Errorf("Error creating wallet for %s: %v", coin, err) + http.Error(w, "error creating wallet", http.StatusBadRequest) + return } resp := struct { Address string `json:"address"` }{ - Address: address, + Address: wallet.DepositAddress(), } + log.Tracef("User %s requested deposit address %s", extractAPIKey(r), resp.Address) + writeJSONWithStatus(w, resp, http.StatusOK) } func (f *fakeBinance) handleWithdrawal(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - + apiKey := extractAPIKey(r) err := r.ParseForm() if err != nil { - fmt.Println("Error parsing form: ", err) + log.Errorf("Error parsing form for user %s: ", apiKey, err) http.Error(w, "Error parsing form", http.StatusBadRequest) return } @@ -943,19 +717,26 @@ func (f *fakeBinance) handleWithdrawal(w http.ResponseWriter, r *http.Request) { amountStr := r.Form.Get("amount") amt, err := strconv.ParseFloat(amountStr, 64) if err != nil { - fmt.Println("Error parsing amount: ", err) + log.Errorf("Error parsing amount for user %s: ", apiKey, err) http.Error(w, "Error parsing amount", http.StatusBadRequest) return } + coin := r.Form.Get("coin") + network := r.Form.Get("network") + address := r.Form.Get("address") + withdrawalID := hex.EncodeToString(encode.RandomBytes(32)) - fmt.Printf("\n\nWithdraw called: %+v\nResponding with ID: %s\n", r.Form, withdrawalID) + log.Debugf("Withdraw of %.8f %s initiated for user %s", amt, coin, apiKey) f.withdrawalHistoryMtx.Lock() - f.withdrawalHistory = append(f.withdrawalHistory, &transfer{ - id: withdrawalID, - amt: amt * 0.99, - }) + f.withdrawalHistory[withdrawalID] = &withdrawal{ + amt: amt * 0.99, + coin: coin, + network: network, + address: address, + apiKey: apiKey, + } f.withdrawalHistoryMtx.Unlock() resp := struct { @@ -964,114 +745,333 @@ func (f *fakeBinance) handleWithdrawal(w http.ResponseWriter, r *http.Request) { writeJSONWithStatus(w, resp, http.StatusOK) } +type withdrawalHistoryStatus struct { + ID string `json:"id"` + Amount float64 `json:"amount,string"` + Status int `json:"status"` + TxID string `json:"txId"` +} + func (f *fakeBinance) handleWithdrawalHistory(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() const withdrawalCompleteStatus = 6 - - type withdrawalHistoryStatus struct { - ID string `json:"id"` - Amount float64 `json:"amount,string"` - Status int `json:"status"` - TxID string `json:"txId"` - } - withdrawalHistory := make([]*withdrawalHistoryStatus, 0) f.withdrawalHistoryMtx.RLock() - for _, w := range f.withdrawalHistory { + for transferID, w := range f.withdrawalHistory { var status int - if w.txID == "" { + txIDPtr := w.txID.Load() + var txID string + if txIDPtr == nil { status = 2 } else { + txID = txIDPtr.(string) status = withdrawalCompleteStatus } withdrawalHistory = append(withdrawalHistory, &withdrawalHistoryStatus{ - ID: w.id, + ID: transferID, Amount: w.amt, Status: status, - TxID: w.txID, + TxID: txID, }) } f.withdrawalHistoryMtx.RUnlock() - fmt.Println("\n\nSending withdrawal history: ") - for _, w := range withdrawalHistory { - fmt.Printf("%+v\n", w) + log.Tracef("Sending %s withdraws to user %s", len(withdrawalHistory), extractAPIKey(r)) + writeJSONWithStatus(w, withdrawalHistory, http.StatusOK) +} + +func (f *fakeBinance) handleExchangeInfo(w http.ResponseWriter, r *http.Request) { + writeJSONWithStatus(w, xcInfo, http.StatusOK) +} + +func (f *fakeBinance) handleAccount(w http.ResponseWriter, r *http.Request) { + f.balancesMtx.RLock() + defer f.balancesMtx.RUnlock() + writeJSONWithStatus(w, &bntypes.Account{Balances: f.balances}, http.StatusOK) +} + +func (f *fakeBinance) handleDepth(w http.ResponseWriter, r *http.Request) { + slug := r.URL.Query().Get("symbol") + var mkt *bntypes.Market + for _, m := range xcInfo.Symbols { + if m.Symbol == slug { + mkt = m + break + } + } + if mkt == nil { + log.Errorf("No market definition found for market %q", slug) + http.Error(w, "no market "+slug, http.StatusBadRequest) + return } + f.marketsMtx.Lock() + m, found := f.markets[slug] + if !found { + baseFiatRate := f.fiatRates[parseAssetID(mkt.BaseAsset)] + quoteFiatRate := f.fiatRates[parseAssetID(mkt.QuoteAsset)] + m = newMarket(slug, baseFiatRate, quoteFiatRate) + f.markets[slug] = m + } + f.marketsMtx.Unlock() - writeJSONWithStatus(w, withdrawalHistory, http.StatusOK) + var resp bntypes.OrderbookSnapshot + m.bookMtx.RLock() + for _, ord := range m.buys { + resp.Bids = append(resp.Bids, [2]json.Number{json.Number(floatString(ord.rate)), json.Number(floatString(ord.qty))}) + } + for _, ord := range m.sells { + resp.Asks = append(resp.Asks, [2]json.Number{json.Number(floatString(ord.rate)), json.Number(floatString(ord.qty))}) + } + resp.LastUpdateID = m.updateID + m.bookMtx.RUnlock() + writeJSONWithStatus(w, &resp, http.StatusOK) } -func (f *fakeBinance) handleCompleteWithdrawal(w http.ResponseWriter, r *http.Request) { - id := r.URL.Query().Get("id") - txid := r.URL.Query().Get("txid") +func (f *fakeBinance) handleGetOrder(w http.ResponseWriter, r *http.Request) { + tradeID := r.URL.Query().Get("origClientOrderId") + f.bookedOrdersMtx.RLock() + ord, found := f.bookedOrders[tradeID] + f.bookedOrdersMtx.RUnlock() + if !found { + log.Errorf("User %s requested unknown order %s", extractAPIKey(r), tradeID) + http.Error(w, "order not found", http.StatusBadRequest) + return + } + status := "NEW" + if ord.cancelled.Load() { + status = "CANCELED" + } + resp := &bntypes.BookedOrder{ + Symbol: ord.slug, + // OrderID: , + ClientOrderID: tradeID, + Price: strconv.FormatFloat(ord.rate, 'f', 9, 64), + OrigQty: strconv.FormatFloat(ord.qty, 'f', 9, 64), + ExecutedQty: "0", + CumulativeQuoteQty: "0", + Status: status, + TimeInForce: "GTC", + } + writeJSONWithStatus(w, &resp, http.StatusOK) +} - if id == "" || txid == "" { - http.Error(w, "Missing id or txid", http.StatusBadRequest) +func (f *fakeBinance) handlePostOrder(w http.ResponseWriter, r *http.Request) { + apiKey := extractAPIKey(r) + q := r.URL.Query() + slug := q.Get("symbol") + side := q.Get("side") + tradeID := q.Get("newClientOrderId") + qty, err := strconv.ParseFloat(q.Get("quantity"), 64) + if err != nil { + log.Errorf("Error parsing quantity %q for order from user %s: %v", q.Get("quantity"), apiKey, err) + http.Error(w, "Bad quantity formatting", http.StatusBadRequest) + return + } + price, err := strconv.ParseFloat(q.Get("price"), 64) + if err != nil { + log.Errorf("Error parsing price %q for order from user %s: %v", q.Get("price"), apiKey, err) + http.Error(w, "Missing price formatting", http.StatusBadRequest) return } - f.withdrawalHistoryMtx.Lock() - for _, w := range f.withdrawalHistory { - if w.id == id { - fmt.Println("\nUpdated withdrawal history") - w.txID = txid - break + resp := &bntypes.OrderResponse{ + Symbol: slug, + Price: price, + OrigQty: qty, + OrigQuoteQty: qty * price, + } + + bookIt := rand.Float32() < 0.2 + if bookIt { + resp.Status = "NEW" + log.Tracef("Booking %s order on %s for %.8f for user %s", side, slug, qty, apiKey) + f.bookedOrdersMtx.Lock() + f.bookedOrders[tradeID] = &userOrder{ + slug: slug, + sell: side == "SELL", + rate: price, + qty: qty, + apiKey: apiKey, } + f.bookedOrdersMtx.Unlock() + } else { + log.Tracef("Filled %s order on %s for %.8f for user %s", side, slug, qty, apiKey) + resp.Status = "FILLED" + resp.ExecutedQty = qty + resp.CumulativeQuoteQty = qty * price } - f.withdrawalHistoryMtx.Unlock() + writeJSONWithStatus(w, &resp, http.StatusOK) +} + +func (f *fakeBinance) streamExtend(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -func (f *fakeBinance) handleCompleteDeposit(w http.ResponseWriter, r *http.Request) { - txid := r.URL.Query().Get("txid") - amtStr := r.URL.Query().Get("amt") - coin := r.URL.Query().Get("coin") - network := r.URL.Query().Get("network") +func (f *fakeBinance) handleListenKeyRequest(w http.ResponseWriter, r *http.Request) { + resp := &bntypes.DataStreamKey{ + ListenKey: extractAPIKey(r), + } + writeJSONWithStatus(w, resp, http.StatusOK) +} - amt, err := strconv.ParseFloat(amtStr, 64) - if err != nil { - fmt.Println("Error parsing amount: ", err) - http.Error(w, "Error parsing amount", http.StatusBadRequest) +func (f *fakeBinance) handleDeleteOrder(w http.ResponseWriter, r *http.Request) { + tradeID := r.URL.Query().Get("origClientOrderId") + apiKey := extractAPIKey(r) + f.bookedOrdersMtx.Lock() + ord, found := f.bookedOrders[tradeID] + f.bookedOrdersMtx.Unlock() + if found { + if !ord.cancelled.CompareAndSwap(false, true) { + log.Errorf("Detected cancellation of an already cancelled order %s", tradeID) + } + ord.cancelled.Store(true) + } + writeJSONWithStatus(w, &struct{}{}, http.StatusOK) + if !found { + log.Errorf("DELETE request received from user %s for unknown order %s", apiKey, tradeID) return } - if txid == "" { - http.Error(w, "Missing txid", http.StatusBadRequest) + log.Tracef("Deleting order %s on %s for user %s", tradeID, ord.slug, apiKey) + f.accountSubscribersMtx.RLock() + sub, found := f.accountSubscribers[ord.apiKey] + f.accountSubscribersMtx.RUnlock() + if !found { return } + update := &bntypes.StreamUpdate{ + EventType: "executionReport", + CurrentOrderStatus: "CANCELED", + ClientOrderID: hex.EncodeToString(encode.RandomBytes(20)), + CancelledOrderID: tradeID, + Filled: 0, + QuoteFilled: 0, + } + updateB, _ := json.Marshal(update) + sub.SendRaw(updateB) +} - f.depositHistoryMtx.Lock() - f.depositHistory = append(f.depositHistory, &transfer{ - amt: amt, - txID: txid, - coin: coin, - network: network, - }) - f.depositHistoryMtx.Unlock() - w.WriteHeader(http.StatusOK) +type rateQty struct { + rate float64 + qty float64 } -type fakeBinanceNetworkInfo struct { - Coin string `json:"coin"` - MinConfirm int `json:"minConfirm"` - Network string `json:"network"` - UnLockConfirm int `json:"unLockConfirm"` - WithdrawEnable bool `json:"withdrawEnable"` - WithdrawFee string `json:"withdrawFee"` - WithdrawIntegerMultiple string `json:"withdrawIntegerMultiple"` - WithdrawMax string `json:"withdrawMax"` - WithdrawMin string `json:"withdrawMin"` +type market struct { + slug string + baseFiatRate, quoteFiatRate, basisRate float64 + minRate, maxRate float64 + + rate atomic.Uint64 + + bookMtx sync.RWMutex + updateID uint64 + buys, sells []*rateQty } -type fakeBinanceCoinInfo struct { - Coin string `json:"coin"` - Free string `json:"free"` - Locked string `json:"locked"` - Withdrawing string `json:"withdrawing"` - NetworkList []*fakeBinanceNetworkInfo `json:"networkList"` +func newMarket(slug string, baseFiatRate, quoteFiatRate float64) *market { + const maxVariation = 0.1 + basisRate := baseFiatRate / quoteFiatRate + minRate, maxRate := basisRate*(1/(1+maxVariation)), basisRate*(1+maxVariation) + m := &market{ + slug: slug, + baseFiatRate: baseFiatRate, + quoteFiatRate: quoteFiatRate, + basisRate: basisRate, + minRate: minRate, + maxRate: maxRate, + buys: make([]*rateQty, 0), + sells: make([]*rateQty, 0), + } + m.rate.Store(math.Float64bits(basisRate)) + log.Tracef("Market %s intitialized with base fiat rate = %.4f, quote fiat rate = %.4f "+ + "basis rate = %.8f. Mid-gap rate will randomly walk between %.8f and %.8f", + slug, baseFiatRate, quoteFiatRate, basisRate, minRate, maxRate) + m.shuffle() + return m +} + +// Randomize the order book. booksMtx must be locked. +func (m *market) shuffle() (buys, sells [][2]json.Number) { + maxChangeRatio := defaultWalkingSpeed * walkingSpeedAdj + maxShift := m.basisRate * maxChangeRatio + oldRate := math.Float64frombits(m.rate.Load()) + if rand.Float64() < 0.5 { + maxShift *= -1 + } + shiftRoll := rand.Float64() + shift := maxShift * shiftRoll + newRate := oldRate + shift + + if newRate < m.minRate { + newRate = m.minRate + } + if newRate > m.maxRate { + newRate = m.maxRate + } + + m.rate.Store(math.Float64bits(newRate)) + log.Tracef("%s: A randomized (max %.1f%%) shift of %.8f (%.3f%%) was applied to the old rate of %.8f, "+ + "resulting in a new mid-gap of %.8f", + m.slug, maxChangeRatio*100, shift, shiftRoll*maxChangeRatio*100, oldRate, newRate, + ) + + halfGapRoll := rand.Float64() + const minHalfGap, halfGapRange = 0.002, 0.02 + halfGapFactor := minHalfGap + halfGapRoll*halfGapRange + bestBuy, bestSell := newRate/(1+halfGapFactor), newRate*(1+halfGapFactor) + + levelSpacingRoll := rand.Float64() + const minLevelSpacing, levelSpacingRange = 0.002, 0.01 + levelSpacing := (minLevelSpacing + levelSpacingRoll*levelSpacingRange) * newRate + + log.Tracef("%s: Half-gap roll of %.4f%% resulted in a half-gap factor of %.4f%%, range %.8f to %0.8f. "+ + "Level-spacing roll of %.4f%% resulted in a level spacing of %.8f", + m.slug, halfGapRoll*100, halfGapFactor*100, bestBuy, bestSell, levelSpacingRoll*100, levelSpacing, + ) + + zeroBookSide := func(ords []*rateQty) map[string]string { + bin := make(map[string]string, len(ords)) + for _, ord := range ords { + bin[floatString(ord.rate)] = "0" + } + return bin + } + jsBuys, jsSells := zeroBookSide(m.buys), zeroBookSide(m.sells) + + makeOrders := func(bestRate, direction float64, jsSide map[string]string) []*rateQty { + nLevels := rand.Intn(25) + ords := make([]*rateQty, nLevels) + for i := 0; i < nLevels; i++ { + rate := bestRate + levelSpacing*direction*newRate*float64(i) + // Each level has between 1 and 10,001 USD equivalent. + const minQtyUSD, qtyUSDRange = 1, 10_000 + qtyUSD := minQtyUSD + qtyUSDRange*rand.Float64() + qty := qtyUSD / m.baseFiatRate + jsSide[floatString(rate)] = floatString(qty) + ords[i] = &rateQty{ + rate: rate, + qty: qty, + } + } + return ords + } + m.buys = makeOrders(bestBuy, -1, jsBuys) + m.sells = makeOrders(bestSell, 1, jsSells) + + log.Tracef("%s: Shuffle resulted in %d buy orders and %d sell orders being placed", m.slug, len(m.buys), len(m.sells)) + + convertSide := func(side map[string]string) [][2]json.Number { + updates := make([][2]json.Number, 0, len(side)) + for r, q := range side { + updates = append(updates, [2]json.Number{json.Number(r), json.Number(q)}) + } + return updates + } + + return convertSide(jsBuys), convertSide(jsSells) } // writeJSON marshals the provided interface and writes the bytes to the @@ -1094,3 +1094,11 @@ func writeBytesWithStatus(w http.ResponseWriter, b []byte, code int) { log.Errorf("Write error: %v", err) } } + +func extractAPIKey(r *http.Request) string { + return r.Header.Get("X-MBX-APIKEY") +} + +func floatString(v float64) string { + return strconv.FormatFloat(v, 'f', 8, 64) +} diff --git a/client/cmd/testbinance/wallets.go b/client/cmd/testbinance/wallets.go new file mode 100644 index 0000000000..b5c867e3ba --- /dev/null +++ b/client/cmd/testbinance/wallets.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + ethrpc "github.com/ethereum/go-ethereum/rpc" +) + +var ( + dextestDir = filepath.Join(os.Getenv("HOME"), "dextest") +) + +type Wallet interface { + DepositAddress() string + Confirmations(ctx context.Context, txID string) (uint32, error) + Send(ctx context.Context, addr string, amt float64) (string, error) +} + +type utxoWallet struct { + symbol string + dir string + addr string +} + +func newUtxoWallet(ctx context.Context, symbol string) (*utxoWallet, error) { + symbol = strings.ToLower(symbol) + dir := filepath.Join(dextestDir, symbol, "harness-ctl") + ctx, cancel := context.WithTimeout(ctx, time.Second*5) + defer cancel() + cmd := exec.CommandContext(ctx, "./alpha", "getnewaddress") + cmd.Dir = dir + addr, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("getnewaddress error with output = %q, err = %v", string(addr), err) + } + return &utxoWallet{ + symbol: symbol, + dir: dir, + addr: strings.TrimSpace(string(addr)), + }, nil +} + +func (w *utxoWallet) DepositAddress() string { + return w.addr +} + +func (w *utxoWallet) Confirmations(ctx context.Context, txID string) (uint32, error) { + cmd := exec.CommandContext(ctx, "./alpha", "gettransaction", txID) + cmd.Dir = w.dir + log.Tracef("Running utxoWallet.Confirmations command %q from directory %q", cmd, w.dir) + b, err := cmd.CombinedOutput() + if err != nil { + return 0, fmt.Errorf("gettransaction error with output = %q, err = %v", string(b), err) + } + var resp struct { + Confs uint32 `json:"confirmations"` + } + if err = json.Unmarshal(b, &resp); err != nil { + return 0, fmt.Errorf("error unmarshaling gettransaction response = %q, err = %s", string(b), err) + } + return resp.Confs, nil +} + +func (w *utxoWallet) unlock(ctx context.Context) { + switch w.symbol { + case "zec": // TODO: Others? + return + } + cmd := exec.CommandContext(ctx, "./alpha", "walletpassphrase", "abc", "100000000") + cmd.Dir = w.dir + errText, err := cmd.CombinedOutput() + if err != nil { + log.Errorf("walletpassphrase error with output = %q, err = %v", string(errText), err) + } +} + +func (w *utxoWallet) Send(ctx context.Context, addr string, amt float64) (string, error) { + w.unlock(ctx) + cmd := exec.CommandContext(ctx, "./alpha", "sendtoaddress", addr, strconv.FormatFloat(amt, 'f', 8, 64)) + cmd.Dir = w.dir + log.Tracef("Running utxoWallet.Send command %q from directory %q", cmd, w.dir) + txID, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("sendtoaddress error with output = %q, err = %v", string(txID), err) + } + return strings.TrimSpace(string(txID)), err +} + +type evmWallet struct { + dir string + addr string + ec *ethclient.Client +} + +func newEvmWallet(ctx context.Context, symbol string) (*evmWallet, error) { + symbol = strings.ToLower(symbol) + rpcAddr := "http://localhost:38556" + switch symbol { + case "matic", "polygon": + symbol = "polygon" + rpcAddr = "http://localhost:48296" + } + + ctx, cancel := context.WithTimeout(ctx, time.Second*5) + defer cancel() + rpcClient, err := ethrpc.DialContext(ctx, rpcAddr) + if err != nil { + return nil, err + } + + ec := ethclient.NewClient(rpcClient) + return &evmWallet{ + dir: filepath.Join(dextestDir, symbol, "harness-ctl"), + addr: "0x18d65fb8d60c1199bb1ad381be47aa692b482605", + ec: ec, + }, nil +} + +func (w *evmWallet) DepositAddress() string { + return w.addr +} + +func (w *evmWallet) Confirmations(ctx context.Context, txID string) (uint32, error) { + r, err := w.ec.TransactionReceipt(ctx, common.HexToHash(txID)) + if err != nil { + return 0, fmt.Errorf("TransactionReceipt error: %v", err) + } + tip, err := w.ec.HeaderByNumber(ctx, nil /* latest */) + if err != nil { + return 0, fmt.Errorf("HeaderByNumber error: %w", err) + } + if r.BlockNumber != nil && tip.Number != nil { + bigConfs := new(big.Int).Sub(tip.Number, r.BlockNumber) + if bigConfs.Sign() < 0 { // avoid potential overflow + return 0, nil + } + bigConfs.Add(bigConfs, big.NewInt(1)) + if bigConfs.IsInt64() { + return uint32(bigConfs.Int64()), nil + } + } + return 0, nil +} + +func (w *evmWallet) Send(ctx context.Context, addr string, amt float64) (string, error) { + cmd := exec.CommandContext(ctx, "./sendtoaddress", addr, strconv.FormatFloat(amt, 'f', 9, 64)) + cmd.Dir = w.dir + log.Tracef("Running evmWallet.Send command %q from directory %q", cmd, w.dir) + b, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("sendtoaddress error with output = %q, err = %v", string(b), err) + } + // There's probably a deprecation warning ending in a newline before the txid. + lines := strings.Split(strings.TrimSpace(string(b)), "\n") + jsonTxID := lines[len(lines)-1] + var txID string + if err = json.Unmarshal([]byte(jsonTxID), &txID); err != nil { + return "", fmt.Errorf("error decoding address from %q: %v", jsonTxID, err) + } + if common.HexToHash(txID) == (common.Hash{}) { + return "", fmt.Errorf("output %q did not parse to a tx hash", txID) + } + return txID, nil +} + +func newWallet(ctx context.Context, symbol string) (w Wallet, err error) { + switch strings.ToLower(symbol) { + case "btc", "dcr": + w, err = newUtxoWallet(ctx, symbol) + case "eth", "matic", "polygon": + w, err = newEvmWallet(ctx, symbol) + } + return w, err +} diff --git a/client/core/core.go b/client/core/core.go index 715d87b8d8..c56d6cce5c 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -1973,20 +1973,34 @@ func (c *Core) Exchange(host string) (*Exchange, error) { // ExchangeMarket returns the market with the given base and quote assets at the // given host. It returns an error if no market exists at that host. -func (c *Core) ExchangeMarket(host string, base, quote uint32) (*Market, error) { +func (c *Core) ExchangeMarket(host string, baseID, quoteID uint32) (*Market, error) { dc, _, err := c.dex(host) if err != nil { return nil, err } - mkt := dc.coreMarket(marketName(base, quote)) + mkt := dc.coreMarket(marketName(baseID, quoteID)) if mkt == nil { - return nil, fmt.Errorf("no market found for %s-%s at %s", unbip(base), unbip(quote), host) + return nil, fmt.Errorf("no market found for %s-%s at %s", unbip(baseID), unbip(quoteID), host) } return mkt, nil } +// MarketConfig gets the configuration for the market. +func (c *Core) MarketConfig(host string, baseID, quoteID uint32) (*msgjson.Market, error) { + dc, _, err := c.dex(host) + if err != nil { + return nil, err + } + for _, mkt := range dc.config().Markets { + if mkt.Base == baseID && mkt.Quote == quoteID { + return mkt, nil + } + } + return nil, fmt.Errorf("market (%d, %d) not found for host %s", baseID, quoteID, host) +} + // dexConnections creates a slice of the *dexConnection in c.conns. func (c *Core) dexConnections() []*dexConnection { c.connMtx.RLock() @@ -7309,7 +7323,7 @@ func (c *Core) authDEX(dc *dexConnection) error { bondAsset := bondAssets[bond.AssetID] if bondAsset == nil { - c.log.Warnf("Server no longer supports %v as a bond asset!", symb) + c.log.Warnf("Server no longer supports %d as a bond asset!", bond.AssetID) continue } diff --git a/client/mm/event_log.go b/client/mm/event_log.go index 6afcb16d06..d2a0131768 100644 --- a/client/mm/event_log.go +++ b/client/mm/event_log.go @@ -188,7 +188,7 @@ func newBoltEventLogDB(ctx context.Context, path string, log dex.Logger) (*boltE // the event already exists, it is updated. If it does not exist, it is added. // The stats for the run are also updated based on the event. func (db *boltEventLogDB) updateEvent(update *eventUpdate) { - err := db.Update(func(tx *bbolt.Tx) error { + if err := db.Update(func(tx *bbolt.Tx) error { botRuns := tx.Bucket(botRunsBucket) runBucket := botRuns.Bucket(update.runKey) if runBucket == nil { @@ -214,8 +214,9 @@ func (db *boltEventLogDB) updateEvent(update *eventUpdate) { return err } return runBucket.Put(finalStateKey, versionedBytes(0).AddData(bsJSON)) - }) - db.log.Errorf("error storing event: %v", err) + }); err != nil { + db.log.Errorf("error storing event: %v", err) + } } // listedForStoreEvents listens on the updateEvent channel and updates the diff --git a/client/mm/exchange_adaptor.go b/client/mm/exchange_adaptor.go index 986b7c6f24..961ed213f7 100644 --- a/client/mm/exchange_adaptor.go +++ b/client/mm/exchange_adaptor.go @@ -20,6 +20,7 @@ import ( "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/dex/order" ) @@ -59,7 +60,8 @@ type botCoreAdaptor interface { SyncBook(host string, base, quote uint32) (*orderbook.OrderBook, core.BookFeed, error) Cancel(oidB dex.Bytes) error DEXTrade(rate, qty uint64, sell bool) (*core.Order, error) - ExchangeMarket(host string, base, quote uint32) (*core.Market, error) + ExchangeMarket(host string, baseID, quoteID uint32) (*core.Market, error) + MarketConfig(host string, baseID, quoteID uint32) (*msgjson.Market, error) MultiTrade(placements []*multiTradePlacement, sell bool, driftTolerance float64, currEpoch uint64, dexReserves, cexReserves map[uint32]uint64) []*order.OrderID CancelAllOrders() bool ExchangeRateFromFiatSources() uint64 @@ -276,9 +278,9 @@ func (u *unifiedExchangeAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, } } - mkt, err := u.ExchangeMarket(u.market.Host, u.market.BaseID, u.market.QuoteID) + mkt, err := u.clientCore.MarketConfig(u.market.Host, u.market.BaseID, u.market.QuoteID) if err != nil { - return false, err + return false, fmt.Errorf("Error getting lot size for market %s: %v", u.market, err) } buyFees, sellFees, err := u.orderFees() @@ -748,8 +750,6 @@ func (u *unifiedExchangeAdaptor) MultiTrade(placements []*multiTradePlacement, s remainingCEXBal -= reserves } - u.balancesMtx.RLock() - cancels := make([]dex.Bytes, 0, len(placements)) addCancel := func(o *core.Order) { if currEpoch-o.Epoch < 2 { // TODO: check epoch @@ -778,6 +778,9 @@ func (u *unifiedExchangeAdaptor) MultiTrade(placements []*multiTradePlacement, s for _, groupedOrders := range pendingOrders { for _, o := range groupedOrders { if !withinTolerance(o.order.Rate, placements[o.placementIndex].rate, driftTolerance) { + u.log.Tracef("%s cancel candidate with rate = %d, placement rate = %d, drift tolerance = %.4f%%", + u.market, o.order.Rate, placements[o.placementIndex].rate, driftTolerance*100, + ) addCancel(o.order) } else { ordersWithinTolerance = append([]*pendingDEXOrder{o}, ordersWithinTolerance...) @@ -802,7 +805,42 @@ func (u *unifiedExchangeAdaptor) MultiTrade(placements []*multiTradePlacement, s rateCausesSelfMatch := u.rateCausesSelfMatchFunc(sell) + fundingReq := func(rate, lots, counterTradeRate uint64) (dexReq map[uint32]uint64, cexReq uint64) { + qty := mkt.LotSize * lots + if !sell { + qty = calc.BaseToQuote(rate, qty) + } + dexReq = make(map[uint32]uint64) + dexReq[fromAsset] += qty + dexReq[fromFeeAsset] += fees.Swap * lots + if u.isAccountLocker(fromAsset) { + dexReq[fromFeeAsset] += fees.Refund * lots + } + if u.isAccountLocker(toAsset) { + dexReq[toFeeAsset] += fees.Redeem * lots + } + if accountForCEXBal { + if sell { + cexReq = calc.BaseToQuote(counterTradeRate, mkt.LotSize*lots) + } else { + cexReq = mkt.LotSize * lots + } + } + return + } + + canAffordLots := func(rate, lots, counterTradeRate uint64) bool { + dexReq, cexReq := fundingReq(rate, lots, counterTradeRate) + for assetID, v := range dexReq { + if remainingBalances[assetID] < v { + return false + } + } + return remainingCEXBal >= cexReq + } + orderInfos := make([]*dexOrderInfo, 0, len(requiredPlacements)) + for i, placement := range requiredPlacements { if placement.lots == 0 { continue @@ -813,51 +851,19 @@ func (u *unifiedExchangeAdaptor) MultiTrade(placements []*multiTradePlacement, s continue } - var lotsToPlace uint64 - for l := 0; l < int(placement.lots); l++ { - qty := mkt.LotSize - if !sell { - qty = calc.BaseToQuote(placement.rate, mkt.LotSize) - } - if remainingBalances[fromAsset] < qty { - break - } - remainingBalances[fromAsset] -= qty - - if remainingBalances[fromFeeAsset] < fees.Swap { - break - } - remainingBalances[fromFeeAsset] -= fees.Swap - - if u.isAccountLocker(fromAsset) { - if remainingBalances[fromFeeAsset] < fees.Refund { - break - } - remainingBalances[fromFeeAsset] -= fees.Refund - } + searchN := int(placement.lots) + 1 + lotsPlus1 := sort.Search(searchN, func(lotsi int) bool { + return !canAffordLots(placement.rate, uint64(lotsi), placement.counterTradeRate) + }) - if u.isAccountLocker(toAsset) { - if remainingBalances[toFeeAsset] < fees.Redeem { - break - } - remainingBalances[toFeeAsset] -= fees.Redeem - } - - if accountForCEXBal { - counterTradeQty := mkt.LotSize - if sell { - counterTradeQty = calc.BaseToQuote(placement.counterTradeRate, mkt.LotSize) - } - if remainingCEXBal < counterTradeQty { - break - } - remainingCEXBal -= counterTradeQty + var lotsToPlace uint64 + if lotsPlus1 > 1 { + lotsToPlace = uint64(lotsPlus1) - 1 + dexReq, cexReq := fundingReq(placement.rate, lotsToPlace, placement.counterTradeRate) + for assetID, v := range dexReq { + remainingBalances[assetID] -= v } - - lotsToPlace = uint64(l + 1) - } - - if lotsToPlace > 0 { + remainingCEXBal -= cexReq orderInfos = append(orderInfos, &dexOrderInfo{ placementIndex: uint64(i), counterTradeRate: placement.counterTradeRate, @@ -881,8 +887,6 @@ func (u *unifiedExchangeAdaptor) MultiTrade(placements []*multiTradePlacement, s } } - u.balancesMtx.RUnlock() - for _, cancel := range cancels { if err := u.Cancel(cancel); err != nil { u.log.Errorf("MultiTrade: error canceling order %s: %v", cancel, err) @@ -1253,6 +1257,14 @@ func (u *unifiedExchangeAdaptor) Deposit(ctx context.Context, assetID uint32, am depositTime := time.Now().Unix() u.updatePendingDeposit(assetID, tx, 0, eventID, depositTime, false) + ui, _ := asset.UnitInfo(assetID) + deposit := &libxc.DepositData{ + AssetID: assetID, + TxID: tx.ID, + Amount: amount, + AmountConventional: float64(amount) / float64(ui.Conventional.ConversionFactor), + } + go func() { if u.isDynamicSwapper(assetID) { tx = u.confirmWalletTransaction(ctx, assetID, tx.ID) @@ -1266,7 +1278,7 @@ func (u *unifiedExchangeAdaptor) Deposit(ctx context.Context, assetID uint32, am u.updatePendingDeposit(assetID, tx, creditedAmt, eventID, depositTime, true) u.sendStatsUpdate() } - u.CEX.ConfirmDeposit(ctx, tx.ID, cexConfirmedDeposit) + u.CEX.ConfirmDeposit(ctx, deposit, cexConfirmedDeposit) }() return nil @@ -1418,9 +1430,6 @@ func (u *unifiedExchangeAdaptor) FreeUpFunds(assetID uint32, cex bool, amt uint6 amt -= bal.Available } - u.balancesMtx.RLock() - defer u.balancesMtx.RUnlock() - sells := base != cex orders := u.groupedBookedOrders(sells) diff --git a/client/mm/exchange_adaptor_test.go b/client/mm/exchange_adaptor_test.go index 9a47019954..438fd54be3 100644 --- a/client/mm/exchange_adaptor_test.go +++ b/client/mm/exchange_adaptor_test.go @@ -1319,6 +1319,7 @@ func TestMultiTrade(t *testing.T) { dexBalances = test.buyDexBalances cexBalances = test.buyCexBalances } + adaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ core: tCore, baseDexBalances: dexBalances, diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index 02dce94267..35cf78aa8b 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -24,6 +24,7 @@ import ( "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/comms" + "decred.org/dcrdex/client/mm/libxc/bntypes" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/encode" @@ -45,33 +46,43 @@ const ( // sapi endpoints are not implemented by binance's test network. This url // connects to the process at client/cmd/testbinance, which responds to the // /sapi/v1/capital/config/getall endpoint. - fakeBinanceURL = "http://localhost:37346" + fakeBinanceURL = "http://localhost:37346" + fakeBinanceWsURL = "ws://localhost:37346" ) // binanceOrderBook manages an orderbook for a single market. It keeps // the orderbook synced and allows querying of vwap. type binanceOrderBook struct { mtx sync.RWMutex - synced bool + synced atomic.Bool numSubscribers uint32 + cm *dex.ConnectionMaster + + getSnapshot func() (*bntypes.OrderbookSnapshot, error) book *orderbook - updateQueue chan *binanceBookUpdate + updateQueue chan *bntypes.BookUpdate mktID string baseConversionFactor uint64 quoteConversionFactor uint64 log dex.Logger } -func newBinanceOrderBook(baseConversionFactor, quoteConversionFactor uint64, mktID string, log dex.Logger) *binanceOrderBook { +func newBinanceOrderBook( + baseConversionFactor, quoteConversionFactor uint64, + mktID string, + getSnapshot func() (*bntypes.OrderbookSnapshot, error), + log dex.Logger, +) *binanceOrderBook { return &binanceOrderBook{ book: newOrderBook(), mktID: mktID, - updateQueue: make(chan *binanceBookUpdate, 1024), + updateQueue: make(chan *bntypes.BookUpdate, 1024), numSubscribers: 1, baseConversionFactor: baseConversionFactor, quoteConversionFactor: quoteConversionFactor, log: log, + getSnapshot: getSnapshot, } } @@ -123,103 +134,151 @@ func (b *binanceOrderBook) convertBinanceBook(binanceBids, binanceAsks [][2]json // // This function runs until the context is canceled. It must be started as // a new goroutine. -func (b *binanceOrderBook) sync(ctx context.Context, getSnapshot func() (*binanceOrderbookSnapshot, error)) { - syncOrderbook := func(minUpdateID uint64) (uint64, bool) { - b.log.Debugf("Syncing %s orderbook. First update ID: %d", b.mktID, minUpdateID) - - const maxTries = 5 - for i := 0; i < maxTries; i++ { - if i > 0 { - select { - case <-time.After(2 * time.Second): - case <-ctx.Done(): - return 0, false - } - } +func (b *binanceOrderBook) sync(ctx context.Context) { + cm := dex.NewConnectionMaster(b) + b.mtx.Lock() + b.cm = cm + b.mtx.Unlock() + if err := cm.ConnectOnce(ctx); err != nil { + b.log.Errorf("Error connecting %s order book: %v", b.mktID, err) + } +} - snapshot, err := getSnapshot() - if err != nil { - b.log.Errorf("Error getting orderbook snapshot: %v", err) - continue - } - if snapshot.LastUpdateID < minUpdateID { - b.log.Infof("Snapshot last update ID %d is less than first update ID %d. Getting new snapshot...", snapshot.LastUpdateID, minUpdateID) +func (b *binanceOrderBook) Connect(ctx context.Context) (*sync.WaitGroup, error /* no errors */) { + const updateIDUnsynced = math.MaxUint64 + + // We'll run two goroutines and sychronize two local vars. + var syncMtx sync.Mutex + var syncCache []*bntypes.BookUpdate + var updateID uint64 = updateIDUnsynced + + resyncChan := make(chan struct{}, 1) + + desync := func() { + // clear the sync cache, set the special ID, trigger a book refresh. + syncMtx.Lock() + defer syncMtx.Unlock() + syncCache = make([]*bntypes.BookUpdate, 0) + if updateID != updateIDUnsynced { + b.synced.Store(false) + updateID = updateIDUnsynced + resyncChan <- struct{}{} + } + } + + acceptUpdate := func(update *bntypes.BookUpdate) bool { + if updateID == updateIDUnsynced { + // Book is still syncing. Add it to the sync cache. + syncCache = append(syncCache, update) + return true + } + if update.FirstUpdateID != updateID+1 { + // Trigger a resync. + return false + } + // Properly sequenced. + updateID = update.LastUpdateID + bids, asks, err := b.convertBinanceBook(update.Bids, update.Asks) + if err != nil { + b.log.Errorf("Error parsing binance book: %v", err) + // Data is compromised. Trigger a resync. + return false + } + b.book.update(bids, asks) + return true + } + + processSyncCache := func(snapshotID uint64) bool { + syncMtx.Lock() + defer syncMtx.Unlock() + updateID = snapshotID + for _, update := range syncCache { + if update.LastUpdateID <= snapshotID { continue } - - bids, asks, err := b.convertBinanceBook(snapshot.Bids, snapshot.Asks) - if err != nil { - b.log.Errorf("Error parsing binance book: %v", err) - return 0, false + if !acceptUpdate(update) { + return false } + } + b.synced.Store(true) + return true + } - b.log.Debugf("Got %s orderbook snapshot with update ID %d", b.mktID, snapshot.LastUpdateID) - - b.book.clear() - b.book.update(bids, asks) - return snapshot.LastUpdateID, true + syncOrderbook := func() bool { + snapshot, err := b.getSnapshot() + if err != nil { + b.log.Errorf("Error getting orderbook snapshot: %v", err) + return false } - return 0, false - } + bids, asks, err := b.convertBinanceBook(snapshot.Bids, snapshot.Asks) + if err != nil { + b.log.Errorf("Error parsing binance book: %v", err) + return false + } - setSynced := func(synced bool) { - b.mtx.Lock() - b.synced = synced - b.mtx.Unlock() - } + b.log.Debugf("Got %s orderbook snapshot with update ID %d", b.mktID, snapshot.LastUpdateID) - var firstUpdate *binanceBookUpdate - select { - case <-ctx.Done(): - return - case firstUpdate = <-b.updateQueue: - } + b.book.clear() + b.book.update(bids, asks) - latestUpdate, success := syncOrderbook(firstUpdate.FirstUpdateID) - if !success { - b.log.Errorf("Failed to sync %s orderbook", b.mktID) - return + return processSyncCache(snapshot.LastUpdateID) } - setSynced(true) - - b.log.Infof("Successfully synced %s orderbook", b.mktID) + var wg sync.WaitGroup + wg.Add(1) + go func() { + processUpdate := func(update *bntypes.BookUpdate) bool { + syncMtx.Lock() + defer syncMtx.Unlock() + return acceptUpdate(update) + } - for { - select { - case update := <-b.updateQueue: - if update.LastUpdateID <= latestUpdate { - continue + defer wg.Done() + for { + select { + case update := <-b.updateQueue: + if !processUpdate(update) { + b.log.Tracef("Bad %s update with ID %d", b.mktID, update.LastUpdateID) + desync() + } + case <-ctx.Done(): + return } + } + }() + + wg.Add(1) + go func() { + defer wg.Done() - if update.FirstUpdateID > latestUpdate+1 { - b.log.Warnf("Missed %d updates for %s orderbook. Re-syncing...", update.FirstUpdateID-latestUpdate, b.mktID) + const retryFrequency = time.Second * 30 - setSynced(false) + retry := time.After(0) - latestUpdate, success = syncOrderbook(update.LastUpdateID) - if !success { - b.log.Errorf("Failed to re-sync %s orderbook.", b.mktID) - return + for { + select { + case <-retry: + case <-resyncChan: + if retry != nil { // don't hammer + continue } - - setSynced(true) - continue + case <-ctx.Done(): + return } - bids, asks, err := b.convertBinanceBook(update.Bids, update.Asks) - if err != nil { - b.log.Errorf("Error parsing binance book: %v", err) - continue + if syncOrderbook() { + b.log.Infof("Synced %s orderbook", b.mktID) + retry = nil + } else { + b.log.Infof("Failed to sync %s orderbook. Trying again in %s", b.mktID, retryFrequency) + desync() // Clears the syncCache + retry = time.After(retryFrequency) } - - b.book.update(bids, asks) - latestUpdate = update.LastUpdateID - case <-ctx.Done(): - return } - } + }() + + return &wg, nil } // vwap returns the volume weighted average price for a certain quantity of the @@ -228,7 +287,7 @@ func (b *binanceOrderBook) vwap(bids bool, qty uint64) (vwap, extrema uint64, fi b.mtx.RLock() defer b.mtx.RUnlock() - if !b.synced { + if !b.synced.Load() { return 0, 0, filled, errors.New("orderbook not synced") } @@ -362,7 +421,8 @@ type tradeInfo struct { type binance struct { log dex.Logger - url string + marketsURL string + accountsURL string wsURL string apiKey string secretKey string @@ -397,13 +457,23 @@ type binance struct { var _ CEX = (*binance)(nil) +// TODO: Investigate stablecoin auto-conversion. +// https://developers.binance.com/docs/wallet/endpoints/switch-busd-stable-coins-convertion + func newBinance(apiKey, secretKey string, log dex.Logger, net dex.Network, binanceUS bool) *binance { - url, wsURL := httpURL, websocketURL - if binanceUS { - url, wsURL = usHttpURL, usWebsocketURL - } - if net == dex.Testnet || net == dex.Simnet { - url, wsURL = testnetHttpURL, testnetWebsocketURL + var marketsURL, accountsURL, wsURL string + + switch net { + case dex.Testnet: + marketsURL, accountsURL, wsURL = testnetHttpURL, fakeBinanceURL, testnetWebsocketURL + case dex.Simnet: + marketsURL, accountsURL, wsURL = fakeBinanceURL, fakeBinanceURL, fakeBinanceWsURL + default: //mainnet + if binanceUS { + marketsURL, accountsURL, wsURL = usHttpURL, usHttpURL, usWebsocketURL + } else { + marketsURL, accountsURL, wsURL = httpURL, httpURL, websocketURL + } } registeredAssets := asset.Assets() @@ -414,7 +484,8 @@ func newBinance(apiKey, secretKey string, log dex.Logger, net dex.Network, binan bnc := &binance{ log: log, - url: url, + marketsURL: marketsURL, + accountsURL: accountsURL, wsURL: wsURL, apiKey: apiKey, secretKey: secretKey, @@ -428,7 +499,7 @@ func newBinance(apiKey, secretKey string, log dex.Logger, net dex.Network, binan tradeIDNoncePrefix: encode.RandomBytes(10), } - bnc.markets.Store(make(map[string]*binanceMarket)) + bnc.markets.Store(make(map[string]*bntypes.Market)) bnc.tokenIDs.Store(make(map[string][]uint32)) return bnc @@ -437,16 +508,10 @@ func newBinance(apiKey, secretKey string, log dex.Logger, net dex.Network, binan // setBalances queries binance for the user's balances and stores them in the // balances map. func (bnc *binance) setBalances(ctx context.Context) error { - var resp struct { - Balances []struct { - Asset string `json:"asset"` - Free float64 `json:"free,string"` - Locked float64 `json:"locked,string"` - } `json:"balances"` - } + var resp bntypes.Account err := bnc.getAPI(ctx, "/api/v3/account", nil, true, true, &resp) if err != nil { - return err + return fmt.Errorf("error getting balances: %w", err) } tokenIDs := bnc.tokenIDs.Load().(map[string][]uint32) @@ -478,7 +543,7 @@ func (bnc *binance) setBalances(ctx context.Context) error { // setTokenIDs stores the token IDs for which deposits and withdrawals are // enabled on binance. -func (bnc *binance) setTokenIDs(coins []*binanceCoinInfo) { +func (bnc *binance) setTokenIDs(coins []*bntypes.CoinInfo) { tokenIDs := make(map[string][]uint32) for _, nfo := range coins { tokenSymbol := strings.ToLower(nfo.Coin) @@ -505,7 +570,7 @@ func (bnc *binance) setTokenIDs(coins []*binanceCoinInfo) { // getCoinInfo retrieves binance configs then updates the user balances and // the tokenIDs. func (bnc *binance) getCoinInfo(ctx context.Context) error { - coins := make([]*binanceCoinInfo, 0) + coins := make([]*bntypes.CoinInfo, 0) err := bnc.getAPI(ctx, "/sapi/v1/capital/config/getall", nil, true, true, &coins) if err != nil { return fmt.Errorf("error getting binance coin info: %w", err) @@ -515,24 +580,14 @@ func (bnc *binance) getCoinInfo(ctx context.Context) error { return nil } -func (bnc *binance) getMarkets(ctx context.Context) (map[string]*binanceMarket, error) { - var exchangeInfo struct { - Timezone string `json:"timezone"` - ServerTime int64 `json:"serverTime"` - RateLimits []struct { - RateLimitType string `json:"rateLimitType"` - Interval string `json:"interval"` - IntervalNum int64 `json:"intervalNum"` - Limit int64 `json:"limit"` - } `json:"rateLimits"` - Symbols []*binanceMarket `json:"symbols"` - } +func (bnc *binance) getMarkets(ctx context.Context) (map[string]*bntypes.Market, error) { + var exchangeInfo bntypes.ExchangeInfo err := bnc.getAPI(ctx, "/api/v3/exchangeInfo", nil, false, false, &exchangeInfo) if err != nil { return nil, fmt.Errorf("error getting markets from Binance: %w", err) } - marketsMap := make(map[string]*binanceMarket, len(exchangeInfo.Symbols)) + marketsMap := make(map[string]*bntypes.Market, len(exchangeInfo.Symbols)) for _, market := range exchangeInfo.Symbols { marketsMap[market.Symbol] = market } @@ -693,7 +748,7 @@ func (bnc *binance) Trade(ctx context.Context, baseID, quoteID uint32, sell bool slug := baseCfg.coin + quoteCfg.coin - marketsMap := bnc.markets.Load().(map[string]*binanceMarket) + marketsMap := bnc.markets.Load().(map[string]*bntypes.Market) market, found := marketsMap[slug] if !found { return nil, fmt.Errorf("market not found: %v", slug) @@ -737,15 +792,7 @@ func (bnc *binance) Trade(ctx context.Context, baseID, quoteID uint32, sell bool } }() - var orderResponse struct { - Symbol string `json:"symbol"` - Price float64 `json:"price,string"` - OrigQty float64 `json:"origQty,string"` - OrigQuoteQty float64 `json:"origQuoteOrderQty,string"` - ExecutedQty float64 `json:"executedQty,string"` - CumulativeQuoteQty float64 `json:"cummulativeQuoteQty,string"` - Status string `json:"status"` - } + var orderResponse bntypes.OrderResponse err = bnc.postAPI(ctx, "/api/v3/order", v, nil, true, true, &orderResponse) if err != nil { return nil, err @@ -767,7 +814,7 @@ func (bnc *binance) Trade(ctx context.Context, baseID, quoteID uint32, sell bool } func (bnc *binance) assetPrecision(coin string) (int, error) { - for _, market := range bnc.markets.Load().(map[string]*binanceMarket) { + for _, market := range bnc.markets.Load().(map[string]*bntypes.Market) { if market.BaseAsset == coin { return market.BaseAssetPrecision, nil } @@ -885,33 +932,37 @@ func (bnc *binance) GetDepositAddress(ctx context.Context, assetID uint32) (stri // ConfirmDeposit is an async function that calls onConfirm when the status of // a deposit has been confirmed. -func (bnc *binance) ConfirmDeposit(ctx context.Context, txID string, onConfirm func(uint64)) { - const ( - pendingStatus = 0 - successStatus = 1 - creditedStatus = 6 - wrongDepositStatus = 7 - waitingUserConfirmStatus = 8 - ) - +func (bnc *binance) ConfirmDeposit(ctx context.Context, deposit *DepositData, onConfirm func(uint64)) { checkDepositStatus := func() (done bool, amt uint64) { - var resp []struct { - Amount float64 `json:"amount,string"` - Coin string `json:"coin"` - Network string `json:"network"` - Status int `json:"status"` - TxID string `json:"txId"` + var resp []*bntypes.PendingDeposit + // We'll add info for the fake server. + var query url.Values + if bnc.accountsURL == fakeBinanceURL { + bncAsset, err := bncAssetCfg(deposit.AssetID) + if err != nil { + bnc.log.Errorf("Error getting asset cfg for %d: %v", deposit.AssetID, err) + return + } + + query = url.Values{ + "txid": []string{deposit.TxID}, + "amt": []string{strconv.FormatFloat(deposit.AmountConventional, 'f', 9, 64)}, + "coin": []string{bncAsset.coin}, + "network": []string{bncAsset.chain}, + } } - err := bnc.getAPI(ctx, "/sapi/v1/capital/deposit/hisrec", nil, true, true, &resp) + // TODO: Use the "startTime" parameter to apply a reasonable limit to + // this request. + err := bnc.getAPI(ctx, "/sapi/v1/capital/deposit/hisrec", query, true, true, &resp) if err != nil { bnc.log.Errorf("error getting deposit status: %v", err) return false, 0 } for _, status := range resp { - if status.TxID == txID { + if status.TxID == deposit.TxID { switch status.Status { - case successStatus, creditedStatus: + case bntypes.DepositStatusSuccess, bntypes.DepositStatusCredited: dexSymbol := binanceCoinNetworkToDexSymbol(status.Coin, status.Network) assetID, found := dex.BipSymbolID(dexSymbol) if !found { @@ -925,13 +976,13 @@ func (bnc *binance) ConfirmDeposit(ctx context.Context, txID string, onConfirm f } amount := uint64(status.Amount * float64(ui.Conventional.ConversionFactor)) return true, amount - case pendingStatus: + case bntypes.DepositStatusPending: return false, 0 - case waitingUserConfirmStatus: + case bntypes.DepositStatusWaitingUserConfirm: // This shouldn't ever happen. bnc.log.Errorf("Deposit %s to binance requires user confirmation!") return true, 0 - case wrongDepositStatus: + case bntypes.DepositStatusWrongDeposit: return true, 0 default: bnc.log.Errorf("Deposit %s to binance has an unknown status %d", status.Status) @@ -1031,7 +1082,7 @@ func (bnc *binance) Balances() (map[uint32]*ExchangeBalance, error) { } func (bnc *binance) Markets(ctx context.Context) (_ []*Market, err error) { - bnMarkets := bnc.markets.Load().(map[string]*binanceMarket) + bnMarkets := bnc.markets.Load().(map[string]*bntypes.Market) if len(bnMarkets) == 0 { bnMarkets, err = bnc.getMarkets(ctx) if err != nil { @@ -1064,7 +1115,7 @@ func (bnc *binance) postAPI(ctx context.Context, endpoint string, query, form ur } func (bnc *binance) requestInto(req *http.Request, thing interface{}) error { - bnc.log.Tracef("Sending request: %+v", req) + // bnc.log.Tracef("Sending request: %+v", req) resp, err := http.DefaultClient.Do(req) if err != nil { @@ -1094,10 +1145,10 @@ func (bnc *binance) requestInto(req *http.Request, thing interface{}) error { func (bnc *binance) generateRequest(ctx context.Context, method, endpoint string, query, form url.Values, key, sign bool) (*http.Request, error) { var fullURL string - if (bnc.net == dex.Simnet || bnc.net == dex.Testnet) && strings.Contains(endpoint, "sapi") { - fullURL = fakeBinanceURL + endpoint + if strings.Contains(endpoint, "sapi") { + fullURL = bnc.accountsURL + endpoint } else { - fullURL = bnc.url + endpoint + fullURL = bnc.marketsURL + endpoint } if query == nil { @@ -1154,7 +1205,7 @@ func (bnc *binance) sendCexUpdateNotes() { } } -func (bnc *binance) handleOutboundAccountPosition(update *binanceStreamUpdate) { +func (bnc *binance) handleOutboundAccountPosition(update *bntypes.StreamUpdate) { bnc.log.Debugf("Received outboundAccountPosition: %+v", update) for _, bal := range update.Balances { bnc.log.Debugf("outboundAccountPosition balance: %+v", bal) @@ -1162,7 +1213,7 @@ func (bnc *binance) handleOutboundAccountPosition(update *binanceStreamUpdate) { supportedTokens := bnc.tokenIDs.Load().(map[string][]uint32) - processSymbol := func(symbol string, bal *binanceWSBalance) { + processSymbol := func(symbol string, bal *bntypes.WSBalance) { for _, assetID := range getDEXAssetIDs(symbol, supportedTokens) { bnc.balances[assetID] = &bncBalance{ available: bal.Free, @@ -1206,7 +1257,7 @@ func (bnc *binance) removeTradeUpdater(tradeID string) { delete(bnc.tradeInfo, tradeID) } -func (bnc *binance) handleExecutionReport(update *binanceStreamUpdate) { +func (bnc *binance) handleExecutionReport(update *bntypes.StreamUpdate) { bnc.log.Debugf("Received executionReport: %+v", update) status := update.CurrentOrderStatus @@ -1257,7 +1308,7 @@ func (bnc *binance) handleExecutionReport(update *binanceStreamUpdate) { func (bnc *binance) handleUserDataStreamUpdate(b []byte) { bnc.log.Tracef("Received user data stream update: %s", string(b)) - var msg *binanceStreamUpdate + var msg *bntypes.StreamUpdate if err := json.Unmarshal(b, &msg); err != nil { bnc.log.Errorf("Error unmarshaling user data stream update: %v\nRaw message: %s", err, string(b)) return @@ -1272,9 +1323,7 @@ func (bnc *binance) handleUserDataStreamUpdate(b []byte) { } func (bnc *binance) getListenID(ctx context.Context) (string, error) { - var resp struct { - ListenKey string `json:"listenKey"` - } + var resp *bntypes.DataStreamKey return resp.ListenKey, bnc.postAPI(ctx, "/api/v3/userDataStream", nil, nil, true, false, &resp) } @@ -1287,8 +1336,6 @@ func (bnc *binance) getUserDataStream(ctx context.Context) (err error) { return nil, err } - hdr := make(http.Header) - hdr.Set("X-MBX-APIKEY", bnc.apiKey) conn, err := comms.NewWsConn(&comms.WsCfg{ URL: bnc.wsURL + "/ws/" + listenKey, PingWait: time.Minute * 4, @@ -1297,10 +1344,10 @@ func (bnc *binance) getUserDataStream(ctx context.Context) (err error) { }, Logger: bnc.log.SubLogger("BNCWS"), RawHandler: bnc.handleUserDataStreamUpdate, - ConnectHeaders: hdr, + ConnectHeaders: http.Header{"X-MBX-APIKEY": []string{bnc.apiKey}}, }) if err != nil { - return nil, err + return nil, fmt.Errorf("NewWsConn error: %w", err) } cm := dex.NewConnectionMaster(conn) @@ -1385,11 +1432,7 @@ func (bnc *binance) subUnsubDepth(subscribe bool, mktStreamID string) error { method = "UNSUBSCRIBE" } - req := &struct { - Method string `json:"method"` - Params []string `json:"params"` - ID uint64 `json:"id"` - }{ + req := &bntypes.StreamSubscription{ Method: method, Params: []string{mktStreamID}, ID: atomic.AddUint64(&subscribeID, 1), @@ -1409,7 +1452,7 @@ func (bnc *binance) subUnsubDepth(subscribe bool, mktStreamID string) error { } func (bnc *binance) handleMarketDataNote(b []byte) { - var note *binanceBookNote + var note *bntypes.BookNote if err := json.Unmarshal(b, ¬e); err != nil { bnc.log.Errorf("Error unmarshaling book note: %v", err) return @@ -1438,12 +1481,12 @@ func (bnc *binance) handleMarketDataNote(b []byte) { book.updateQueue <- note.Data } -func (bnc *binance) getOrderbookSnapshot(ctx context.Context, mktSymbol string) (*binanceOrderbookSnapshot, error) { +func (bnc *binance) getOrderbookSnapshot(ctx context.Context, mktSymbol string) (*bntypes.OrderbookSnapshot, error) { v := make(url.Values) v.Add("symbol", strings.ToUpper(mktSymbol)) v.Add("limit", "1000") - resp := &binanceOrderbookSnapshot{} - return resp, bnc.getAPI(ctx, "/api/v3/depth", v, false, false, resp) + var resp bntypes.OrderbookSnapshot + return &resp, bnc.getAPI(ctx, "/api/v3/depth", v, false, false, &resp) } // subscribeToAdditionalMarketDataStream is called when a new market is @@ -1469,16 +1512,16 @@ func (bnc *binance) subscribeToAdditionalMarketDataStream(ctx context.Context, b return nil } - bnc.books[mktID] = newBinanceOrderBook(baseCfg.conversionFactor, quoteCfg.conversionFactor, mktID, bnc.log) - book = bnc.books[mktID] - if err := bnc.subUnsubDepth(true, streamID); err != nil { return fmt.Errorf("error subscribing to %s: %v", streamID, err) } - go book.sync(ctx, func() (*binanceOrderbookSnapshot, error) { + getSnapshot := func() (*bntypes.OrderbookSnapshot, error) { return bnc.getOrderbookSnapshot(ctx, mktID) - }) + } + book = newBinanceOrderBook(baseCfg.conversionFactor, quoteCfg.conversionFactor, mktID, getSnapshot, bnc.log) + bnc.books[mktID] = book + book.sync(ctx) return nil } @@ -1533,7 +1576,10 @@ func (bnc *binance) connectToMarketDataStream(ctx context.Context, baseID, quote } mktID := binanceMktID(baseCfg, quoteCfg) bnc.booksMtx.Lock() - book := newBinanceOrderBook(baseCfg.conversionFactor, quoteCfg.conversionFactor, mktID, bnc.log) + getSnapshot := func() (*bntypes.OrderbookSnapshot, error) { + return bnc.getOrderbookSnapshot(ctx, mktID) + } + book := newBinanceOrderBook(baseCfg.conversionFactor, quoteCfg.conversionFactor, mktID, getSnapshot, bnc.log) bnc.books[mktID] = book bnc.booksMtx.Unlock() @@ -1545,9 +1591,7 @@ func (bnc *binance) connectToMarketDataStream(ctx context.Context, baseID, quote bnc.marketStream = conn - go book.sync(ctx, func() (*binanceOrderbookSnapshot, error) { - return bnc.getOrderbookSnapshot(ctx, mktID) - }) + book.sync(ctx) // Start a goroutine to reconnect every 12 hours go func() { @@ -1613,11 +1657,16 @@ func (bnc *binance) UnsubscribeMarket(baseID, quoteID uint32) (err error) { } var unsubscribe bool + var closer *dex.ConnectionMaster bnc.booksMtx.Lock() defer func() { bnc.booksMtx.Unlock() + if closer != nil { + closer.Disconnect() + } + if unsubscribe { if err := bnc.subUnsubDepth(false, streamID); err != nil { bnc.log.Errorf("error unsubscribing from market data stream", err) @@ -1632,12 +1681,13 @@ func (bnc *binance) UnsubscribeMarket(baseID, quoteID uint32) (err error) { } book.mtx.Lock() - defer book.mtx.Unlock() book.numSubscribers-- if book.numSubscribers == 0 { unsubscribe = true delete(bnc.books, mktID) + closer = book.cm } + book.mtx.Unlock() return nil } @@ -1691,19 +1741,7 @@ func (bnc *binance) MidGap(baseID, quoteID uint32) uint64 { return book.midGap() } -func (bnc *binance) TradeStatus(ctx context.Context, id string, baseID, quoteID uint32) { - var resp struct { - Symbol string `json:"symbol"` - OrderID int64 `json:"orderId"` - ClientOrderID string `json:"clientOrderId"` - Price string `json:"price"` - OrigQty string `json:"origQty"` - ExecutedQty string `json:"executedQty"` - CumulativeQuoteQty string `json:"cumulativeQuoteQty"` - Status string `json:"status"` - TimeInForce string `json:"timeInForce"` - } - +func (bnc *binance) TradeStatus(ctx context.Context, tradeID string, baseID, quoteID uint32) { baseAsset, err := bncAssetCfg(baseID) if err != nil { bnc.log.Errorf("Error getting asset cfg for %d: %v", baseID, err) @@ -1718,8 +1756,9 @@ func (bnc *binance) TradeStatus(ctx context.Context, id string, baseID, quoteID v := make(url.Values) v.Add("symbol", baseAsset.coin+quoteAsset.coin) - v.Add("origClientOrderId", id) + v.Add("origClientOrderId", tradeID) + var resp bntypes.BookedOrder err = bnc.getAPI(ctx, "/api/v3/order", v, true, true, &resp) if err != nil { bnc.log.Errorf("Error getting trade status: %v", err) diff --git a/client/mm/libxc/binance_live_test.go b/client/mm/libxc/binance_live_test.go index 202d0916e7..a64d1be70d 100644 --- a/client/mm/libxc/binance_live_test.go +++ b/client/mm/libxc/binance_live_test.go @@ -14,6 +14,7 @@ import ( "decred.org/dcrdex/client/asset" _ "decred.org/dcrdex/client/asset/importall" + "decred.org/dcrdex/client/mm/libxc/bntypes" "decred.org/dcrdex/dex" ) @@ -237,6 +238,23 @@ func TestVWAP(t *testing.T) { } } +func TestSubscribeMarket(t *testing.T) { + bnc := tNewBinance(t, dex.Testnet) + ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) + defer cancel() + wg, err := bnc.Connect(ctx) + if err != nil { + t.Fatalf("Connect error: %v", err) + } + + err = bnc.SubscribeMarket(ctx, 60, 0) + if err != nil { + t.Fatalf("failed to subscribe to market: %v", err) + } + + wg.Wait() +} + func TestWithdrawal(t *testing.T) { bnc := tNewBinance(t, dex.Mainnet) ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) @@ -249,12 +267,12 @@ func TestWithdrawal(t *testing.T) { wg := sync.WaitGroup{} wg.Add(1) - onComplete := func(amt uint64, txID string) { - t.Logf("withdrawal complete: %v, %v", amt, txID) + onComplete := func(txID string) { + t.Logf("withdrawal complete: %v", txID) wg.Done() } - err = bnc.Withdraw(ctx, 966, 2e10, "", onComplete) + _, err = bnc.Withdraw(ctx, 966, 2e10, "", onComplete) if err != nil { fmt.Printf("withdrawal error: %v", err) return @@ -275,12 +293,14 @@ func TestConfirmDeposit(t *testing.T) { wg := sync.WaitGroup{} wg.Add(1) - onComplete := func(success bool, amount uint64) { - t.Logf("deposit complete: %v, %v", success, amount) + onComplete := func(amount uint64) { + t.Logf("deposit complete: %v", amount) wg.Done() } - bnc.ConfirmDeposit(ctx, "", onComplete) + bnc.ConfirmDeposit(ctx, &DepositData{ + TxID: "", + }, onComplete) wg.Wait() } @@ -326,7 +346,7 @@ func TestGetCoinInfo(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) defer cancel() - coins := make([]*binanceCoinInfo, 0) + coins := make([]*bntypes.CoinInfo, 0) err := bnc.getAPI(ctx, "/sapi/v1/capital/config/getall", nil, true, true, &coins) if err != nil { t.Fatalf("error getting binance coin info: %v", err) diff --git a/client/mm/libxc/binance_types.go b/client/mm/libxc/binance_types.go deleted file mode 100644 index 07f5dbf547..0000000000 --- a/client/mm/libxc/binance_types.go +++ /dev/null @@ -1,94 +0,0 @@ -// This code is available on the terms of the project LICENSE.md file, -// also available online at https://blueoakcouncil.org/license/1.0.0. - -package libxc - -import "encoding/json" - -type binanceMarket struct { - Symbol string `json:"symbol"` - Status string `json:"status"` - BaseAsset string `json:"baseAsset"` - BaseAssetPrecision int `json:"baseAssetPrecision"` - QuoteAsset string `json:"quoteAsset"` - QuoteAssetPrecision int `json:"quoteAssetPrecision"` - OrderTypes []string `json:"orderTypes"` -} - -type binanceNetworkInfo struct { - AddressRegex string `json:"addressRegex"` - Coin string `json:"coin"` - DepositEnable bool `json:"depositEnable"` - IsDefault bool `json:"isDefault"` - MemoRegex string `json:"memoRegex"` - MinConfirm int `json:"minConfirm"` - Name string `json:"name"` - Network string `json:"network"` - ResetAddressStatus bool `json:"resetAddressStatus"` - SpecialTips string `json:"specialTips"` - UnLockConfirm int `json:"unLockConfirm"` - WithdrawEnable bool `json:"withdrawEnable"` - WithdrawFee float64 `json:"withdrawFee,string"` - WithdrawIntegerMultiple float64 `json:"withdrawIntegerMultiple,string"` - WithdrawMax float64 `json:"withdrawMax,string"` - WithdrawMin float64 `json:"withdrawMin,string"` - SameAddress bool `json:"sameAddress"` - EstimatedArrivalTime int `json:"estimatedArrivalTime"` - Busy bool `json:"busy"` -} - -type binanceCoinInfo struct { - Coin string `json:"coin"` - DepositAllEnable bool `json:"depositAllEnable"` - Free float64 `json:"free,string"` - Freeze float64 `json:"freeze,string"` - Ipoable float64 `json:"ipoable,string"` - Ipoing float64 `json:"ipoing,string"` - IsLegalMoney bool `json:"isLegalMoney"` - Locked float64 `json:"locked,string"` - Name string `json:"name"` - Storage float64 `json:"storage,string"` - Trading bool `json:"trading"` - WithdrawAllEnable bool `json:"withdrawAllEnable"` - Withdrawing float64 `json:"withdrawing,string"` - NetworkList []*binanceNetworkInfo `json:"networkList"` -} - -type binanceOrderbookSnapshot struct { - LastUpdateID uint64 `json:"lastUpdateId"` - Bids [][2]json.Number `json:"bids"` - Asks [][2]json.Number `json:"asks"` -} - -type binanceBookUpdate struct { - FirstUpdateID uint64 `json:"U"` - LastUpdateID uint64 `json:"u"` - Bids [][2]json.Number `json:"b"` - Asks [][2]json.Number `json:"a"` -} - -type binanceBookNote struct { - StreamName string `json:"stream"` - Data *binanceBookUpdate `json:"data"` -} - -type binanceWSBalance struct { - Asset string `json:"a"` - Free float64 `json:"f,string"` - Locked float64 `json:"l,string"` -} - -type binanceStreamUpdate struct { - Asset string `json:"a"` - EventType string `json:"e"` - ClientOrderID string `json:"c"` - CurrentOrderStatus string `json:"X"` - Balances []*binanceWSBalance `json:"B"` - BalanceDelta float64 `json:"d,string"` - Filled float64 `json:"z,string"` - QuoteFilled float64 `json:"Z,string"` - OrderQty float64 `json:"q,string"` - QuoteOrderQty float64 `json:"Q,string"` - CancelledOrderID string `json:"C"` - E json.RawMessage `json:"E"` -} diff --git a/client/mm/libxc/bntypes/types.go b/client/mm/libxc/bntypes/types.go new file mode 100644 index 0000000000..d7bc2a7aa1 --- /dev/null +++ b/client/mm/libxc/bntypes/types.go @@ -0,0 +1,163 @@ +package bntypes + +import "encoding/json" + +type Market struct { + Symbol string `json:"symbol"` + Status string `json:"status"` + BaseAsset string `json:"baseAsset"` + BaseAssetPrecision int `json:"baseAssetPrecision"` + QuoteAsset string `json:"quoteAsset"` + QuoteAssetPrecision int `json:"quoteAssetPrecision"` + OrderTypes []string `json:"orderTypes"` +} + +type Balance struct { + Asset string `json:"asset"` + Free float64 `json:"free,string"` + Locked float64 `json:"locked,string"` +} + +type Account struct { + Balances []*Balance `json:"balances"` +} + +type NetworkInfo struct { + // AddressRegex string `json:"addressRegex"` + Coin string `json:"coin"` + DepositEnable bool `json:"depositEnable"` + // IsDefault bool `json:"isDefault"` + // MemoRegex string `json:"memoRegex"` + // MinConfirm int `json:"minConfirm"` + // Name string `json:"name"` + Network string `json:"network"` + // ResetAddressStatus bool `json:"resetAddressStatus"` + // SpecialTips string `json:"specialTips"` + // UnLockConfirm int `json:"unLockConfirm"` + WithdrawEnable bool `json:"withdrawEnable"` + WithdrawFee float64 `json:"withdrawFee,string"` + // WithdrawIntegerMultiple float64 `json:"withdrawIntegerMultiple,string"` + // WithdrawMax float64 `json:"withdrawMax,string"` + // WithdrawMin float64 `json:"withdrawMin,string"` + // SameAddress bool `json:"sameAddress"` + // EstimatedArrivalTime int `json:"estimatedArrivalTime"` + // Busy bool `json:"busy"` +} + +type CoinInfo struct { + Coin string `json:"coin"` + // DepositAllEnable bool `json:"depositAllEnable"` + // Free float64 `json:"free,string"` + // Freeze float64 `json:"freeze,string"` + // Ipoable float64 `json:"ipoable,string"` + // Ipoing float64 `json:"ipoing,string"` + // IsLegalMoney bool `json:"isLegalMoney"` + // Locked float64 `json:"locked,string"` + // Name string `json:"name"` + // Storage float64 `json:"storage,string"` + // Trading bool `json:"trading"` + // WithdrawAllEnable bool `json:"withdrawAllEnable"` + // Withdrawing float64 `json:"withdrawing,string"` + NetworkList []*NetworkInfo `json:"networkList"` +} + +type OrderbookSnapshot struct { + LastUpdateID uint64 `json:"lastUpdateId"` + Bids [][2]json.Number `json:"bids"` + Asks [][2]json.Number `json:"asks"` +} + +type BookUpdate struct { + FirstUpdateID uint64 `json:"U"` + LastUpdateID uint64 `json:"u"` + Bids [][2]json.Number `json:"b"` + Asks [][2]json.Number `json:"a"` +} + +type BookNote struct { + StreamName string `json:"stream"` + Data *BookUpdate `json:"data"` +} + +type WSBalance struct { + Asset string `json:"a"` + Free float64 `json:"f,string"` + Locked float64 `json:"l,string"` +} + +type StreamUpdate struct { + Asset string `json:"a"` + EventType string `json:"e"` + ClientOrderID string `json:"c"` + CurrentOrderStatus string `json:"X"` + Balances []*WSBalance `json:"B"` + BalanceDelta float64 `json:"d,string"` + Filled float64 `json:"z,string"` + QuoteFilled float64 `json:"Z,string"` + OrderQty float64 `json:"q,string"` + QuoteOrderQty float64 `json:"Q,string"` + CancelledOrderID string `json:"C"` + E json.RawMessage `json:"E"` +} + +type RateLimit struct { + RateLimitType string `json:"rateLimitType"` + Interval string `json:"interval"` + IntervalNum int64 `json:"intervalNum"` + Limit int64 `json:"limit"` +} + +type DataStreamKey struct { + ListenKey string `json:"listenKey"` +} + +type ExchangeInfo struct { + Timezone string `json:"timezone"` + ServerTime int64 `json:"serverTime"` + RateLimits []*RateLimit `json:"rateLimits"` + Symbols []*Market `json:"symbols"` +} + +type StreamSubscription struct { + Method string `json:"method"` + Params []string `json:"params"` + ID uint64 `json:"id"` +} + +type PendingDeposit struct { + Amount float64 `json:"amount,string"` + Coin string `json:"coin"` + Network string `json:"network"` + Status int `json:"status"` + TxID string `json:"txId"` +} + +const ( + DepositStatusPending = 0 + DepositStatusSuccess = 1 + DepositStatusCredited = 6 + DepositStatusWrongDeposit = 7 + DepositStatusWaitingUserConfirm = 8 +) + +type OrderResponse struct { + Symbol string `json:"symbol"` + Price float64 `json:"price,string"` + OrigQty float64 `json:"origQty,string"` + OrigQuoteQty float64 `json:"origQuoteOrderQty,string"` + ExecutedQty float64 `json:"executedQty,string"` + CumulativeQuoteQty float64 `json:"cummulativeQuoteQty,string"` + Status string `json:"status"` +} + +type BookedOrder struct { + Symbol string `json:"symbol"` + OrderID int64 `json:"orderId"` + ClientOrderID string `json:"clientOrderId"` + Price string `json:"price"` + OrigQty string `json:"origQty"` + ExecutedQty string `json:"executedQty"` + CumulativeQuoteQty string `json:"cumulativeQuoteQty"` + Status string `json:"status"` + TimeInForce string `json:"timeInForce"` +} diff --git a/client/mm/libxc/interface.go b/client/mm/libxc/interface.go index e35e0a8a14..d526da3a3e 100644 --- a/client/mm/libxc/interface.go +++ b/client/mm/libxc/interface.go @@ -33,6 +33,13 @@ type Market struct { QuoteID uint32 `json:"quoteID"` } +type DepositData struct { + AssetID uint32 + Amount uint64 + AmountConventional float64 + TxID string +} + // CEX implements a set of functions that can be used to interact with a // centralized exchange's spot trading API. All rates and quantities // when interacting with the CEX interface will adhere to the standard @@ -71,7 +78,7 @@ type CEX interface { GetDepositAddress(ctx context.Context, assetID uint32) (string, error) // ConfirmDeposit is an async function that calls onConfirm when the status // of a deposit has been confirmed. - ConfirmDeposit(ctx context.Context, txID string, onConfirm func(amount uint64)) + ConfirmDeposit(ctx context.Context, deposit *DepositData, onConfirm func(amount uint64)) // Withdraw withdraws funds from the CEX to a certain address. onComplete // is called with the actual amount withdrawn (amt - fees) and the // transaction ID of the withdrawal. diff --git a/client/mm/mm.go b/client/mm/mm.go index e625b6ba31..14e249f998 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -19,13 +19,15 @@ import ( "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/msgjson" ) // clientCore is satisfied by core.Core. type clientCore interface { NotificationFeed() *core.NoteFeed - ExchangeMarket(host string, base, quote uint32) (*core.Market, error) - SyncBook(host string, base, quote uint32) (*orderbook.OrderBook, core.BookFeed, error) + ExchangeMarket(host string, baseID, quoteID uint32) (*core.Market, error) + MarketConfig(host string, baseID, quoteID uint32) (*msgjson.Market, error) + SyncBook(host string, baseID, quoteID uint32) (*orderbook.OrderBook, core.BookFeed, error) SupportedAssets() map[uint32]*core.SupportedAsset SingleLotFees(form *core.SingleLotFeesForm) (uint64, uint64, uint64, error) Cancel(oidB dex.Bytes) error @@ -856,7 +858,7 @@ func (m *MarketMaker) Start(pw []byte, alternateConfigPath *string) (err error) mkt := MarketWithHost{cfg.Host, cfg.BaseID, cfg.QuoteID} mktID := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) - logger := m.log.SubLogger(fmt.Sprintf("MarketMaker-%s", mktID)) + logger := m.log.SubLogger(fmt.Sprintf("MM-%s", mktID)) exchangeAdaptor := unifiedExchangeAdaptorForBot(&exchangeAdaptorCfg{ botID: mktID, market: &mkt, @@ -891,7 +893,7 @@ func (m *MarketMaker) Start(pw []byte, alternateConfigPath *string) (err error) defer m.log.Infof("Simple arbitrage bot for %s-%d-%d stopped", cfg.Host, cfg.BaseID, cfg.QuoteID) mktID := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) - logger := m.log.SubLogger(fmt.Sprintf("SimpleArbitrage-%s", mktID)) + logger := m.log.SubLogger(fmt.Sprintf("ARB-%s", mktID)) cex, found := cexes[cfg.CEXCfg.Name] if !found { @@ -934,7 +936,7 @@ func (m *MarketMaker) Start(pw []byte, alternateConfigPath *string) (err error) defer m.log.Infof("ArbMarketMaker for %s-%d-%d stopped", cfg.Host, cfg.BaseID, cfg.QuoteID) mktID := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) - logger := m.log.SubLogger(fmt.Sprintf("ArbMarketMaker-%s", mktID)) + logger := m.log.SubLogger(fmt.Sprintf("AMM-%s", mktID)) cex, found := cexes[cfg.CEXCfg.Name] if !found { diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index df003b17e5..3dc8561d46 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -10,6 +10,7 @@ import ( "sync" "sync/atomic" + "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/dex" @@ -188,7 +189,7 @@ func (a *arbMarketMaker) cancelExpiredCEXTrades() { // the DEX order book based on the rate of the counter trade on the CEX. The // rate is calculated so that the difference in rates between the DEX and the // CEX will pay for the network fees and still leave the configured profit. -func dexPlacementRate(cexRate uint64, sell bool, profitRate float64, mkt *core.Market, feesInQuoteUnits uint64) (uint64, error) { +func dexPlacementRate(cexRate uint64, sell bool, profitRate float64, mkt *core.Market, feesInQuoteUnits uint64, log dex.Logger) (uint64, error) { var unadjustedRate uint64 if sell { unadjustedRate = uint64(math.Round(float64(cexRate) * (1 + profitRate))) @@ -198,6 +199,20 @@ func dexPlacementRate(cexRate uint64, sell bool, profitRate float64, mkt *core.M rateAdj := rateAdjustment(feesInQuoteUnits, mkt.LotSize) + if log.Level() <= dex.LevelTrace { + if qui, err := asset.UnitInfo(mkt.QuoteID); err != nil { + log.Errorf("no unit info for placement rate logging quote asset %d", mkt.QuoteID) + } else { + cexRateConv := mkt.MsgRateToConventional(cexRate) + rateAdjConv := mkt.MsgRateToConventional(rateAdj) + feesConv := qui.ConventionalString(feesInQuoteUnits) + + log.Tracef("%s sell = %t placement rate: cexRate = %.8f, profitRate = %.3f, rateAdj = %.8f, fees = %s %s", + mkt.Name, sell, cexRateConv, profitRate, rateAdjConv, feesConv, qui.Conventional.Unit, + ) + } + } + if sell { return steppedRate(unadjustedRate+rateAdj, mkt.RateStep), nil } @@ -223,14 +238,14 @@ func (a *arbMarketMaker) dexPlacementRate(cexRate uint64, sell bool) (uint64, er return 0, fmt.Errorf("error getting fees in quote units: %w", err) } - return dexPlacementRate(cexRate, sell, a.cfg.Profit, a.mkt, feesInQuoteUnits) + return dexPlacementRate(cexRate, sell, a.cfg.Profit, a.mkt, feesInQuoteUnits, a.log) } func (a *arbMarketMaker) ordersToPlace() (buys, sells []*multiTradePlacement) { orders := func(cfgPlacements []*ArbMarketMakingPlacement, sellOnDEX bool) []*multiTradePlacement { newPlacements := make([]*multiTradePlacement, 0, len(cfgPlacements)) var cumulativeCEXDepth uint64 - for _, cfgPlacement := range cfgPlacements { + for i, cfgPlacement := range cfgPlacements { cumulativeCEXDepth += uint64(float64(cfgPlacement.Lots*a.mkt.LotSize) * cfgPlacement.Multiplier) _, extrema, filled, err := a.cex.VWAP(a.mkt.BaseID, a.mkt.QuoteID, !sellOnDEX, cumulativeCEXDepth) if err != nil { @@ -242,6 +257,10 @@ func (a *arbMarketMaker) ordersToPlace() (buys, sells []*multiTradePlacement) { continue } + a.log.Tracef("%s placement orders: sellOnDex = %t placement # %d, lots = %d, extrema = %.8f, filled = %t", + a.mkt.Name, sellOnDEX, i, cfgPlacement.Lots, a.mkt.MsgRateToConventional(extrema), filled, + ) + if !filled { a.log.Infof("CEX %s side has < %d %s on the orderbook.", map[bool]string{true: "sell", false: "buy"}[!sellOnDEX], cumulativeCEXDepth, a.mkt.BaseSymbol) newPlacements = append(newPlacements, &multiTradePlacement{ @@ -441,9 +460,9 @@ func (a *arbMarketMaker) run() { defer wg.Done() for { select { - case n := <-bookFeed.Next(): - if n.Action == core.EpochMatchSummary { - payload := n.Payload.(*core.EpochMatchSummaryPayload) + case ni := <-bookFeed.Next(): + switch payload := ni.Payload.(type) { + case *core.EpochMatchSummaryPayload: a.rebalance(payload.Epoch + 1) } case <-a.ctx.Done(): diff --git a/client/mm/mm_arb_market_maker_test.go b/client/mm/mm_arb_market_maker_test.go index 5b534df1e1..d41cb98a9f 100644 --- a/client/mm/mm_arb_market_maker_test.go +++ b/client/mm/mm_arb_market_maker_test.go @@ -123,7 +123,7 @@ func TestArbMMRebalance(t *testing.T) { if sell { fees = sellFeesInQuoteUnits } - rate, err := dexPlacementRate(counterTradeRate, sell, 0.01, mkt, fees) + rate, err := dexPlacementRate(counterTradeRate, sell, 0.01, mkt, fees, tLogger) if err != nil { panic(err) } @@ -445,7 +445,7 @@ func TestDEXPlacementRate(t *testing.T) { } runTest := func(tt *test) { - sellRate, err := dexPlacementRate(tt.counterTradeRate, true, tt.profit, tt.mkt, tt.fees) + sellRate, err := dexPlacementRate(tt.counterTradeRate, true, tt.profit, tt.mkt, tt.fees, tLogger) if err != nil { t.Fatalf("%s: unexpected error: %v", tt.name, err) } @@ -456,7 +456,7 @@ func TestDEXPlacementRate(t *testing.T) { t.Fatalf("%s: expected additional %d but got %d", tt.name, tt.fees, additional) } - buyRate, err := dexPlacementRate(tt.counterTradeRate, false, tt.profit, tt.mkt, tt.fees) + buyRate, err := dexPlacementRate(tt.counterTradeRate, false, tt.profit, tt.mkt, tt.fees, tLogger) if err != nil { t.Fatalf("%s: unexpected error: %v", tt.name, err) } diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go index 353ad5ecd2..2c5748e715 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -15,6 +15,7 @@ import ( "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/dex/order" _ "decred.org/dcrdex/client/asset/btc" // register btc asset @@ -116,6 +117,16 @@ func (c *tCore) ExchangeMarket(host string, base, quote uint32) (*core.Market, e return c.market, nil } +func (c *tCore) MarketConfig(host string, base, quote uint32) (*msgjson.Market, error) { + return &msgjson.Market{ + Name: c.market.Name, + Base: c.market.BaseID, + Quote: c.market.QuoteID, + LotSize: c.market.LotSize, + RateStep: c.market.RateStep, + }, nil +} + func (t *tCore) SyncBook(host string, base, quote uint32) (*orderbook.OrderBook, core.BookFeed, error) { return t.book, t.bookFeed, nil } @@ -1363,8 +1374,8 @@ func (c *tCEX) Withdraw(ctx context.Context, assetID uint32, qty uint64, address return "", nil } -func (c *tCEX) ConfirmDeposit(ctx context.Context, txID string, onConfirm func(uint64)) { - c.lastConfirmDepositTx = txID +func (c *tCEX) ConfirmDeposit(ctx context.Context, deposit *libxc.DepositData, onConfirm func(uint64)) { + c.lastConfirmDepositTx = deposit.TxID go func() { confirmDepositAmt := <-c.confirmDeposit diff --git a/client/webserver/site/src/js/app.ts b/client/webserver/site/src/js/app.ts index 00ce9d4fe9..10757ee3e8 100644 --- a/client/webserver/site/src/js/app.ts +++ b/client/webserver/site/src/js/app.ts @@ -1181,7 +1181,7 @@ export default class Application { if (!w) return false const traitAccountLocker = 1 << 14 if ((w.traits & traitAccountLocker) === 0) return false - const res = await postJSON('/api/walletsettings', { assetID }) + const res = await postJSON('/api/walletsettings', { baseChainID }) if (!this.checkResponse(res)) { console.error(res.msg) return false diff --git a/client/webserver/site/src/js/dexsettings.ts b/client/webserver/site/src/js/dexsettings.ts index 1ab030f6be..217c78c264 100644 --- a/client/webserver/site/src/js/dexsettings.ts +++ b/client/webserver/site/src/js/dexsettings.ts @@ -215,10 +215,10 @@ export default class DexSettingsPage extends BasePage { } async progressTierFormsWithWallet (assetID: number, wallet: WalletState) { - const { page, host, confirmRegisterForm: { fees } } = this + const { page, confirmRegisterForm: { fees } } = this const asset = app().assets[assetID] - const xc = app().exchanges[host] - const bondAsset = xc.bondAssets[asset.symbol] + const { bondAssets } = this.regAssetForm.xc + const bondAsset = bondAssets[asset.symbol] if (!wallet.open) { if (State.passwordIsCached()) { const loaded = app().loading(page.forms) diff --git a/client/webserver/site/src/js/forms.ts b/client/webserver/site/src/js/forms.ts index bdadf4002b..30b50572ee 100644 --- a/client/webserver/site/src/js/forms.ts +++ b/client/webserver/site/src/js/forms.ts @@ -802,7 +802,7 @@ export class ConfirmRegistrationForm { form: HTMLElement success: () => void page: Record - host: string + xc: Exchange certFile: string bondAssetID: number tier: number @@ -821,7 +821,7 @@ export class ConfirmRegistrationForm { } setExchange (xc: Exchange, certFile: string) { - this.host = xc.host + this.xc = xc this.certFile = certFile const page = this.page if (State.passwordIsCached() || (this.pwCache && this.pwCache.pw)) Doc.hide(page.passBox) @@ -836,8 +836,7 @@ export class ConfirmRegistrationForm { this.tier = tier this.fees = fees const page = this.page - const xc = app().exchanges[this.host] - const bondAsset = xc.bondAssets[asset.symbol] + const bondAsset = this.xc.bondAssets[asset.symbol] const bondLock = bondAsset.amount * tier * bondReserveMultiplier const bondLockConventional = bondLock / conversionFactor page.tradingTier.textContent = String(tier) @@ -874,7 +873,7 @@ export class ConfirmRegistrationForm { * submitForm is called when the form is submitted. */ async submitForm () { - const { page, bondAssetID, host, certFile, pwCache, tier } = this + const { page, bondAssetID, xc, certFile, pwCache, tier } = this const asset = app().assets[bondAssetID] if (!asset) { page.regErr.innerText = intl.prep(intl.ID_SELECT_WALLET_FOR_FEE_PAYMENT) @@ -882,7 +881,6 @@ export class ConfirmRegistrationForm { return } Doc.hide(page.regErr) - const xc = app().exchanges[host] const bondAsset = xc.bondAssets[asset.wallet.symbol] const dexAddr = xc.host const pw = page.appPass.value || (pwCache ? pwCache.pw : '') @@ -935,7 +933,7 @@ interface MarketLimitsRow { export class FeeAssetSelectionForm { form: HTMLElement success: (assetID: number, tier: number) => Promise - host: string + xc: Exchange selectedAssetID: number certFile: string page: Record @@ -1003,7 +1001,7 @@ export class FeeAssetSelectionForm { } setExchange (xc: Exchange, certFile: string) { - this.host = xc.host + this.xc = xc this.certFile = certFile this.assetRows = {} this.marketRows = [] @@ -1101,7 +1099,7 @@ export class FeeAssetSelectionForm { } refresh () { - this.setExchange(app().exchanges[this.host], this.certFile) + this.setExchange(this.xc, this.certFile) } assetSelected (assetID: number) { @@ -1114,10 +1112,10 @@ export class FeeAssetSelectionForm { } setTier () { - const { page, host, selectedAssetID: assetID } = this + const { page, xc: { bondAssets }, selectedAssetID: assetID } = this const { symbol, unitInfo: ui } = app().assets[assetID] const { conventional: { conversionFactor, unit } } = ui - const { bondAssets } = app().exchanges[host] + const bondAsset = bondAssets[symbol] const raw = page.tradingTierInput.value ?? '' if (!raw) return @@ -1224,7 +1222,7 @@ export class FeeAssetSelectionForm { } async submitPrepaidBond () { - const { page, host, pwCache } = this + const { page, xc: { host }, pwCache } = this Doc.hide(page.prepaidBondErr) const code = page.prepaidBondCode.value if (!code) { @@ -1842,6 +1840,7 @@ export class DEXAddressForm { pass: pw } } + const loaded = app().loading(this.form) const res = await postJSON(endpoint, req) loaded() diff --git a/client/webserver/site/src/js/mmsettings.ts b/client/webserver/site/src/js/mmsettings.ts index d5298af8ef..bda1052d21 100644 --- a/client/webserver/site/src/js/mmsettings.ts +++ b/client/webserver/site/src/js/mmsettings.ts @@ -153,7 +153,7 @@ const defaultMarketMakingConfig : ConfigState = { oracleWeighting: 0.1, oracleBias: 0, emptyMarketRate: 0, - profit: 3, + profit: 0.02, orderPersistence: 20, baseBalanceType: BalanceType.Percentage, quoteBalanceType: BalanceType.Percentage, @@ -412,9 +412,7 @@ export default class MarketMakerSettingsPage extends BasePage { async initialize (specs?: BotSpecs) { await this.refreshStatus() - this.setupCEXes() - this.marketRows = [] for (const { host, markets, assets, auth: { effectiveTier, pendingStrength } } of Object.values(app().exchanges)) { if (effectiveTier + pendingStrength === 0) { @@ -1011,8 +1009,8 @@ export default class MarketMakerSettingsPage extends BasePage { qcProfitChanged () { const { page, updatedConfig: cfg } = this - const v = Math.max(qcMinimumProfit, parseFloat(page.qcProfit.value ?? '') || qcDefaultProfitThreshold) - cfg.profit = v + const v = Math.max(qcMinimumProfit, parseFloat(page.qcProfit.value ?? '') || qcDefaultProfitThreshold * 100) + cfg.profit = v / 100 page.qcProfit.value = v.toFixed(2) this.qcProfitSlider.setValue(v / 100, true) this.quickConfigUpdated() @@ -1741,16 +1739,17 @@ export default class MarketMakerSettingsPage extends BasePage { const handleChanged = () => { this.updateModifiedMarkers() } // Profit - page.profitInput.value = String(cfg.profit) + page.profitInput.value = String(cfg.profit * 100) Doc.bind(page.profitInput, 'change', () => { Doc.hide(page.profitInputErr) const showError = (errID: string) => { Doc.show(page.profitInputErr) page.profitInputErr.textContent = intl.prep(errID) } - cfg.profit = parseFloat(page.profitInput.value || '') - if (isNaN(cfg.profit)) return showError(intl.ID_INVALID_VALUE) - if (cfg.profit === 0) return showError(intl.ID_NO_ZERO) + const profit = parseFloat(page.profitInput.value || '') / 100 + if (isNaN(profit)) return showError(intl.ID_INVALID_VALUE) + if (profit === 0) return showError(intl.ID_NO_ZERO) + cfg.profit = profit this.updateModifiedMarkers() }) @@ -1825,8 +1824,8 @@ export default class MarketMakerSettingsPage extends BasePage { // Quick Config this.qcProfitSlider = new XYRangeHandler(profitSliderRange, qcDefaultProfitThreshold, { updated: (x: number /* , y: number */) => { - cfg.profit = x * 100 - page.qcProfit.value = page.profitInput.value = cfg.profit.toFixed(2) + cfg.profit = x + page.qcProfit.value = page.profitInput.value = (cfg.profit * 100).toFixed(2) this.quickConfigUpdated() }, changed: () => { this.quickConfigUpdated() }, @@ -1957,7 +1956,7 @@ export default class MarketMakerSettingsPage extends BasePage { orderPersistence.reset() if (baseBalance) baseBalance.reset() if (quoteBalance) quoteBalance.reset() - page.profitInput.value = String(cfg.profit) + page.profitInput.value = String(cfg.profit * 100) page.useOracleCheckbox.checked = cfg.useOracles && oldCfg.oracleWeighting > 0 this.useOraclesChanged() page.emptyMarketRateCheckbox.checked = cfg.useEmptyMarketRate && cfg.emptyMarketRate > 0 @@ -2892,7 +2891,7 @@ class PlacementsChart extends Chart { render () { const { ctx, canvas, theme, settingsPage } = this if (canvas.width === 0) return - const { page, cfg: { buyPlacements, sellPlacements, profit: profitPercent }, baseFiatRate, botType } = settingsPage.marketStuff() + const { page, cfg: { buyPlacements, sellPlacements, profit }, baseFiatRate, botType } = settingsPage.marketStuff() if (botType === botTypeBasicArb) return this.clear() @@ -2909,7 +2908,6 @@ class PlacementsChart extends Chart { const isBasicMM = botType === botTypeBasicMM const cx = canvas.width / 2 const [cexGapL, cexGapR] = isBasicMM ? [cx, cx] : [0.48 * canvas.width, 0.52 * canvas.width] - const profit = profitPercent / 100 const buyLots = buyPlacements.reduce((v: number, p: OrderPlacement) => v + p.lots, 0) const sellLots = sellPlacements.reduce((v: number, p: OrderPlacement) => v + p.lots, 0) diff --git a/client/webserver/site/src/js/register.ts b/client/webserver/site/src/js/register.ts index bbb6c1b4d2..f851721c90 100644 --- a/client/webserver/site/src/js/register.ts +++ b/client/webserver/site/src/js/register.ts @@ -108,10 +108,9 @@ export default class RegistrationPage extends BasePage { return } const asset = app().assets[assetID] - const xc = app().exchanges[this.host] const wallet = asset.wallet if (wallet) { - const bondAsset = xc.bondAssets[asset.symbol] + const bondAsset = this.regAssetForm.xc.bondAssets[asset.symbol] const bondsFeeBuffer = await this.getBondsFeeBuffer(assetID, page.regAssetForm) this.confirmRegisterForm.setAsset(assetID, tier, bondsFeeBuffer) if (wallet.synced && wallet.balance.available >= 2 * bondAsset.amount + bondsFeeBuffer) { diff --git a/dex/networks/eth/tokens.go b/dex/networks/eth/tokens.go index 16acae4c3d..927236dfb7 100644 --- a/dex/networks/eth/tokens.go +++ b/dex/networks/eth/tokens.go @@ -208,16 +208,19 @@ func MaybeReadSimnetAddrsDir( testUSDCContractAddrFile := filepath.Join(harnessDir, "test_usdc_contract_address.txt") multiBalanceContractAddrFile := filepath.Join(harnessDir, "multibalance_address.txt") - contractsAddrs[0][dex.Simnet] = getContractAddrFromFile(ethSwapContractAddrFile) - multiBalandAddresses[dex.Simnet] = getContractAddrFromFile(multiBalanceContractAddrFile) + contractsAddrs[0][dex.Simnet] = maybeGetContractAddrFromFile(ethSwapContractAddrFile) + multiBalandAddresses[dex.Simnet] = maybeGetContractAddrFromFile(multiBalanceContractAddrFile) - usdcToken.SwapContracts[0].Address = getContractAddrFromFile(testUSDCSwapContractAddrFile) - usdcToken.Address = getContractAddrFromFile(testUSDCContractAddrFile) + usdcToken.SwapContracts[0].Address = maybeGetContractAddrFromFile(testUSDCSwapContractAddrFile) + usdcToken.Address = maybeGetContractAddrFromFile(testUSDCContractAddrFile) } -func getContractAddrFromFile(fileName string) (addr common.Address) { +func maybeGetContractAddrFromFile(fileName string) (addr common.Address) { addrBytes, err := os.ReadFile(fileName) if err != nil { + if os.IsNotExist(err) { + return + } fmt.Printf("error reading contract address: %v \n", err) return } diff --git a/dex/testing/walletpair/walletpair.sh b/dex/testing/walletpair/walletpair.sh index 3dce7b22f7..2e44c3ff93 100755 --- a/dex/testing/walletpair/walletpair.sh +++ b/dex/testing/walletpair/walletpair.sh @@ -133,11 +133,11 @@ tmux new-session -d -s $SESSION $SHELL tmux rename-window -t $SESSION:0 'harness-ctl' tmux new-window -t $SESSION:1 -n 'dexc1' $SHELL -tmux send-keys -t $SESSION:1 "cd ${PAIR_ROOT}/dexc1" C-m +tmux send-keys -t $SESSION:1 "cd ${CLIENT_1_DIR}" C-m tmux send-keys -t $SESSION:1 "${DEXC} --appdata=${CLIENT_1_DIR} ${DEXC_ARGS}" C-m -tmux new-window -t $SESSION:2 -n 'dexc1' $SHELL -tmux send-keys -t $SESSION:2 "cd ${PAIR_ROOT}/dexc1" C-m +tmux new-window -t $SESSION:2 -n 'dexc2' $SHELL +tmux send-keys -t $SESSION:2 "cd ${CLIENT_2_DIR}" C-m tmux send-keys -t $SESSION:2 "${DEXC} --appdata=${CLIENT_2_DIR} ${DEXC_ARGS}" C-m tmux select-window -t $SESSION:0 diff --git a/server/comms/server.go b/server/comms/server.go index c962c5d894..5b566a09d1 100644 --- a/server/comms/server.go +++ b/server/comms/server.go @@ -394,8 +394,6 @@ type onionListener struct{ net.Listener } // Run starts the server. Run should be called only after all routes are // registered. func (s *Server) Run(ctx context.Context) { - log.Trace("Starting server") - mux := s.mux var wg sync.WaitGroup