From 2d2ec5ea6ca2e42ecb6da19d11c72a2d70539c2f Mon Sep 17 00:00:00 2001 From: martonp Date: Mon, 2 Oct 2023 22:25:40 -0400 Subject: [PATCH 1/9] multi: Return TxID from Send and add TransactionConfirmations Send and Withdraw are updated to return a transaction ID. Also, a TransactionConfirmations function is added to the wallet interface which allows callers to determine the amount of confirmations a wallet transaction has. --- client/asset/btc/btc.go | 23 +++++++--- client/asset/btc/btc_test.go | 6 +-- client/asset/btc/livetest/livetest.go | 4 +- client/asset/dcr/dcr.go | 30 +++++++++---- client/asset/dcr/dcr_test.go | 12 +++--- client/asset/dcr/simnet_test.go | 4 +- client/asset/eth/eth.go | 27 +++++++----- client/asset/eth/eth_test.go | 6 +-- client/asset/interface.go | 7 ++- client/asset/zec/zec.go | 12 ++++-- client/core/core.go | 62 ++++++++++++++++----------- client/core/core_test.go | 35 +++++++++------ client/core/locale_ntfn.go | 2 +- client/rpcserver/handlers.go | 6 ++- client/rpcserver/rpcserver.go | 2 +- client/rpcserver/rpcserver_test.go | 4 +- client/webserver/api.go | 6 ++- client/webserver/live_test.go | 4 +- client/webserver/webserver.go | 2 +- client/webserver/webserver_test.go | 4 +- dex/testing/loadbot/mantle.go | 2 +- 21 files changed, 165 insertions(+), 95 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 6109e06abf..010dfe5ec1 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -4209,23 +4209,23 @@ func (btc *baseWallet) EstimateRegistrationTxFee(feeRate uint64) uint64 { // Withdraw withdraws funds to the specified address. Fees are subtracted from // the value. feeRate is in units of sats/byte. // Withdraw satisfies asset.Withdrawer. -func (btc *baseWallet) Withdraw(address string, value, feeRate uint64) (asset.Coin, error) { +func (btc *baseWallet) Withdraw(address string, value, feeRate uint64) (string, asset.Coin, error) { txHash, vout, sent, err := btc.send(address, value, btc.feeRateWithFallback(feeRate), true) if err != nil { - return nil, err + return "", nil, err } - return NewOutput(txHash, vout, sent), nil + return txHash.String(), NewOutput(txHash, vout, sent), nil } // Send sends the exact value to the specified address. This is different from // Withdraw, which subtracts the tx fees from the amount sent. feeRate is in // units of sats/byte. -func (btc *baseWallet) Send(address string, value, feeRate uint64) (asset.Coin, error) { +func (btc *baseWallet) Send(address string, value, feeRate uint64) (string, asset.Coin, error) { txHash, vout, sent, err := btc.send(address, value, btc.feeRateWithFallback(feeRate), false) if err != nil { - return nil, err + return "", nil, err } - return NewOutput(txHash, vout, sent), nil + return txHash.String(), NewOutput(txHash, vout, sent), nil } // SendTransaction broadcasts a valid fully-signed transaction. @@ -4339,6 +4339,17 @@ func (btc *baseWallet) SwapConfirmations(_ context.Context, id dex.Bytes, contra return btc.node.swapConfirmations(txHash, vout, pkScript, startTime) } +// TransactionConfirmations gets the number of confirmations for the specified +// transaction. +func (btc *baseWallet) TransactionConfirmations(ctx context.Context, txID string) (confs uint32, err error) { + txHash, err := chainhash.NewHashFromStr(txID) + if err != nil { + return 0, fmt.Errorf("error decoding txid %q: %w", txID, err) + } + _, confs, err = btc.rawWalletTx(txHash) + return +} + // RegFeeConfirmations gets the number of confirmations for the specified output // by first checking for a unspent output, and if not found, searching indexed // wallet transactions. diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index 2fd90ae1a1..df85978fff 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -3729,11 +3729,11 @@ func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType st wallet, node, shutdown := tNewWallet(segwit, walletType) defer shutdown() const feeSuggestion = 100 - sender := func(addr string, val uint64) (asset.Coin, error) { + sender := func(addr string, val uint64) (string, asset.Coin, error) { return wallet.Send(addr, val, defaultFee) } if senderType == tWithdrawSender { - sender = func(addr string, val uint64) (asset.Coin, error) { + sender = func(addr string, val uint64) (string, asset.Coin, error) { return wallet.Withdraw(addr, val, feeSuggestion) } } @@ -3895,7 +3895,7 @@ func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType st node.listUnspent = test.unspents wallet.bondReserves.Store(test.bondReserves) - _, err := sender(addr.String(), test.val) + _, _, err := sender(addr.String(), test.val) if test.expectErr { if err == nil { t.Fatalf("%s: no error for expected error", test.name) diff --git a/client/asset/btc/livetest/livetest.go b/client/asset/btc/livetest/livetest.go index ba1859afcc..368f1489d9 100644 --- a/client/asset/btc/livetest/livetest.go +++ b/client/asset/btc/livetest/livetest.go @@ -561,7 +561,7 @@ func Run(t *testing.T, cfg *Config) { // Test Send. tLogger.Info("Testing Send") - coin, err := rig.secondWallet.Send(address, cfg.LotSize, defaultFee) + _, coin, err := rig.secondWallet.Send(address, cfg.LotSize, defaultFee) if err != nil { t.Fatalf("error sending: %v", err) } @@ -573,7 +573,7 @@ func Run(t *testing.T, cfg *Config) { // Test Withdraw. withdrawer, _ := rig.secondWallet.Wallet.(asset.Withdrawer) tLogger.Info("Testing Withdraw") - coin, err = withdrawer.Withdraw(address, cfg.LotSize, defaultFee) + _, coin, err = withdrawer.Withdraw(address, cfg.LotSize, defaultFee) if err != nil { t.Fatalf("error withdrawing: %v", err) } diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index d8d08e9905..1c652b1058 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -4271,31 +4271,31 @@ func (dcr *ExchangeWallet) SendTransaction(rawTx []byte) ([]byte, error) { // Withdraw withdraws funds to the specified address. Fees are subtracted from // the value. feeRate is in units of atoms/byte. // Withdraw satisfies asset.Withdrawer. -func (dcr *ExchangeWallet) Withdraw(address string, value, feeRate uint64) (asset.Coin, error) { +func (dcr *ExchangeWallet) Withdraw(address string, value, feeRate uint64) (string, asset.Coin, error) { addr, err := stdaddr.DecodeAddress(address, dcr.chainParams) if err != nil { - return nil, fmt.Errorf("invalid address: %s", address) + return "", nil, fmt.Errorf("invalid address: %s", address) } msgTx, sentVal, err := dcr.withdraw(addr, value, dcr.feeRateWithFallback(feeRate)) if err != nil { - return nil, err + return "", nil, err } - return newOutput(msgTx.CachedTxHash(), 0, sentVal, wire.TxTreeRegular), nil + return msgTx.CachedTxHash().String(), newOutput(msgTx.CachedTxHash(), 0, sentVal, wire.TxTreeRegular), nil } // Send sends the exact value to the specified address. This is different from // Withdraw, which subtracts the tx fees from the amount sent. feeRate is in // units of atoms/byte. -func (dcr *ExchangeWallet) Send(address string, value, feeRate uint64) (asset.Coin, error) { +func (dcr *ExchangeWallet) Send(address string, value, feeRate uint64) (string, asset.Coin, error) { addr, err := stdaddr.DecodeAddress(address, dcr.chainParams) if err != nil { - return nil, fmt.Errorf("invalid address: %s", address) + return "", nil, fmt.Errorf("invalid address: %s", address) } msgTx, sentVal, err := dcr.sendToAddress(addr, value, dcr.feeRateWithFallback(feeRate)) if err != nil { - return nil, err + return "", nil, err } - return newOutput(msgTx.CachedTxHash(), 0, sentVal, wire.TxTreeRegular), nil + return msgTx.CachedTxHash().String(), newOutput(msgTx.CachedTxHash(), 0, sentVal, wire.TxTreeRegular), nil } // ValidateSecret checks that the secret satisfies the contract. @@ -4353,6 +4353,20 @@ func (dcr *ExchangeWallet) SwapConfirmations(ctx context.Context, coinID, contra return confs, spent, err } +// TransactionConfirmations gets the number of confirmations for the specified +// transaction. +func (dcr *ExchangeWallet) TransactionConfirmations(ctx context.Context, txID string) (confs uint32, err error) { + txHash, err := chainhash.NewHashFromStr(txID) + if err != nil { + return 0, fmt.Errorf("error decoding txid %s: %w", txID, err) + } + tx, err := dcr.wallet.GetTransaction(ctx, txHash) + if err != nil { + return 0, err + } + return uint32(tx.Confirmations), nil +} + // RegFeeConfirmations gets the number of confirmations for the specified // output. func (dcr *ExchangeWallet) RegFeeConfirmations(ctx context.Context, coinID dex.Bytes) (confs uint32, err error) { diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 0791d1cef8..84b5138577 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -3365,14 +3365,14 @@ func testSender(t *testing.T, senderType tSenderType) { var unspentVal uint64 = 100e8 const feeSuggestion = 100 funName := "Send" - sender := func(addr string, val uint64) (asset.Coin, error) { + sender := func(addr string, val uint64) (string, asset.Coin, error) { return wallet.Send(addr, val, feeSuggestion) } if senderType == tWithdrawSender { funName = "Withdraw" // For withdraw, test with unspent total = withdraw value unspentVal = sendVal - sender = func(addr string, val uint64) (asset.Coin, error) { + sender = func(addr string, val uint64) (string, asset.Coin, error) { return wallet.Withdraw(addr, val, feeSuggestion) } } @@ -3391,13 +3391,13 @@ func testSender(t *testing.T, senderType tSenderType) { }} //node.unspent = append(node.unspent, node.unspent[0]) - _, err := sender(addr, sendVal) + _, _, err := sender(addr, sendVal) if err != nil { t.Fatalf(funName+" error: %v", err) } // invalid address - _, err = sender("badaddr", sendVal) + _, _, err = sender("badaddr", sendVal) if err == nil { t.Fatalf("no error for bad address: %v", err) } @@ -3405,7 +3405,7 @@ func testSender(t *testing.T, senderType tSenderType) { // GetRawChangeAddress error if senderType == tSendSender { // withdraw test does not get a change address node.changeAddrErr = tErr - _, err = sender(addr, sendVal) + _, _, err = sender(addr, sendVal) if err == nil { t.Fatalf("no error for rawchangeaddress: %v", err) } @@ -3413,7 +3413,7 @@ func testSender(t *testing.T, senderType tSenderType) { } // good again - _, err = sender(addr, sendVal) + _, _, err = sender(addr, sendVal) if err != nil { t.Fatalf(funName+" error afterwards: %v", err) } diff --git a/client/asset/dcr/simnet_test.go b/client/asset/dcr/simnet_test.go index 462c2c6735..f373527e40 100644 --- a/client/asset/dcr/simnet_test.go +++ b/client/asset/dcr/simnet_test.go @@ -571,14 +571,14 @@ func runTest(t *testing.T, splitTx bool) { } // Test Send - coin, err := rig.beta().Send(alphaAddress, 1e8, defaultFee) + _, coin, err := rig.beta().Send(alphaAddress, 1e8, defaultFee) if err != nil { t.Fatalf("error sending fees: %v", err) } tLogger.Infof("fee paid with tx %s", coin.String()) // Test Withdraw - coin, err = rig.beta().Withdraw(alphaAddress, 5e7, tDCR.MaxFeeRate/4) + _, coin, err = rig.beta().Withdraw(alphaAddress, 5e7, tDCR.MaxFeeRate/4) if err != nil { t.Fatalf("error withdrawing: %v", err) } diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index dccdecdb0f..b5da423bb0 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -3144,14 +3144,14 @@ func (w *assetWallet) SwapConfirmations(ctx context.Context, coinID dex.Bytes, c // Send sends the exact value to the specified address. The provided fee rate is // ignored since all sends will use an internally derived fee rate. -func (w *ETHWallet) Send(addr string, value, _ uint64) (asset.Coin, error) { +func (w *ETHWallet) Send(addr string, value, _ uint64) (string, asset.Coin, error) { if err := isValidSend(addr, value, false); err != nil { - return nil, err + return "", nil, err } maxFee, maxFeeRate, err := w.canSend(value, true, false) if err != nil { - return nil, err + return "", nil, err } // TODO: Subtract option. // if avail < value+maxFee { @@ -3160,37 +3160,37 @@ func (w *ETHWallet) Send(addr string, value, _ uint64) (asset.Coin, error) { tx, err := w.sendToAddr(common.HexToAddress(addr), value, maxFeeRate) if err != nil { - return nil, err + return "", nil, err } txHash := tx.Hash() w.addToTxHistory(tx.Nonce(), -int64(value), maxFee, 0, w.assetID, txHash[:], asset.Send) - return &coin{id: txHash, value: value}, nil + return txHash.String(), &coin{id: txHash, value: value}, nil } // Send sends the exact value to the specified address. Fees are taken from the // parent wallet. The provided fee rate is ignored since all sends will use an // internally derived fee rate. -func (w *TokenWallet) Send(addr string, value, _ uint64) (asset.Coin, error) { +func (w *TokenWallet) Send(addr string, value, _ uint64) (string, asset.Coin, error) { if err := isValidSend(addr, value, false); err != nil { - return nil, err + return "", nil, err } maxFee, maxFeeRate, err := w.canSend(value, true, false) if err != nil { - return nil, err + return "", nil, err } tx, err := w.sendToAddr(common.HexToAddress(addr), value, maxFeeRate) if err != nil { - return nil, err + return "", nil, err } txHash := tx.Hash() w.addToTxHistory(tx.Nonce(), -int64(value), maxFee, 0, w.assetID, txHash[:], asset.Send) - return &coin{id: txHash, value: value}, nil + return txHash.String(), &coin{id: txHash, value: value}, nil } // ValidateSecret checks that the secret satisfies the contract. @@ -3346,6 +3346,13 @@ func (eth *baseWallet) swapOrRedemptionFeesPaid(ctx context.Context, coinID, con return dexeth.WeiToGwei(bigFees), secretHashes, nil } +// TransactionConfirmations gets the number of confirmations for the specified +// transaction. +func (eth *baseWallet) TransactionConfirmations(ctx context.Context, txID string) (confs uint32, err error) { + txHash := common.HexToHash(txID) + return eth.node.transactionConfirmations(ctx, txHash) +} + // RegFeeConfirmations gets the number of confirmations for the specified // transaction. func (eth *baseWallet) RegFeeConfirmations(ctx context.Context, coinID dex.Bytes) (confs uint32, err error) { diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index d86eedf9f9..c7dd3f30dd 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -4340,7 +4340,7 @@ func testSend(t *testing.T, assetID uint32) { node.tokenContractor.bal = dexeth.GweiToWei(val - test.sendAdj) node.bal = dexeth.GweiToWei(tokenFees - test.feeAdj) } - coin, err := w.Send(test.addr, val, 0) + _, coin, err := w.Send(test.addr, val, 0) if test.wantErr { if err == nil { t.Fatalf("expected error for test %v", test.name) @@ -5253,7 +5253,7 @@ func testEstimateVsActualSendFees(t *testing.T, assetID uint32) { if err != nil { t.Fatalf("error converting canSend to gwei: %v", err) } - _, err = w.Send(testAddr, canSendGwei, 0) + _, _, err = w.Send(testAddr, canSendGwei, 0) if err != nil { t.Fatalf("error sending: %v", err) } @@ -5261,7 +5261,7 @@ func testEstimateVsActualSendFees(t *testing.T, assetID uint32) { tokenVal := uint64(10e9) node.tokenContractor.bal = dexeth.GweiToWei(tokenVal) node.bal = dexeth.GweiToWei(fee) - _, err = w.Send(testAddr, tokenVal, 0) + _, _, err = w.Send(testAddr, tokenVal, 0) if err != nil { t.Fatalf("error sending: %v", err) } diff --git a/client/asset/interface.go b/client/asset/interface.go index 3af74525c3..e527fb8deb 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -526,9 +526,12 @@ type Wallet interface { // payment. This method need not be supported by all assets. Those assets // which do no support DEX registration fees will return an ErrUnsupported. RegFeeConfirmations(ctx context.Context, coinID dex.Bytes) (confs uint32, err error) + // TransactionConfirmations gets the number of confirmations for the specified + // transaction. + TransactionConfirmations(ctx context.Context, txID string) (confs uint32, err error) // Send sends the exact value to the specified address. This is different // from Withdraw, which subtracts the tx fees from the amount sent. - Send(address string, value, feeRate uint64) (Coin, error) + Send(address string, value, feeRate uint64) (txID string, coin Coin, err error) // EstimateRegistrationTxFee returns an estimate for the tx fee needed to // pay the registration fee using the provided feeRate. EstimateRegistrationTxFee(feeRate uint64) uint64 @@ -640,7 +643,7 @@ type Recoverer interface { type Withdrawer interface { // Withdraw withdraws funds to the specified address. Fees are subtracted // from the value. - Withdraw(address string, value, feeRate uint64) (Coin, error) + Withdraw(address string, value, feeRate uint64) (txID string, coin Coin, err error) } // Sweeper is a wallet that can clear the entire balance of the wallet/account diff --git a/client/asset/zec/zec.go b/client/asset/zec/zec.go index 99b1e68062..51f356e261 100644 --- a/client/asset/zec/zec.go +++ b/client/asset/zec/zec.go @@ -2115,12 +2115,18 @@ func (w *zecWallet) EstimateSendTxFee( return } -func (w *zecWallet) Send(address string, value, feeRate uint64) (asset.Coin, error) { +func (w *zecWallet) Send(address string, value, feeRate uint64) (string, asset.Coin, error) { txHash, vout, sent, err := w.send(address, value, false) if err != nil { - return nil, err + return "", nil, err } - return btc.NewOutput(txHash, vout, sent), nil + return txHash.String(), btc.NewOutput(txHash, vout, sent), nil +} + +// TransactionConfirmations gets the number of confirmations for the specified +// transaction. +func (w *zecWallet) TransactionConfirmations(ctx context.Context, txID string) (confs uint32, err error) { + return } // send the value to the address, with the given fee rate. If subtract is true, diff --git a/client/core/core.go b/client/core/core.go index 8aff5638a7..44fcc5138f 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -4335,7 +4335,7 @@ func (c *Core) Register(form *RegisterForm) (*RegisterResult, error) { "Do NOT manually send funds to this address even if this fails.", regRes.Address, dc.acct.id, regRes.Fee, regFeeAssetSymbol) feeRate := c.feeSuggestionAny(feeAsset.ID, dc) - coin, err := wallet.Send(regRes.Address, regRes.Fee, feeRate) + _, coin, err := wallet.Send(regRes.Address, regRes.Fee, feeRate) if err != nil { return nil, newError(feeSendErr, "error paying registration fee: %w", err) } @@ -5452,54 +5452,55 @@ func (c *Core) feeSuggestion(dc *dexConnection, assetID uint32) (feeSuggestion u return dc.fetchFeeRate(assetID) } -// Withdraw initiates a withdraw from an exchange wallet. The client password -// must be provided as an additional verification. This method is DEPRECATED. Use -// Send with the subtract option instead. -func (c *Core) Withdraw(pw []byte, assetID uint32, value uint64, address string) (asset.Coin, error) { - return c.Send(pw, assetID, value, address, true) -} - // Send initiates either send or withdraw from an exchange wallet. if subtract // is true, fees are subtracted from the value else fees are taken from the -// exchange wallet. The client password must be provided as an additional -// verification. -func (c *Core) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) { - crypter, err := c.encryptionKey(pw) - if err != nil { - return nil, fmt.Errorf("password error: %w", err) +// exchange wallet. +func (c *Core) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) { + var crypter encrypt.Crypter + // Empty password can be provided if wallet is already unlocked. Webserver + // and RPCServer should not allow empty password, but this is used for + // bots. + if len(pw) > 0 { + var err error + crypter, err = c.encryptionKey(pw) + if err != nil { + return "", nil, fmt.Errorf("Trade password error: %w", err) + } + defer crypter.Close() } - defer crypter.Close() + if value == 0 { - return nil, fmt.Errorf("cannot send/withdraw zero %s", unbip(assetID)) + return "", nil, fmt.Errorf("cannot send/withdraw zero %s", unbip(assetID)) } wallet, found := c.wallet(assetID) if !found { - return nil, newError(missingWalletErr, "no wallet found for %s", unbip(assetID)) + return "", nil, newError(missingWalletErr, "no wallet found for %s", unbip(assetID)) } - err = c.connectAndUnlock(crypter, wallet) + err := c.connectAndUnlock(crypter, wallet) if err != nil { - return nil, err + return "", nil, err } if err = wallet.checkPeersAndSyncStatus(); err != nil { - return nil, err + return "", nil, err } var coin asset.Coin + var txID string feeSuggestion := c.feeSuggestionAny(assetID) if !subtract { - coin, err = wallet.Wallet.Send(address, value, feeSuggestion) + txID, coin, err = wallet.Wallet.Send(address, value, feeSuggestion) } else { if withdrawer, isWithdrawer := wallet.Wallet.(asset.Withdrawer); isWithdrawer { - coin, err = withdrawer.Withdraw(address, value, feeSuggestion) + txID, coin, err = withdrawer.Withdraw(address, value, feeSuggestion) } else { - return nil, fmt.Errorf("wallet does not support subtracting network fee from withdraw amount") + return "", nil, fmt.Errorf("wallet does not support subtracting network fee from withdraw amount") } } if err != nil { subject, details := c.formatDetails(TopicSendError, unbip(assetID), err) c.notify(newSendNote(TopicSendError, subject, details, db.ErrorLevel)) - return nil, err + return "", nil, err } sentValue := wallet.Info().UnitInfo.ConventionalString(coin.Value()) @@ -5507,7 +5508,18 @@ func (c *Core) Send(pw []byte, assetID uint32, value uint64, address string, sub c.notify(newSendNote(TopicSendSuccess, subject, details, db.Success)) c.updateAssetBalance(assetID) - return coin, nil + return txID, coin, nil +} + +// TransactionConfirmations returns the number of confirmations of +// a transaction. +func (c *Core) TransactionConfirmations(assetID uint32, txid string) (confirmations uint32, err error) { + wallet, err := c.connectedWallet(assetID) + if err != nil { + return 0, err + } + + return wallet.TransactionConfirmations(c.ctx, txid) } // ValidateAddress checks that the provided address is valid. diff --git a/client/core/core_test.go b/client/core/core_test.go index 61b7b9fdb5..aa357f5062 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -730,6 +730,7 @@ type TXCWallet struct { } var _ asset.Accelerator = (*TXCWallet)(nil) +var _ asset.Withdrawer = (*TXCWallet)(nil) func newTWallet(assetID uint32) (*xcWallet, *TXCWallet) { w := &TXCWallet{ @@ -931,19 +932,19 @@ func (w *TXCWallet) ConfirmTime(id dex.Bytes, nConfs uint32) (time.Time, error) return time.Time{}, nil } -func (w *TXCWallet) Send(address string, value, feeSuggestion uint64) (asset.Coin, error) { +func (w *TXCWallet) Send(address string, value, feeSuggestion uint64) (string, asset.Coin, error) { w.sendFeeSuggestion = feeSuggestion w.sendCoin.val = value - return w.sendCoin, w.sendErr + return "", w.sendCoin, w.sendErr } func (w *TXCWallet) SendTransaction(rawTx []byte) ([]byte, error) { return w.feeCoinSent, w.sendTxnErr } -func (w *TXCWallet) Withdraw(address string, value, feeSuggestion uint64) (asset.Coin, error) { +func (w *TXCWallet) Withdraw(address string, value, feeSuggestion uint64) (string, asset.Coin, error) { w.sendFeeSuggestion = feeSuggestion - return w.sendCoin, w.sendErr + return "", w.sendCoin, w.sendErr } func (w *TXCWallet) EstimateRegistrationTxFee(feeRate uint64) uint64 { @@ -1113,6 +1114,10 @@ func (w *TXCWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime time.Ti }, func() {}, nil } +func (w *TXCWallet) TransactionConfirmations(ctx context.Context, txID string) (confs uint32, err error) { + return 0, nil +} + type TAccountLocker struct { *TXCWallet reserveNRedemptions uint64 @@ -2731,7 +2736,7 @@ func TestSend(t *testing.T) { address := "addr" // Successful - coin, err := tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) + _, coin, err := tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) if err != nil { t.Fatalf("Send error: %v", err) } @@ -2740,13 +2745,13 @@ func TestSend(t *testing.T) { } // 0 value - _, err = tCore.Send(tPW, tUTXOAssetA.ID, 0, address, false) + _, _, err = tCore.Send(tPW, tUTXOAssetA.ID, 0, address, false) if err == nil { t.Fatalf("no error for zero value send") } // no wallet - _, err = tCore.Send(tPW, 12345, 1e8, address, false) + _, _, err = tCore.Send(tPW, 12345, 1e8, address, false) if err == nil { t.Fatalf("no error for unknown wallet") } @@ -2754,7 +2759,7 @@ func TestSend(t *testing.T) { // connect error wallet.hookedUp = false tWallet.connectErr = tErr - _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) + _, _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) if err == nil { t.Fatalf("no error for wallet connect error") } @@ -2762,7 +2767,7 @@ func TestSend(t *testing.T) { // Send error tWallet.sendErr = tErr - _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) + _, _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) if err == nil { t.Fatalf("no error for wallet send error") } @@ -2770,7 +2775,7 @@ func TestSend(t *testing.T) { // Check the coin. tWallet.sendCoin = &tCoin{id: []byte{'a'}} - coin, err = tCore.Send(tPW, tUTXOAssetA.ID, 3e8, address, false) + _, coin, err = tCore.Send(tPW, tUTXOAssetA.ID, 3e8, address, false) if err != nil { t.Fatalf("coin check error: %v", err) } @@ -2796,7 +2801,7 @@ func TestSend(t *testing.T) { wallet.Wallet = feeRater - coin, err = tCore.Send(tPW, tUTXOAssetA.ID, 2e8, address, false) + _, coin, err = tCore.Send(tPW, tUTXOAssetA.ID, 2e8, address, false) if err != nil { t.Fatalf("FeeRater Withdraw/send error: %v", err) } @@ -2810,7 +2815,7 @@ func TestSend(t *testing.T) { // wallet is not synced wallet.synced = false - _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) + _, _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) if err == nil { t.Fatalf("Expected error for a non-synchronized wallet") } @@ -10419,12 +10424,16 @@ func TestEstimateSendTxFee(t *testing.T) { tWallet.estFeeErr = tErr } estimate, _, err := tCore.EstimateSendTxFee("addr", test.asset, test.value, test.subtract) - if test.wantErr && err == nil { + if test.wantErr { if err != nil { continue } t.Fatalf("%s: expected error", test.name) } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if estimate != test.estFee { t.Fatalf("%s: expected fee %v, got %v", test.name, test.estFee, estimate) } diff --git a/client/core/locale_ntfn.go b/client/core/locale_ntfn.go index 0ea1228d34..56670bfbcb 100644 --- a/client/core/locale_ntfn.go +++ b/client/core/locale_ntfn.go @@ -85,7 +85,7 @@ var originLocale = map[Topic]*translation{ // [value string, ticker, destination address, coin ID] TopicSendSuccess: { subject: "Send successful", - template: "Sending %s %s to %s has completed successfully. Coin ID = %s", + template: "Sending %s %s to %s has completed successfully. Tx ID = %s", }, // [value string, ticker, destination address, coin ID] TopicShieldedSendSuccess: { diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index b00fb4812a..5cb3f3b782 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -630,7 +630,11 @@ func send(s *RPCServer, params *RawParams, route string) *msgjson.ResponsePayloa if route == withdrawRoute { subtract = true } - coin, err := s.core.Send(form.appPass, form.assetID, form.value, form.address, subtract) + if len(form.appPass) == 0 { + resErr := msgjson.NewError(msgjson.RPCFundTransferError, "empty pass") + return createResponse(route, nil, resErr) + } + _, coin, err := s.core.Send(form.appPass, form.assetID, form.value, form.address, subtract) if err != nil { errMsg := fmt.Sprintf("unable to %s: %v", err, route) resErr := msgjson.NewError(msgjson.RPCFundTransferError, errMsg) diff --git a/client/rpcserver/rpcserver.go b/client/rpcserver/rpcserver.go index 4991e5a59d..67859d4d86 100644 --- a/client/rpcserver/rpcserver.go +++ b/client/rpcserver/rpcserver.go @@ -77,7 +77,7 @@ type clientCore interface { Wallets() (walletsStates []*core.WalletState) WalletState(assetID uint32) *core.WalletState RescanWallet(assetID uint32, force bool) error - Send(appPass []byte, assetID uint32, value uint64, addr string, subtract bool) (asset.Coin, error) + Send(appPass []byte, assetID uint32, value uint64, addr string, subtract bool) (string, asset.Coin, error) ExportSeed(pw []byte) ([]byte, error) DeleteArchivedRecords(olderThan *time.Time, matchesFileStr, ordersFileStr string) (int, error) WalletPeers(assetID uint32) ([]*asset.WalletPeer, error) diff --git a/client/rpcserver/rpcserver_test.go b/client/rpcserver/rpcserver_test.go index c75f63ada6..a141bbd1ac 100644 --- a/client/rpcserver/rpcserver_test.go +++ b/client/rpcserver/rpcserver_test.go @@ -151,8 +151,8 @@ func (c *TCore) Wallets() []*core.WalletState { func (c *TCore) WalletState(assetID uint32) *core.WalletState { return c.walletState } -func (c *TCore) Send(pw []byte, assetID uint32, value uint64, addr string, subtract bool) (asset.Coin, error) { - return c.coin, c.sendErr +func (c *TCore) Send(pw []byte, assetID uint32, value uint64, addr string, subtract bool) (string, asset.Coin, error) { + return "", c.coin, c.sendErr } func (c *TCore) ExportSeed(pw []byte) ([]byte, error) { return c.exportSeed, c.exportSeedErr diff --git a/client/webserver/api.go b/client/webserver/api.go index 062aff0556..7b20ae0e78 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -1361,7 +1361,11 @@ func (s *WebServer) send(w http.ResponseWriter, r *http.Request, form *sendOrWit s.writeAPIError(w, fmt.Errorf("no wallet found for %s", unbip(form.AssetID))) return } - coin, err := s.core.Send(form.Pass, form.AssetID, form.Value, form.Address, form.Subtract) + if len(form.Pass) == 0 { + s.writeAPIError(w, fmt.Errorf("empty password")) + return + } + _, coin, err := s.core.Send(form.Pass, form.AssetID, form.Value, form.Address, form.Subtract) if err != nil { s.writeAPIError(w, fmt.Errorf("send/withdraw error: %w", err)) return diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 1e549d49f5..2d6b70932c 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -1765,8 +1765,8 @@ func (c *TCore) SupportedAssets() map[uint32]*core.SupportedAsset { } } -func (c *TCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) { - return &tCoin{id: []byte{0xde, 0xc7, 0xed}}, nil +func (c *TCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) { + return "", &tCoin{id: []byte{0xde, 0xc7, 0xed}}, nil } func (c *TCore) Trade(pw []byte, form *core.TradeForm) (*core.Order, error) { return c.trade(form), nil diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index 2289f207c9..605eaccdee 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -119,7 +119,7 @@ type clientCore interface { AddDEX(dexAddr string, certI any) error DiscoverAccount(dexAddr string, pass []byte, certI any) (*core.Exchange, bool, error) SupportedAssets() map[uint32]*core.SupportedAsset - Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) + Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) Trade(pw []byte, form *core.TradeForm) (*core.Order, error) TradeAsync(pw []byte, form *core.TradeForm) (*core.InFlightOrder, error) Cancel(oid dex.Bytes) error diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index 796be94db8..89e4c998da 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -171,8 +171,8 @@ func (c *TCore) User() *core.User { return nil } func (c *TCore) SupportedAssets() map[uint32]*core.SupportedAsset { return make(map[uint32]*core.SupportedAsset) } -func (c *TCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) { - return &tCoin{id: []byte{0xde, 0xc7, 0xed}}, c.sendErr +func (c *TCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) { + return "", &tCoin{id: []byte{0xde, 0xc7, 0xed}}, c.sendErr } func (c *TCore) ValidateAddress(address string, assetID uint32) (bool, error) { return c.validAddr, nil diff --git a/dex/testing/loadbot/mantle.go b/dex/testing/loadbot/mantle.go index 19d16b84bc..8f5d9577dc 100644 --- a/dex/testing/loadbot/mantle.go +++ b/dex/testing/loadbot/mantle.go @@ -540,7 +540,7 @@ func (m *Mantle) replenishBalance(w *botWallet, minFunds, maxFunds uint64) { // Send some back to the alpha address. amt := bal.Available - wantBal m.log.Debugf("Sending %s back to %s alpha node", valString(amt, w.symbol), w.symbol) - _, err := m.Send(pass, w.assetID, amt, returnAddress(w.symbol, alpha), false) + _, _, err := m.Send(pass, w.assetID, amt, returnAddress(w.symbol, alpha), false) if err != nil { m.fatalError("failed to send funds to alpha: %v", err) } From 528193e9978175a5a8fcb217edd6153891657eb3 Mon Sep 17 00:00:00 2001 From: martonp Date: Fri, 13 Oct 2023 03:00:39 -0400 Subject: [PATCH 2/9] client/mm/libxc: Add deposit/withdraw functionality to CEX interface - Deposit and Withdraw functionality is added to the CEX interface and is implemented in Binance. - The CEX interface is updated to take uint32 assetIDs instead of strings to represent assets. - The "weth.polygon" and "wbtc.polygon" assets are renamed to be "eth.polygon" and "btc.polygon". This makes it clear that "btc" and "btc.polygon" are the same asset, just on different networks. --- client/asset/polygon/polygon.go | 4 +- client/mm/libxc/binance.go | 382 +++++++++++++++++++++------ client/mm/libxc/binance_live_test.go | 89 ++++++- client/mm/libxc/interface.go | 30 ++- dex/bip-id.go | 4 +- dex/networks/polygon/params.go | 4 +- server/asset/polygon/polygon.go | 4 +- 7 files changed, 403 insertions(+), 114 deletions(-) diff --git a/client/asset/polygon/polygon.go b/client/asset/polygon/polygon.go index ac5e0486c9..4609a642e8 100644 --- a/client/asset/polygon/polygon.go +++ b/client/asset/polygon/polygon.go @@ -53,8 +53,8 @@ const ( var ( simnetTokenID, _ = dex.BipSymbolID("dextt.polygon") usdcTokenID, _ = dex.BipSymbolID("usdc.polygon") - wethTokenID, _ = dex.BipSymbolID("weth.polygon") - wbtcTokenID, _ = dex.BipSymbolID("wbtc.polygon") + wethTokenID, _ = dex.BipSymbolID("eth.polygon") + wbtcTokenID, _ = dex.BipSymbolID("btc.polygon") // WalletInfo defines some general information about a Polygon Wallet(EVM // Compatible). diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index 8cdb6c2177..38cfe55234 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -68,8 +68,6 @@ func newBNCBook() *bncBook { } type bncAssetConfig struct { - // assetID is the bip id - assetID uint32 // symbol is the DEX asset symbol, always lower case symbol string // coin is the asset symbol on binance, always upper case. @@ -83,28 +81,37 @@ type bncAssetConfig struct { conversionFactor uint64 } -func bncSymbolData(symbol string) (*bncAssetConfig, error) { - coin := strings.ToUpper(symbol) - var ok bool - assetID, ok := dex.BipSymbolID(symbol) - if !ok { - return nil, fmt.Errorf("not id found for %q", symbol) +// TODO: check all symbols +func mapDEXSymbolToBinance(symbol string) string { + if symbol == "POLYGON" { + return "MATIC" } - networkID := assetID + return symbol +} + +func bncAssetCfg(assetID uint32) (*bncAssetConfig, error) { + symbol := dex.BipIDSymbol(assetID) + if symbol == "" { + return nil, fmt.Errorf("no symbol found for %d", assetID) + } + + coin := strings.ToUpper(symbol) + chain := strings.ToUpper(symbol) if token := asset.TokenInfo(assetID); token != nil { - networkID = token.ParentID parts := strings.Split(symbol, ".") coin = strings.ToUpper(parts[0]) + chain = strings.ToUpper(parts[1]) } + ui, err := asset.UnitInfo(assetID) if err != nil { return nil, fmt.Errorf("no unit info found for %d", assetID) } + return &bncAssetConfig{ - assetID: assetID, symbol: symbol, - coin: coin, - chain: strings.ToUpper(dex.BipIDSymbol(networkID)), + coin: mapDEXSymbolToBinance(coin), + chain: mapDEXSymbolToBinance(chain), conversionFactor: ui.Conventional.ConversionFactor, }, nil } @@ -116,6 +123,12 @@ type bncBalance struct { locked float64 } +type tradeInfo struct { + updaterID int + baseID uint32 + quoteID uint32 +} + type binance struct { log dex.Logger url string @@ -143,7 +156,7 @@ type binance struct { books map[string]*bncBook tradeUpdaterMtx sync.RWMutex - tradeToUpdater map[string]int + tradeInfo map[string]*tradeInfo tradeUpdaters map[int]chan *TradeUpdate tradeUpdateCounter int @@ -178,7 +191,7 @@ func newBinance(apiKey, secretKey string, log dex.Logger, net dex.Network, binan balances: make(map[string]*bncBalance), books: make(map[string]*bncBook), net: net, - tradeToUpdater: make(map[string]int), + tradeInfo: make(map[string]*tradeInfo), tradeUpdaters: make(map[int]chan *TradeUpdate), cexUpdaters: make(map[chan interface{}]struct{}, 0), tradeIDNoncePrefix: encode.RandomBytes(10), @@ -354,8 +367,8 @@ func (bnc *binance) SubscribeCEXUpdates() (<-chan interface{}, func()) { } // Balance returns the balance of an asset at the CEX. -func (bnc *binance) Balance(symbol string) (*ExchangeBalance, error) { - assetConfig, err := bncSymbolData(symbol) +func (bnc *binance) Balance(assetID uint32) (*ExchangeBalance, error) { + assetConfig, err := bncAssetCfg(assetID) if err != nil { return nil, err } @@ -382,20 +395,20 @@ func (bnc *binance) generateTradeID() string { // Trade executes a trade on the CEX. subscriptionID takes an ID returned from // SubscribeTradeUpdates. -func (bnc *binance) Trade(ctx context.Context, baseSymbol, quoteSymbol string, sell bool, rate, qty uint64, subscriptionID int) (string, error) { +func (bnc *binance) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64, subscriptionID int) (string, error) { side := "BUY" if sell { side = "SELL" } - baseCfg, err := bncSymbolData(baseSymbol) + baseCfg, err := bncAssetCfg(baseID) if err != nil { - return "", fmt.Errorf("error getting symbol data for %s: %w", baseSymbol, err) + return "", fmt.Errorf("error getting asset cfg for %d", baseID) } - quoteCfg, err := bncSymbolData(quoteSymbol) + quoteCfg, err := bncAssetCfg(quoteID) if err != nil { - return "", fmt.Errorf("error getting symbol data for %s: %w", quoteSymbol, err) + return "", fmt.Errorf("error getting asset cfg for %d", quoteID) } slug := baseCfg.coin + quoteCfg.coin @@ -434,11 +447,196 @@ func (bnc *binance) Trade(ctx context.Context, baseSymbol, quoteSymbol string, s bnc.tradeUpdaterMtx.Lock() defer bnc.tradeUpdaterMtx.Unlock() - bnc.tradeToUpdater[tradeID] = subscriptionID + bnc.tradeInfo[tradeID] = &tradeInfo{ + updaterID: subscriptionID, + baseID: baseID, + quoteID: quoteID, + } return tradeID, err } +func (bnc *binance) assetPrecision(coin string) (int, error) { + for _, market := range bnc.markets.Load().(map[string]*bnMarket) { + if market.BaseAsset == coin { + return market.BaseAssetPrecision, nil + } + if market.QuoteAsset == coin { + return market.QuoteAssetPrecision, nil + } + } + return 0, fmt.Errorf("asset %s not found", coin) +} + +func (bnc *binance) Withdraw(ctx context.Context, assetID uint32, qty uint64, address string, onComplete func(uint64, string)) error { + assetCfg, err := bncAssetCfg(assetID) + if err != nil { + return fmt.Errorf("error getting symbol data for %d: %w", assetID, err) + } + + precision, err := bnc.assetPrecision(assetCfg.coin) + if err != nil { + return fmt.Errorf("error getting precision for %s: %w", assetCfg.coin, err) + } + + amt := float64(qty) / float64(assetCfg.conversionFactor) + v := make(url.Values) + v.Add("coin", assetCfg.coin) + v.Add("network", assetCfg.chain) + v.Add("address", address) + v.Add("amount", strconv.FormatFloat(amt, 'f', precision, 64)) + + withdrawResp := struct { + ID string `json:"id"` + }{} + err = bnc.postAPI(ctx, "/sapi/v1/capital/withdraw/apply", nil, v, true, true, &withdrawResp) + if err != nil { + return err + } + + go func() { + getWithdrawalStatus := func() (complete bool, amount uint64, txID string) { + type withdrawalHistoryStatus struct { + ID string `json:"id"` + Amount float64 `json:"amount,string"` + Status int `json:"status"` + TxID string `json:"txId"` + } + + withdrawHistoryResponse := []*withdrawalHistoryStatus{} + v := make(url.Values) + v.Add("coin", assetCfg.coin) + err = bnc.getAPI(ctx, "/sapi/v1/capital/withdraw/history", v, true, true, &withdrawHistoryResponse) + if err != nil { + bnc.log.Errorf("Error getting withdrawal status: %v", err) + return false, 0, "" + } + + var status *withdrawalHistoryStatus + for _, s := range withdrawHistoryResponse { + if s.ID == withdrawResp.ID { + status = s + } + } + if status == nil { + bnc.log.Errorf("Withdrawal status not found for %s", withdrawResp.ID) + return false, 0, "" + } + + amt := status.Amount * float64(assetCfg.conversionFactor) + return status.Status == 6, uint64(amt), status.TxID + } + + for { + ticker := time.NewTicker(time.Second * 20) + defer ticker.Stop() + + select { + case <-ctx.Done(): + return + case <-ticker.C: + if complete, amt, txID := getWithdrawalStatus(); complete { + onComplete(amt, txID) + return + } + } + } + }() + + return nil +} + +func (bnc *binance) GetDepositAddress(ctx context.Context, assetID uint32) (string, error) { + assetCfg, err := bncAssetCfg(assetID) + if err != nil { + return "", fmt.Errorf("error getting asset cfg for %d: %w", assetID, err) + } + + v := make(url.Values) + v.Add("coin", assetCfg.coin) + v.Add("network", assetCfg.chain) + + resp := struct { + Address string `json:"address"` + }{} + err = bnc.getAPI(ctx, "/sapi/v1/capital/deposit/address", v, true, true, &resp) + if err != nil { + return "", err + } + + return resp.Address, nil +} + +func (bnc *binance) ConfirmDeposit(ctx context.Context, txID string, onConfirm func(bool, uint64)) { + const pendingStatus = 0 + const successStatus = 1 + const creditedStatus = 6 + const wrongDepositStatus = 7 + const waitingUserConfirmStatus = 8 + + type depositHistory struct { + Amount float64 `json:"amount,string"` + Coin string `json:"coin"` + Network string `json:"network"` + Status int `json:"status"` + Address string `json:"address"` + AddressTag string `json:"addressTag"` + TxID string `json:"txId"` + InsertTime int64 `json:"insertTime"` + TransferType int `json:"transferType"` + ConfirmTimes string `json:"confirmTimes"` + } + + checkDepositStatus := func() (success, done bool) { + resp := []*depositHistory{} + err := bnc.getAPI(ctx, "/sapi/v1/capital/deposit/hisrec", nil, true, true, resp) + if err != nil { + bnc.log.Errorf("error getting deposit status: %v", err) + return false, false + } + + for _, status := range resp { + if status.TxID == txID { + switch status.Status { + case successStatus, creditedStatus: + return true, true + case pendingStatus: + return false, false + case waitingUserConfirmStatus: + // This shouldn't ever happen. + bnc.log.Errorf("Deposit %s to binance requires user confirmation!") + return false, false + case wrongDepositStatus: + return false, true + default: + bnc.log.Errorf("Deposit %s to binance has an unknown status %d", status.Status) + } + } + } + + return false, false + } + + go func() { + for { + ticker := time.NewTicker(time.Second * 20) + defer ticker.Stop() + + select { + case <-ctx.Done(): + return + case <-ticker.C: + success, done := checkDepositStatus() + if done { + // TODO: get amount + onConfirm(success, 0) + return + } + } + } + }() +} + // SubscribeTradeUpdates returns a channel that the caller can use to // listen for updates to a trade's status. When the subscription ID // returned from this function is passed as the updaterID argument to @@ -462,15 +660,15 @@ func (bnc *binance) SubscribeTradeUpdates() (<-chan *TradeUpdate, func(), int) { } // CancelTrade cancels a trade on the CEX. -func (bnc *binance) CancelTrade(ctx context.Context, baseSymbol, quoteSymbol string, tradeID string) error { - baseCfg, err := bncSymbolData(baseSymbol) +func (bnc *binance) CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error { + baseCfg, err := bncAssetCfg(baseID) if err != nil { - return fmt.Errorf("error getting symbol data for %s: %w", baseSymbol, err) + return fmt.Errorf("error getting asset cfg for %d", baseID) } - quoteCfg, err := bncSymbolData(quoteSymbol) + quoteCfg, err := bncAssetCfg(quoteID) if err != nil { - return fmt.Errorf("error getting symbol data for %s: %w", quoteSymbol, err) + return fmt.Errorf("error getting asset cfg for %d", quoteID) } slug := baseCfg.coin + quoteCfg.coin @@ -487,27 +685,6 @@ func (bnc *binance) CancelTrade(ctx context.Context, baseSymbol, quoteSymbol str return bnc.requestInto(req, &struct{}{}) } -func (bnc *binance) Balances() (map[uint32]*ExchangeBalance, error) { - bnc.balanceMtx.RLock() - defer bnc.balanceMtx.RUnlock() - - balances := make(map[uint32]*ExchangeBalance) - - for coin, bal := range bnc.balances { - assetConfig, err := bncSymbolData(strings.ToLower(coin)) - if err != nil { - continue - } - - balances[assetConfig.assetID] = &ExchangeBalance{ - Available: uint64(bal.available * float64(assetConfig.conversionFactor)), - Locked: uint64(bal.locked * float64(assetConfig.conversionFactor)), - } - } - - return balances, nil -} - func (bnc *binance) Markets() ([]*Market, error) { bnMarkets := bnc.markets.Load().(map[string]*bnMarket) markets := make([]*Market, 0, 16) @@ -639,7 +816,9 @@ type bncStreamUpdate struct { 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"` } @@ -678,26 +857,26 @@ func (bnc *binance) handleOutboundAccountPosition(update *bncStreamUpdate) { bnc.sendCexUpdateNotes() } -func (bnc *binance) getTradeUpdater(tradeID string) (chan *TradeUpdate, error) { +func (bnc *binance) getTradeUpdater(tradeID string) (chan *TradeUpdate, *tradeInfo, error) { bnc.tradeUpdaterMtx.RLock() defer bnc.tradeUpdaterMtx.RUnlock() - updaterID, found := bnc.tradeToUpdater[tradeID] + tradeInfo, found := bnc.tradeInfo[tradeID] if !found { - return nil, fmt.Errorf("updater not found for trade ID %v", tradeID) + return nil, nil, fmt.Errorf("info not found for trade ID %v", tradeID) } - updater, found := bnc.tradeUpdaters[updaterID] + updater, found := bnc.tradeUpdaters[tradeInfo.updaterID] if !found { - return nil, fmt.Errorf("no updater with ID %v", tradeID) + return nil, nil, fmt.Errorf("no updater with ID %v", tradeID) } - return updater, nil + return updater, tradeInfo, nil } func (bnc *binance) removeTradeUpdater(tradeID string) { bnc.tradeUpdaterMtx.RLock() defer bnc.tradeUpdaterMtx.RUnlock() - delete(bnc.tradeToUpdater, tradeID) + delete(bnc.tradeInfo, tradeID) } func (bnc *binance) handleExecutionReport(update *bncStreamUpdate) { @@ -711,16 +890,31 @@ func (bnc *binance) handleExecutionReport(update *bncStreamUpdate) { id = update.ClientOrderID } - updater, err := bnc.getTradeUpdater(id) + updater, tradeInfo, err := bnc.getTradeUpdater(id) if err != nil { bnc.log.Errorf("Error getting trade updater: %v", err) return } complete := status == "FILLED" || status == "CANCELED" || status == "REJECTED" || status == "EXPIRED" + + baseCfg, err := bncAssetCfg(tradeInfo.baseID) + if err != nil { + bnc.log.Errorf("Error getting asset cfg for %d: %v", tradeInfo.baseID, err) + return + } + + quoteCfg, err := bncAssetCfg(tradeInfo.quoteID) + if err != nil { + bnc.log.Errorf("Error getting asset cfg for %d: %v", tradeInfo.quoteID, err) + return + } + updater <- &TradeUpdate{ - TradeID: id, - Complete: complete, + TradeID: id, + Complete: complete, + BaseFilled: uint64(update.Filled * float64(baseCfg.conversionFactor)), + QuoteFilled: uint64(update.QuoteFilled * float64(quoteCfg.conversionFactor)), } if complete { @@ -1083,24 +1277,55 @@ func (bnc *binance) startMarketDataStream(ctx context.Context, baseSymbol, quote } // UnsubscribeMarket unsubscribes from order book updates on a market. -func (bnc *binance) UnsubscribeMarket(baseSymbol, quoteSymbol string) { - bnc.stopMarketDataStream(strings.ToLower(baseSymbol + quoteSymbol)) +func (bnc *binance) UnsubscribeMarket(baseID, quoteID uint32) (err error) { + baseCfg, err := bncAssetCfg(baseID) + if err != nil { + return fmt.Errorf("error getting asset cfg for %d", baseID) + } + + quoteCfg, err := bncAssetCfg(quoteID) + if err != nil { + return fmt.Errorf("error getting asset cfg for %d", quoteID) + } + + bnc.stopMarketDataStream(strings.ToLower(baseCfg.coin + quoteCfg.coin)) + return nil } // SubscribeMarket subscribes to order book updates on a market. This must // be called before calling VWAP. -func (bnc *binance) SubscribeMarket(ctx context.Context, baseSymbol, quoteSymbol string) error { - return bnc.startMarketDataStream(ctx, baseSymbol, quoteSymbol) +func (bnc *binance) SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error { + baseCfg, err := bncAssetCfg(baseID) + if err != nil { + return fmt.Errorf("error getting asset cfg for %d", baseID) + } + + quoteCfg, err := bncAssetCfg(quoteID) + if err != nil { + return fmt.Errorf("error getting asset cfg for %d", quoteID) + } + + return bnc.startMarketDataStream(ctx, baseCfg.coin, quoteCfg.coin) } // VWAP returns the volume weighted average price for a certain quantity // of the base asset on a market. -func (bnc *binance) VWAP(baseSymbol, quoteSymbol string, sell bool, qty uint64) (avgPrice, extrema uint64, filled bool, err error) { +func (bnc *binance) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (avgPrice, extrema uint64, filled bool, err error) { fail := func(err error) (uint64, uint64, bool, error) { return 0, 0, false, err } - slug := strings.ToLower(baseSymbol + quoteSymbol) + baseCfg, err := bncAssetCfg(baseID) + if err != nil { + return fail(fmt.Errorf("error getting asset cfg for %d", baseID)) + } + + quoteCfg, err := bncAssetCfg(quoteID) + if err != nil { + return fail(fmt.Errorf("error getting asset cfg for %d", quoteID)) + } + + slug := strings.ToLower(baseCfg.coin + quoteCfg.coin) var side []*bookBin var latestUpdate int64 bnc.booksMtx.RLock() @@ -1122,16 +1347,6 @@ func (bnc *binance) VWAP(baseSymbol, quoteSymbol string, sell bool, qty uint64) return fail(fmt.Errorf("book for %s is stale", slug)) } - baseCfg, err := bncSymbolData(baseSymbol) - if err != nil { - return fail(fmt.Errorf("error getting symbol data for %s: %w", baseSymbol, err)) - } - - quoteCfg, err := bncSymbolData(quoteSymbol) - if err != nil { - return fail(fmt.Errorf("error getting symbol data for %s: %w", quoteSymbol, err)) - } - remaining := qty var weightedSum uint64 for _, bin := range side { @@ -1202,24 +1417,27 @@ type bnMarket struct { OrderTypes []string `json:"orderTypes"` } -// dexMarkets returns all the possible markets for this symbol. A symbol -// represents a single market on the CEX, but tokens on the DEX have a -// different assetID for each network they are on, therefore they will +// dexMarkets returns all the possible dex markets for this binance market. +// A symbol represents a single market on the CEX, but tokens on the DEX +// have a different assetID for each network they are on, therefore they will // match multiple markets as defined using assetID. func (s *bnMarket) dexMarkets(tokenIDs map[string][]uint32) []*Market { var baseAssetIDs, quoteAssetIDs []uint32 getAssetIDs := func(coin string) []uint32 { symbol := strings.ToLower(coin) + assetIDs := make([]uint32, 0, 1) + + // In some cases a token may also be a base asset. For example btc + // should return the btc ID as well as the ID of all wbtc tokens. if assetID, found := dex.BipSymbolID(symbol); found { - return []uint32{assetID} + assetIDs = append(assetIDs, assetID) } - if tokenIDs, found := tokenIDs[symbol]; found { - return tokenIDs + assetIDs = append(assetIDs, tokenIDs...) } - return nil + return assetIDs } baseAssetIDs = getAssetIDs(s.BaseAsset) diff --git a/client/mm/libxc/binance_live_test.go b/client/mm/libxc/binance_live_test.go index 4210aaee18..a63eacccb6 100644 --- a/client/mm/libxc/binance_live_test.go +++ b/client/mm/libxc/binance_live_test.go @@ -4,6 +4,7 @@ package libxc import ( "context" + "fmt" "os" "os/user" "sync" @@ -84,13 +85,13 @@ func TestConnect(t *testing.T) { t.Fatalf("Connect error: %v", err) } - balance, err := bnc.Balance("eth") + balance, err := bnc.Balance(60) if err != nil { t.Fatalf("Balance error: %v", err) } t.Logf("usdc balance: %v", balance) - balance, err = bnc.Balance("btc") + balance, err = bnc.Balance(0) if err != nil { t.Fatalf("Balance error: %v", err) } @@ -100,7 +101,7 @@ func TestConnect(t *testing.T) { // This may fail due to balance being to low. You can try switching the side // of the trade or the qty. func TestTrade(t *testing.T) { - bnc := tNewBinance(t, dex.Simnet) + bnc := tNewBinance(t, dex.Mainnet) ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) defer cancel() _, err := bnc.Connect(ctx) @@ -130,14 +131,14 @@ func TestTrade(t *testing.T) { } } }() - tradeID, err := bnc.Trade(ctx, "eth", "btc", false, 6000e2, 1e7, updaterID) + tradeID, err := bnc.Trade(ctx, 60, 0, false, 6000e2, 1e7, updaterID) if err != nil { t.Fatalf("trade error: %v", err) } if true { // Cancel the trade time.Sleep(1 * time.Second) - err = bnc.CancelTrade(ctx, "eth", "btc", tradeID) + err = bnc.CancelTrade(ctx, 60, 0, tradeID) if err != nil { t.Fatalf("error cancelling trade: %v", err) } @@ -157,7 +158,7 @@ func TestCancelTrade(t *testing.T) { t.Fatalf("Connect error: %v", err) } - err = bnc.CancelTrade(ctx, "eth", "btc", tradeID) + err = bnc.CancelTrade(ctx, 60, 0, tradeID) if err != nil { t.Fatalf("error cancelling trade: %v", err) } @@ -192,43 +193,105 @@ func TestVWAP(t *testing.T) { t.Fatalf("Connect error: %v", err) } - err = bnc.SubscribeMarket(ctx, "eth", "btc") + err = bnc.SubscribeMarket(ctx, 60, 0) if err != nil { t.Fatalf("failed to subscribe to market: %v", err) } time.Sleep(10 * time.Second) - avg, extrema, filled, err := bnc.VWAP("eth", "btc", true, 2e9) + avg, extrema, filled, err := bnc.VWAP(60, 0, true, 2e9) if err != nil { t.Fatalf("VWAP failed: %v", err) } t.Logf("avg: %v, extrema: %v, filled: %v", avg, extrema, filled) - err = bnc.SubscribeMarket(ctx, "eth", "btc") + err = bnc.SubscribeMarket(ctx, 60, 0) if err != nil { t.Fatalf("failed to subscribe to market: %v", err) } time.Sleep(2 * time.Second) - avg, extrema, filled, err = bnc.VWAP("eth", "btc", true, 2e9) + avg, extrema, filled, err = bnc.VWAP(60, 0, true, 2e9) if err != nil { t.Fatalf("VWAP failed: %v", err) } t.Logf("avg: %v, extrema: %v, filled: %v", avg, extrema, filled) - bnc.UnsubscribeMarket("eth", "btc") + bnc.UnsubscribeMarket(60, 0) - avg, extrema, filled, err = bnc.VWAP("eth", "btc", true, 2e9) + avg, extrema, filled, err = bnc.VWAP(60, 0, true, 2e9) if err != nil { t.Fatalf("VWAP failed: %v", err) } t.Logf("avg: %v, extrema: %v, filled: %v", avg, extrema, filled) - bnc.UnsubscribeMarket("eth", "btc") + bnc.UnsubscribeMarket(60, 0) if err != nil { t.Fatalf("error unsubscribing market") } } + +func TestWithdrawal(t *testing.T) { + bnc := tNewBinance(t, dex.Testnet) + ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) + defer cancel() + + _, err := bnc.Connect(ctx) + if err != nil { + t.Fatalf("Connect error: %v", err) + } + + wg := sync.WaitGroup{} + wg.Add(1) + onComplete := func(amt uint64, txID string) { + t.Logf("withdrawal complete: %v, %v", amt, txID) + wg.Done() + } + + err = bnc.Withdraw(ctx, 60001, 4e10, "", onComplete) + if err != nil { + fmt.Printf("withdrawal error: %v", err) + return + } + + wg.Wait() +} + +func TestGetDepositAddress(t *testing.T) { + bnc := tNewBinance(t, dex.Mainnet) + ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) + defer cancel() + + _, err := bnc.Connect(ctx) + if err != nil { + t.Fatalf("Connect error: %v", err) + } + + addr, err := bnc.GetDepositAddress(ctx, 966) + if err != nil { + t.Fatalf("getDepositAddress error: %v", err) + } + + t.Logf("deposit address: %v", addr) +} + +func TestBalances(t *testing.T) { + bnc := tNewBinance(t, dex.Mainnet) + ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) + defer cancel() + + _, err := bnc.Connect(ctx) + if err != nil { + t.Fatalf("Connect error: %v", err) + } + + balance, err := bnc.Balance(966) + if err != nil { + t.Fatalf("balances error: %v", err) + } + + t.Logf("%+v", balance) +} diff --git a/client/mm/libxc/interface.go b/client/mm/libxc/interface.go index cf4268add1..fe05063800 100644 --- a/client/mm/libxc/interface.go +++ b/client/mm/libxc/interface.go @@ -17,8 +17,10 @@ type ExchangeBalance struct { // TradeUpdate is a notification sent when the status of a trade on the CEX // has been updated. type TradeUpdate struct { - TradeID string - Complete bool // cancelled or filled + TradeID string + Complete bool // cancelled or filled + BaseFilled uint64 + QuoteFilled uint64 } // Market is the base and quote assets of a market on a CEX. @@ -34,12 +36,9 @@ type Market struct { type CEX interface { dex.Connector // Balance returns the balance of an asset at the CEX. - Balance(symbol string) (*ExchangeBalance, error) - // Balances returns a list of all asset balances at the CEX. Only assets that are - // registered in the DEX client will be returned. - Balances() (map[uint32]*ExchangeBalance, error) + Balance(assetID uint32) (*ExchangeBalance, error) // CancelTrade cancels a trade on the CEX. - CancelTrade(ctx context.Context, baseSymbol, quoteSymbol, tradeID string) error + CancelTrade(ctx context.Context, base, quote uint32, tradeID string) error // Markets returns the list of markets at the CEX. Markets() ([]*Market, error) // SubscribeCEXUpdates returns a channel which sends an empty struct when @@ -47,7 +46,7 @@ type CEX interface { SubscribeCEXUpdates() (updates <-chan interface{}, unsubscribe func()) // SubscribeMarket subscribes to order book updates on a market. This must // be called before calling VWAP. - SubscribeMarket(ctx context.Context, baseSymbol, quoteSymbol string) error + SubscribeMarket(ctx context.Context, base, quote uint32) error // SubscribeTradeUpdates returns a channel that the caller can use to // listen for updates to a trade's status. When the subscription ID // returned from this function is passed as the updaterID argument to @@ -56,12 +55,21 @@ type CEX interface { SubscribeTradeUpdates() (updates <-chan *TradeUpdate, unsubscribe func(), subscriptionID int) // Trade executes a trade on the CEX. updaterID takes a subscriptionID // returned from SubscribeTradeUpdates. - Trade(ctx context.Context, baseSymbol, quoteSymbol string, sell bool, rate, qty uint64, subscriptionID int) (string, error) + Trade(ctx context.Context, base, quote uint32, sell bool, rate, qty uint64, subscriptionID int) (string, error) // UnsubscribeMarket unsubscribes from order book updates on a market. - UnsubscribeMarket(baseSymbol, quoteSymbol string) + UnsubscribeMarket(base, quote uint32) error // VWAP returns the volume weighted average price for a certain quantity // of the base asset on a market. - VWAP(baseSymbol, quoteSymbol string, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) + VWAP(base, quote uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) + // GetDepositAddress returns a deposit address for an asset. + 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(success bool, 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. + Withdraw(ctx context.Context, assetID uint32, amt uint64, address string, onComplete func(amt uint64, txID string)) error } const ( diff --git a/dex/bip-id.go b/dex/bip-id.go index 26b3ff1ef7..46ba91d10a 100644 --- a/dex/bip-id.go +++ b/dex/bip-id.go @@ -619,8 +619,8 @@ var bipIDs = map[uint32]string{ // Polygon reserved token range 966000-966999 966000: "dextt.polygon", 966001: "usdc.polygon", - 966002: "weth.polygon", - 966003: "wbtc.polygon", + 966002: "eth.polygon", + 966003: "btc.polygon", // END Polygon reserved token range 1171337: "ilt", 1313114: "etho", diff --git a/dex/networks/polygon/params.go b/dex/networks/polygon/params.go index 868da483a0..b54fb7166a 100644 --- a/dex/networks/polygon/params.go +++ b/dex/networks/polygon/params.go @@ -57,8 +57,8 @@ var ( testTokenID, _ = dex.BipSymbolID("dextt.polygon") usdcTokenID, _ = dex.BipSymbolID("usdc.polygon") - wethTokenID, _ = dex.BipSymbolID("weth.polygon") - wbtcTokenID, _ = dex.BipSymbolID("wbtc.polygon") + wethTokenID, _ = dex.BipSymbolID("eth.polygon") + wbtcTokenID, _ = dex.BipSymbolID("btc.polygon") Tokens = map[uint32]*dexeth.Token{ testTokenID: TestToken, diff --git a/server/asset/polygon/polygon.go b/server/asset/polygon/polygon.go index 9f8e3faf88..399cee1acd 100644 --- a/server/asset/polygon/polygon.go +++ b/server/asset/polygon/polygon.go @@ -63,8 +63,8 @@ const ( var ( testTokenID, _ = dex.BipSymbolID("dextt.polygon") usdcID, _ = dex.BipSymbolID("usdc.polygon") - wethTokenID, _ = dex.BipSymbolID("weth.polygon") - wbtcTokenID, _ = dex.BipSymbolID("wbtc.polygon") + wethTokenID, _ = dex.BipSymbolID("eth.polygon") + wbtcTokenID, _ = dex.BipSymbolID("btc.polygon") // blockPollInterval is the delay between calls to bestBlockHash to check // for new blocks. Modify at compile time via blockPollIntervalStr: From e74ff54332f44adc00cfbdeb64a9158d3393a30d Mon Sep 17 00:00:00 2001 From: martonp Date: Fri, 13 Oct 2023 03:03:55 -0400 Subject: [PATCH 3/9] client/mm: Tracking of CEX balances Tracking of CEX balances for each bot is implemented in a similar fashion to tracking bot balances on the DEX. A `wrappedCEX` is created for each bot which behaves as if the entire balance of the CEX is the amount that is allocated for each bot. --- client/mm/config.go | 29 +- client/mm/mm.go | 347 +++++-- client/mm/mm_test.go | 2066 +++++++++++++++++++++++++++++--------- client/mm/wrapped_cex.go | 292 ++++++ 4 files changed, 2175 insertions(+), 559 deletions(-) create mode 100644 client/mm/wrapped_cex.go diff --git a/client/mm/config.go b/client/mm/config.go index d8a6e55b57..0cc3a8086c 100644 --- a/client/mm/config.go +++ b/client/mm/config.go @@ -27,20 +27,31 @@ type CEXConfig struct { APISecret string `json:"apiSecret"` } +// BotCEXCfg is the specifies the CEX that a bot uses and the initial balances +// that should be allocated to the bot on that CEX. +type BotCEXCfg struct { + Name string `json:"name"` + BaseBalanceType BalanceType `json:"baseBalanceType"` + BaseBalance uint64 `json:"baseBalance"` + QuoteBalanceType BalanceType `json:"quoteBalanceType"` + QuoteBalance uint64 `json:"quoteBalance"` +} + // BotConfig is the configuration for a market making bot. // The balance fields are the initial amounts that will be reserved to use for // this bot. As the bot trades, the amounts reserved for it will be updated. type BotConfig struct { - Host string `json:"host"` - BaseAsset uint32 `json:"baseAsset"` - QuoteAsset uint32 `json:"quoteAsset"` - - BaseBalanceType BalanceType `json:"baseBalanceType"` - BaseBalance uint64 `json:"baseBalance"` - + Host string `json:"host"` + BaseAsset uint32 `json:"baseAsset"` + QuoteAsset uint32 `json:"quoteAsset"` + BaseBalanceType BalanceType `json:"baseBalanceType"` + BaseBalance uint64 `json:"baseBalance"` QuoteBalanceType BalanceType `json:"quoteBalanceType"` QuoteBalance uint64 `json:"quoteBalance"` + // Only applicable for arb bots. + CEXCfg *BotCEXCfg `json:"cexCfg"` + // Only one of the following configs should be set BasicMMConfig *BasicMarketMakingConfig `json:"basicMarketMakingConfig,omitempty"` SimpleArbConfig *SimpleArbConfig `json:"simpleArbConfig,omitempty"` @@ -56,6 +67,10 @@ func (c *BotConfig) requiresPriceOracle() bool { return false } +func (c *BotConfig) requiresCEX() bool { + return c.SimpleArbConfig != nil || c.MMWithCEXConfig != nil +} + func dexMarketID(host string, base, quote uint32) string { return fmt.Sprintf("%s-%d-%d", host, base, quote) } diff --git a/client/mm/mm.go b/client/mm/mm.go index 98981ed502..6acd6e66d9 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -13,6 +13,7 @@ import ( "sync" "sync/atomic" + "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/client/orderbook" @@ -42,6 +43,9 @@ type clientCore interface { OpenWallet(assetID uint32, appPW []byte) error Broadcast(core.Notification) FiatConversionRates() map[uint32]float64 + Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) + NewDepositAddress(assetID uint32) (string, error) + TransactionConfirmations(assetID uint32, txid string) (uint32, error) } var _ clientCore = (*core.Core)(nil) @@ -112,6 +116,8 @@ type botBalance struct { type botBalances struct { mtx sync.RWMutex balances map[uint32]*botBalance + // It is assumed that a bot only interacts with one CEX. + cexBalances map[uint32]uint64 } // orderInfo stores the necessary information the MarketMaker needs for a @@ -153,6 +159,17 @@ func (o *orderInfo) finishedProcessing() bool { return true } +// MarketWithHost represents a market on a specific dex server. +type MarketWithHost struct { + Host string `json:"host"` + BaseID uint32 `json:"base"` + QuoteID uint32 `json:"quote"` +} + +func (m *MarketWithHost) String() string { + return fmt.Sprintf("%s-%d-%d", m.Host, m.BaseID, m.QuoteID) +} + // MarketMaker handles the market making process. It supports running different // strategies on different markets. type MarketMaker struct { @@ -160,7 +177,6 @@ type MarketMaker struct { die context.CancelFunc running atomic.Bool log dex.Logger - dir string core clientCore doNotKillWhenBotsStop bool // used for testing botBalances map[string]*botBalances @@ -213,17 +229,6 @@ func (m *MarketMaker) Running() bool { return m.running.Load() } -// MarketWithHost represents a market on a specific dex server. -type MarketWithHost struct { - Host string `json:"host"` - BaseID uint32 `json:"base"` - QuoteID uint32 `json:"quote"` -} - -func (m *MarketWithHost) String() string { - return fmt.Sprintf("%s-%d-%d", m.Host, m.BaseID, m.QuoteID) -} - // RunningBots returns the markets on which a bot is running. func (m *MarketMaker) RunningBots() []MarketWithHost { m.runningBotsMtx.RLock() @@ -249,22 +254,6 @@ func marketsRequiringPriceOracle(cfgs []*BotConfig) []*mkt { return mkts } -// duplicateBotConfig returns an error if there is more than one bot config for -// the same market on the same dex host. -func duplicateBotConfig(cfgs []*BotConfig) error { - mkts := make(map[string]struct{}) - - for _, cfg := range cfgs { - mkt := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) - if _, found := mkts[mkt]; found { - return fmt.Errorf("duplicate bot config for market %s", mkt) - } - mkts[mkt] = struct{}{} - } - - return nil -} - func priceOracleFromConfigs(ctx context.Context, cfgs []*BotConfig, log dex.Logger) (*priceOracle, error) { var oracle *priceOracle var err error @@ -358,9 +347,29 @@ func (m *MarketMaker) loginAndUnlockWallets(pw []byte, cfgs []*BotConfig) error return nil } +// duplicateBotConfig returns an error if there is more than one bot config for +// the same market on the same dex host. +func duplicateBotConfig(cfgs []*BotConfig) error { + mkts := make(map[string]struct{}) + + for _, cfg := range cfgs { + mkt := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + if _, found := mkts[mkt]; found { + return fmt.Errorf("duplicate bot config for market %s", mkt) + } + mkts[mkt] = struct{}{} + } + + return nil +} + func validateAndFilterEnabledConfigs(cfgs []*BotConfig) ([]*BotConfig, error) { enabledCfgs := make([]*BotConfig, 0, len(cfgs)) for _, cfg := range cfgs { + if cfg.requiresCEX() && cfg.CEXCfg == nil { + mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + return nil, fmt.Errorf("bot at %s requires cex config", mktID) + } if !cfg.Disabled { enabledCfgs = append(enabledCfgs, cfg) } @@ -374,73 +383,99 @@ func validateAndFilterEnabledConfigs(cfgs []*BotConfig) ([]*BotConfig, error) { return enabledCfgs, nil } -// setupBalances makes sure there is sufficient balance to cover all the bots, -// and populates the botBalances map. -func (m *MarketMaker) setupBalances(cfgs []*BotConfig) error { +// setupBalances makes sure there is sufficient balance in both the dex +// client wallets and on the CEXes, and populates m.botBalances. +func (m *MarketMaker) setupBalances(cfgs []*BotConfig, cexes map[string]libxc.CEX) error { m.botBalances = make(map[string]*botBalances, len(cfgs)) type trackedBalance struct { - balanceAvailable uint64 - balanceReserved uint64 + available uint64 + reserved uint64 } - balanceTracker := make(map[uint32]*trackedBalance) - trackAsset := func(assetID uint32) error { - if _, found := balanceTracker[assetID]; found { + dexBalanceTracker := make(map[uint32]*trackedBalance) + cexBalanceTracker := make(map[string]map[string]*trackedBalance) + + trackAssetOnDEX := func(assetID uint32) error { + if _, found := dexBalanceTracker[assetID]; found { return nil } bal, err := m.core.AssetBalance(assetID) if err != nil { return fmt.Errorf("failed to get balance for asset %d: %v", assetID, err) } - balanceTracker[assetID] = &trackedBalance{ - balanceAvailable: bal.Available, + dexBalanceTracker[assetID] = &trackedBalance{ + available: bal.Available, } return nil } - for _, cfg := range cfgs { - err := trackAsset(cfg.BaseAsset) - if err != nil { - return err + trackAssetOnCEX := func(assetSymbol string, assetID uint32, cexName string) error { + cexBalances, found := cexBalanceTracker[cexName] + if !found { + cexBalanceTracker[cexName] = make(map[string]*trackedBalance) + cexBalances = cexBalanceTracker[cexName] + } + + if _, found := cexBalances[assetSymbol]; found { + return nil } - err = trackAsset(cfg.QuoteAsset) + + cex, found := cexes[cexName] + if !found { + return fmt.Errorf("no cex config for %s", cexName) + } + + // TODO: what if conversion factors of an asset on different chains + // are different? currently they are all the same. + balance, err := cex.Balance(assetID) if err != nil { return err } - baseBalance := balanceTracker[cfg.BaseAsset] - quoteBalance := balanceTracker[cfg.QuoteAsset] + cexBalances[assetSymbol] = &trackedBalance{ + available: balance.Available, + } - var baseRequired, quoteRequired uint64 - if cfg.BaseBalanceType == Percentage { - baseRequired = baseBalance.balanceAvailable * cfg.BaseBalance / 100 - } else { - baseRequired = cfg.BaseBalance + return nil + } + + calcBalance := func(balType BalanceType, balAmount, availableBal uint64) uint64 { + if balType == Percentage { + return availableBal * balAmount / 100 } + return balAmount + } - if cfg.QuoteBalanceType == Percentage { - quoteRequired = quoteBalance.balanceAvailable * cfg.QuoteBalance / 100 - } else { - quoteRequired = cfg.QuoteBalance + for _, cfg := range cfgs { + err := trackAssetOnDEX(cfg.BaseAsset) + if err != nil { + return err + } + err = trackAssetOnDEX(cfg.QuoteAsset) + if err != nil { + return err } + mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + + // Calculate DEX balances + baseBalance := dexBalanceTracker[cfg.BaseAsset] + quoteBalance := dexBalanceTracker[cfg.QuoteAsset] + baseRequired := calcBalance(cfg.BaseBalanceType, cfg.BaseBalance, baseBalance.available) + quoteRequired := calcBalance(cfg.QuoteBalanceType, cfg.QuoteBalance, quoteBalance.available) if baseRequired == 0 && quoteRequired == 0 { - return fmt.Errorf("both base and quote balance are zero for market %s-%d-%d", - cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + return fmt.Errorf("both base and quote balance are zero for market %s", mktID) } - - if baseRequired > baseBalance.balanceAvailable-baseBalance.balanceReserved { + if baseRequired > baseBalance.available-baseBalance.reserved { return fmt.Errorf("insufficient balance for asset %d", cfg.BaseAsset) } - if quoteRequired > quoteBalance.balanceAvailable-quoteBalance.balanceReserved { + if quoteRequired > quoteBalance.available-quoteBalance.reserved { return fmt.Errorf("insufficient balance for asset %d", cfg.QuoteAsset) } + baseBalance.reserved += baseRequired + quoteBalance.reserved += quoteRequired - baseBalance.balanceReserved += baseRequired - quoteBalance.balanceReserved += quoteRequired - - mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) m.botBalances[mktID] = &botBalances{ balances: map[uint32]*botBalance{ cfg.BaseAsset: { @@ -451,12 +486,49 @@ func (m *MarketMaker) setupBalances(cfgs []*BotConfig) error { }, }, } + + // Calculate CEX balances + if cfg.CEXCfg != nil { + baseSymbol := dex.BipIDSymbol(cfg.BaseAsset) + if baseSymbol == "" { + return fmt.Errorf("unknown asset ID %d", cfg.BaseAsset) + } + baseAssetSymbol := dex.TokenSymbol(baseSymbol) + + quoteSymbol := dex.BipIDSymbol(cfg.QuoteAsset) + if quoteSymbol == "" { + return fmt.Errorf("unknown asset ID %d", cfg.QuoteAsset) + } + quoteAssetSymbol := dex.TokenSymbol(quoteSymbol) + + trackAssetOnCEX(baseAssetSymbol, cfg.BaseAsset, cfg.CEXCfg.Name) + trackAssetOnCEX(quoteAssetSymbol, cfg.QuoteAsset, cfg.CEXCfg.Name) + baseCEXBalance := cexBalanceTracker[cfg.CEXCfg.Name][baseAssetSymbol] + quoteCEXBalance := cexBalanceTracker[cfg.CEXCfg.Name][quoteAssetSymbol] + cexBaseRequired := calcBalance(cfg.CEXCfg.BaseBalanceType, cfg.CEXCfg.BaseBalance, baseCEXBalance.available) + cexQuoteRequired := calcBalance(cfg.QuoteBalanceType, cfg.QuoteBalance, quoteCEXBalance.available) + if cexBaseRequired == 0 && cexQuoteRequired == 0 { + return fmt.Errorf("both base and quote CEX balances are zero for market %s", mktID) + } + if cexBaseRequired > baseCEXBalance.available-baseCEXBalance.reserved { + return fmt.Errorf("insufficient CEX base balance for asset %d", cfg.BaseAsset) + } + if cexQuoteRequired > quoteCEXBalance.available-quoteCEXBalance.reserved { + return fmt.Errorf("insufficient CEX quote balance for asset %d", cfg.QuoteAsset) + } + baseCEXBalance.reserved += cexBaseRequired + quoteCEXBalance.reserved += cexQuoteRequired + m.botBalances[mktID].cexBalances = map[uint32]uint64{ + cfg.BaseAsset: cexBaseRequired, + cfg.QuoteAsset: cexQuoteRequired, + } + } } return nil } -// isAccountLocker returns if the asset is an account locker. +// isAccountLocker returns if the asset's wallet is an asset.AccountLocker. func (m *MarketMaker) isAccountLocker(assetID uint32) bool { walletState := m.core.WalletState(assetID) if walletState == nil { @@ -467,6 +539,17 @@ func (m *MarketMaker) isAccountLocker(assetID uint32) bool { return walletState.Traits.IsAccountLocker() } +// isWithdrawer returns if the asset's wallet is an asset.Withdrawer. +func (m *MarketMaker) isWithdrawer(assetID uint32) bool { + walletState := m.core.WalletState(assetID) + if walletState == nil { + m.log.Errorf("isAccountLocker: wallet state not found for asset %d", assetID) + return false + } + + return walletState.Traits.IsWithdrawer() +} + type botBalanceType uint8 const ( @@ -550,6 +633,56 @@ func (m *MarketMaker) botBalance(botID string, assetID uint32) uint64 { return 0 } +func (m *MarketMaker) modifyBotCEXBalance(botID string, assetID uint32, amount uint64, increase bool) { + bb := m.botBalances[botID] + if bb == nil { + m.log.Errorf("modifyBotCEXBalance: bot %s not found", botID) + return + } + + bb.mtx.RLock() + defer bb.mtx.RUnlock() + + if _, found := bb.cexBalances[assetID]; !found { + // Even if the balance is 0, it should have been defined in + // setupBalances. + m.log.Errorf("modifyBotCEXBalance: bot %s does not have balance for asset %d", botID, assetID) + return + } + + if increase { + bb.cexBalances[assetID] += amount + return + } + + if bb.cexBalances[assetID] < amount { + m.log.Errorf("modifyBotCEXBalance: bot %s: decreasing asset %d balance by %d but only have %d", + botID, assetID, amount, bb.cexBalances[assetID]) + bb.cexBalances[assetID] = 0 + return + } + + bb.cexBalances[assetID] -= amount +} + +func (m *MarketMaker) botCEXBalance(botID string, assetID uint32) uint64 { + bb := m.botBalances[botID] + if bb == nil { + m.log.Errorf("balance: bot %s not found", botID) + return 0 + } + + bb.mtx.RLock() + defer bb.mtx.RUnlock() + + if _, found := bb.cexBalances[assetID]; found { + return bb.cexBalances[assetID] + } + + m.log.Errorf("botCEXBalance: asset %d not found for bot %s", assetID, botID) + return 0 +} + func (m *MarketMaker) getOrderInfo(id dex.Bytes) *orderInfo { var oid order.OrderID copy(oid[:], id) @@ -840,11 +973,40 @@ func (m *MarketMaker) handleNotification(n core.Notification) { } } +func (m *MarketMaker) initCEXConnections(cfgs []*CEXConfig) (map[string]libxc.CEX, map[string]*dex.ConnectionMaster) { + cexes := make(map[string]libxc.CEX) + cexCMs := make(map[string]*dex.ConnectionMaster) + + for _, cfg := range cfgs { + if _, found := cexes[cfg.Name]; !found { + logger := m.log.SubLogger(fmt.Sprintf("CEX-%s", cfg.Name)) + cex, err := libxc.NewCEX(cfg.Name, cfg.APIKey, cfg.APISecret, logger, dex.Simnet) + if err != nil { + m.log.Errorf("Failed to create %s: %v", cfg.Name, err) + continue + } + + cm := dex.NewConnectionMaster(cex) + err = cm.Connect(m.ctx) + if err != nil { + m.log.Errorf("Failed to connect to %s: %v", cfg.Name, err) + continue + } + + cexes[cfg.Name] = cex + cexCMs[cfg.Name] = cm + } + } + + return cexes, cexCMs +} + // Run starts the MarketMaker. There can only be one BotConfig per dex market. func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *string) error { if !m.running.CompareAndSwap(false, true) { return errors.New("market making is already running") } + path := m.cfgPath if alternateConfigPath != nil { path = *alternateConfigPath @@ -876,7 +1038,6 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s if err != nil { return err } - m.syncedOracleMtx.Lock() m.syncedOracle = oracle m.syncedOracleMtx.Unlock() @@ -886,14 +1047,13 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s m.syncedOracleMtx.Unlock() }() - if err := m.setupBalances(enabledBots); err != nil { + cexes, cexCMs := m.initCEXConnections(cfg.CexConfigs) + + if err := m.setupBalances(enabledBots, cexes); err != nil { return err } - user := m.core.User() - cexes := make(map[string]libxc.CEX) - cexCMs := make(map[string]*dex.ConnectionMaster) - + fiatRates := m.core.FiatConversionRates() startedMarketMaking = true m.core.Broadcast(newMMStartStopNote(true)) @@ -924,33 +1084,6 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s } } - getConnectedCEX := func(cexName string) (libxc.CEX, error) { - var cex libxc.CEX - var found bool - if cex, found = cexes[cexName]; !found { - cexCfg := cexCfgMap[cexName] - if cexCfg == nil { - return nil, fmt.Errorf("no CEX config provided for %s", cexName) - } - logger := m.log.SubLogger(fmt.Sprintf("CEX-%s", cexName)) - cex, err = libxc.NewCEX(cexName, cexCfg.APIKey, cexCfg.APISecret, logger, dex.Simnet) - if err != nil { - return nil, fmt.Errorf("failed to create CEX: %v", err) - } - cm := dex.NewConnectionMaster(cex) - if err != nil { - return nil, fmt.Errorf("failed to connect to CEX: %v", err) - } - cexCMs[cexName] = cm - err = cm.Connect(m.ctx) - if err != nil { - return nil, fmt.Errorf("failed to connect to CEX: %v", err) - } - cexes[cexName] = cex - } - return cex, nil - } - for _, cfg := range enabledBots { switch { case cfg.BasicMMConfig != nil: @@ -969,11 +1102,8 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s }() logger := m.log.SubLogger(fmt.Sprintf("MarketMaker-%s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset)) mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) - var baseFiatRate, quoteFiatRate float64 - if user != nil { - baseFiatRate = user.FiatRates[cfg.BaseAsset] - quoteFiatRate = user.FiatRates[cfg.QuoteAsset] - } + baseFiatRate := fiatRates[cfg.BaseAsset] + quoteFiatRate := fiatRates[cfg.QuoteAsset] RunBasicMarketMaker(m.ctx, cfg, m.wrappedCoreForBot(mktID), oracle, baseFiatRate, quoteFiatRate, logger) }(cfg) case cfg.SimpleArbConfig != nil: @@ -986,7 +1116,18 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s logger.Errorf("Failed to connect to CEX: %v", err) return } - RunSimpleArbBot(m.ctx, cfg, m.core, cex, logger) + mkt := MarketWithHost{cfg.Host, cfg.BaseAsset, cfg.QuoteAsset} + m.markBotAsRunning(mkt, true) + defer func() { + m.markBotAsRunning(mkt, false) + }() + mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + cex, found := cexes[cfg.CEXCfg.Name] + if !found { + logger.Errorf("Cannot start %s bot due to CEX not starting", mktID) + return + } + RunSimpleArbBot(m.ctx, cfg, m.wrappedCoreForBot(mktID), m.wrappedCEXForBot(mktID, cex), logger) }(cfg) case cfg.ArbMarketMakerConfig != nil: wg.Add(1) diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go index 89e2ee66df..d1fb81b576 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -2,17 +2,20 @@ package mm import ( "context" + "encoding/hex" "fmt" "math/rand" "os" "path/filepath" "reflect" + "sync" "testing" "time" "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/db" + "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" @@ -42,6 +45,7 @@ var ( MaxFeeRate: 2, SwapConf: 1, } + tACCTAsset = &dex.Asset{ ID: 60, Symbol: "eth", @@ -52,6 +56,7 @@ var ( MaxFeeRate: 20, SwapConf: 1, } + tWalletInfo = &asset.WalletInfo{ Version: 0, SupportedVersions: []uint32{0}, @@ -66,21 +71,6 @@ var ( } ) -type tCreator struct { - *tDriver - doesntExist bool - existsErr error - createErr error -} - -func (ctr *tCreator) Exists(walletType, dataDir string, settings map[string]string, net dex.Network) (bool, error) { - return !ctr.doesntExist, ctr.existsErr -} - -func (ctr *tCreator) Create(*asset.CreateWalletParams) error { - return ctr.createErr -} - func init() { asset.Register(tUTXOAssetA.ID, &tDriver{ decodedCoinID: tUTXOAssetA.Symbol + "-coin", @@ -101,6 +91,21 @@ func init() { rand.Seed(time.Now().UnixNano()) } +type tCreator struct { + *tDriver + doesntExist bool + existsErr error + createErr error +} + +func (ctr *tCreator) Exists(walletType, dataDir string, settings map[string]string, net dex.Network) (bool, error) { + return !ctr.doesntExist, ctr.existsErr +} + +func (ctr *tCreator) Create(*asset.CreateWalletParams) error { + return ctr.createErr +} + type tDriver struct { wallet asset.Wallet decodedCoinID string @@ -127,6 +132,13 @@ func (t *tBookFeed) Next() <-chan *core.BookUpdate { return t.c } func (t *tBookFeed) Close() {} func (t *tBookFeed) Candles(dur string) error { return nil } +type sendArgs struct { + assetID uint32 + value uint64 + address string + subtract bool +} + type tCore struct { assetBalances map[uint32]*core.WalletBalance assetBalanceErr error @@ -144,6 +156,7 @@ type tCore struct { multiTradeResult []*core.Order noteFeed chan core.Notification isAccountLocker map[uint32]bool + isWithdrawer map[uint32]bool maxBuyEstimate *core.MaxOrderEstimate maxBuyErr error maxSellEstimate *core.MaxOrderEstimate @@ -155,8 +168,31 @@ type tCore struct { maxFundingFees uint64 book *orderbook.OrderBook bookFeed *tBookFeed + lastSendArgs *sendArgs + sendTxID string + txConfs uint32 + txConfsErr error + txConfsTxID string + newDepositAddress string +} + +func newTCore() *tCore { + return &tCore{ + assetBalances: make(map[uint32]*core.WalletBalance), + noteFeed: make(chan core.Notification), + isAccountLocker: make(map[uint32]bool), + isWithdrawer: make(map[uint32]bool), + cancelsPlaced: make([]dex.Bytes, 0), + buysPlaced: make([]*core.TradeForm, 0), + sellsPlaced: make([]*core.TradeForm, 0), + bookFeed: &tBookFeed{ + c: make(chan *core.BookUpdate, 1), + }, + } } +var _ clientCore = (*tCore)(nil) + func (c *tCore) NotificationFeed() *core.NoteFeed { return &core.NoteFeed{C: c.noteFeed} } @@ -181,8 +217,8 @@ func (c *tCore) SingleLotFees(form *core.SingleLotFeesForm) (uint64, uint64, uin } return c.buySwapFees, c.buyRedeemFees, c.buyRefundFees, nil } -func (t *tCore) Cancel(oidB dex.Bytes) error { - t.cancelsPlaced = append(t.cancelsPlaced, oidB) +func (c *tCore) Cancel(oidB dex.Bytes) error { + c.cancelsPlaced = append(c.cancelsPlaced, oidB) return nil } func (c *tCore) Trade(pw []byte, form *core.TradeForm) (*core.Order, error) { @@ -216,14 +252,17 @@ func (c *tCore) MultiTrade(pw []byte, forms *core.MultiTradeForm) ([]*core.Order c.multiTradesPlaced = append(c.multiTradesPlaced, forms) return c.multiTradeResult, nil } - func (c *tCore) WalletState(assetID uint32) *core.WalletState { isAccountLocker := c.isAccountLocker[assetID] + isWithdrawer := c.isWithdrawer[assetID] var traits asset.WalletTrait if isAccountLocker { traits |= asset.WalletTraitAccountLocker } + if isWithdrawer { + traits |= asset.WalletTraitWithdrawer + } return &core.WalletState{ Traits: traits, @@ -238,18 +277,29 @@ func (c *tCore) Login(pw []byte) error { func (c *tCore) OpenWallet(assetID uint32, pw []byte) error { return nil } - func (c *tCore) User() *core.User { return nil } - func (c *tCore) Broadcast(core.Notification) {} - func (c *tCore) FiatConversionRates() map[uint32]float64 { return nil } - -var _ clientCore = (*tCore)(nil) +func (c *tCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) { + c.lastSendArgs = &sendArgs{ + assetID: assetID, + value: value, + address: address, + subtract: subtract, + } + return c.sendTxID, nil, nil +} +func (c *tCore) NewDepositAddress(assetID uint32) (string, error) { + return c.newDepositAddress, nil +} +func (c *tCore) TransactionConfirmations(assetID uint32, txID string) (confirmations uint32, err error) { + c.txConfsTxID = txID + return c.txConfs, c.txConfsErr +} func tMaxOrderEstimate(lots uint64, swapFees, redeemFees uint64) *core.MaxOrderEstimate { return &core.MaxOrderEstimate{ @@ -283,20 +333,6 @@ func (c *tCore) clearTradesAndCancels() { c.multiTradesPlaced = make([]*core.MultiTradeForm, 0) } -func newTCore() *tCore { - return &tCore{ - assetBalances: make(map[uint32]*core.WalletBalance), - noteFeed: make(chan core.Notification), - isAccountLocker: make(map[uint32]bool), - cancelsPlaced: make([]dex.Bytes, 0), - buysPlaced: make([]*core.TradeForm, 0), - sellsPlaced: make([]*core.TradeForm, 0), - bookFeed: &tBookFeed{ - c: make(chan *core.BookUpdate, 1), - }, - } -} - type tOrderBook struct { midGap uint64 midGapErr error @@ -365,14 +401,18 @@ func TestSetupBalances(t *testing.T) { dcrEthID := fmt.Sprintf("%s-%d-%d", "host1", 42, 60) type ttest struct { - name string - cfgs []*BotConfig + name string + cfgs []*BotConfig + assetBalances map[uint32]uint64 + cexBalances map[string]map[uint32]uint64 - wantReserves map[string]map[uint32]uint64 - wantErr bool + wantReserves map[string]map[uint32]uint64 + wantCEXReserves map[string]map[uint32]uint64 + wantErr bool } tests := []*ttest{ + // "percentages only, ok" { name: "percentages only, ok", cfgs: []*BotConfig{ @@ -413,7 +453,7 @@ func TestSetupBalances(t *testing.T) { }, }, }, - + // "50% + 51% error" { name: "50% + 51% error", cfgs: []*BotConfig{ @@ -445,7 +485,7 @@ func TestSetupBalances(t *testing.T) { wantErr: true, }, - + // "combine amount and percentages, ok" { name: "combine amount and percentages, ok", cfgs: []*BotConfig{ @@ -486,6 +526,7 @@ func TestSetupBalances(t *testing.T) { }, }, }, + // "combine amount and percentages, too high error" { name: "combine amount and percentages, too high error", cfgs: []*BotConfig{ @@ -517,120 +558,553 @@ func TestSetupBalances(t *testing.T) { wantErr: true, }, - } - - runTest := func(test *ttest) { - tCore.setAssetBalances(test.assetBalances) - - mm, done := tNewMarketMaker(t, tCore) - defer done() - - err := mm.setupBalances(test.cfgs) - if test.wantErr { - if err == nil { - t.Fatalf("%s: expected error, got nil", test.name) - } - return - } - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } - - for botID, wantReserve := range test.wantReserves { - botReserves := mm.botBalances[botID] - for assetID, wantReserve := range wantReserve { - if botReserves.balances[assetID].Available != wantReserve { - t.Fatalf("%s: unexpected reserve for bot %s, asset %d. "+ - "want %d, got %d", test.name, botID, assetID, wantReserve, - botReserves.balances[assetID]) - } - } - } - } - - for _, test := range tests { - runTest(test) - } -} + // "CEX percentages only, ok" + { + name: "CEX percentages only, ok", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 60, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: "Kraken", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, + }, + }, -func TestSegregatedCoreMaxSell(t *testing.T) { - tCore := newTCore() - tCore.isAccountLocker[60] = true - dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) - dcrEthID := fmt.Sprintf("%s-%d-%d", "host1", 42, 60) + assetBalances: map[uint32]uint64{ + 0: 1000, + 42: 1000, + 60: 2000, + }, - // Whatever is returned from PreOrder is returned from this function. - // What we need to test is what is passed to PreOrder. - orderEstimate := &core.OrderEstimate{ - Swap: &asset.PreSwap{ - Estimate: &asset.SwapEstimate{ - Lots: 5, - Value: 5e8, - MaxFees: 1600, - RealisticWorstCase: 12010, - RealisticBestCase: 6008, + cexBalances: map[string]map[uint32]uint64{ + "Binance": { + 42: 2000, + 0: 3000, + }, + "Kraken": { + 42: 4000, + 60: 2000, + }, }, - }, - Redeem: &asset.PreRedeem{ - Estimate: &asset.RedeemEstimate{ - RealisticBestCase: 2800, - RealisticWorstCase: 6500, + + wantReserves: map[string]map[uint32]uint64{ + dcrBtcID: { + 0: 500, + 42: 500, + }, + dcrEthID: { + 42: 500, + 60: 2000, + }, }, - }, - } - tCore.orderEstimate = orderEstimate - expectedResult := &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - Value: 5e8, - MaxFees: 1600, - RealisticWorstCase: 12010, - RealisticBestCase: 6008, - }, - Redeem: &asset.RedeemEstimate{ - RealisticBestCase: 2800, - RealisticWorstCase: 6500, + wantCEXReserves: map[string]map[uint32]uint64{ + dcrBtcID: { + 0: 1500, + 42: 1000, + }, + dcrEthID: { + 42: 2000, + 60: 2000, + }, + }, }, - } - - tests := []struct { - name string - cfg *BotConfig - assetBalances map[uint32]uint64 - market *core.Market - swapFees uint64 - redeemFees uint64 - refundFees uint64 - - expectPreOrderParam *core.TradeForm - wantErr bool - }{ + // "CEX 50% + 51% error" { - name: "ok", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, + name: "CEX 50% + 51% error", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 60, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 51, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, }, + assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, + 0: 1000, + 42: 1000, + 60: 2000, }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 4 * 1e6, + + cexBalances: map[string]map[uint32]uint64{ + "Binance": { + 42: 2000, + 60: 1000, + 0: 3000, + }, + }, + + wantErr: true, + }, + // "CEX combine amount and percentages, ok" + { + name: "CEX combine amount and percentages, ok", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 60, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Amount, + BaseBalance: 600, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, + }, + }, + + assetBalances: map[uint32]uint64{ + 0: 1000, + 42: 1000, + 60: 2000, + }, + + cexBalances: map[string]map[uint32]uint64{ + "Binance": { + 42: 2000, + 0: 3000, + 60: 2000, + }, + "Kraken": { + 42: 4000, + 60: 2000, + }, + }, + + wantReserves: map[string]map[uint32]uint64{ + dcrBtcID: { + 0: 500, + 42: 500, + }, + dcrEthID: { + 42: 500, + 60: 2000, + }, + }, + + wantCEXReserves: map[string]map[uint32]uint64{ + dcrBtcID: { + 0: 1500, + 42: 1000, + }, + dcrEthID: { + 42: 600, + 60: 2000, + }, + }, + }, + + // "CEX combine amount and percentages" + { + name: "CEX combine amount and percentages, too high error", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 60, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Amount, + BaseBalance: 1501, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, + }, + }, + + assetBalances: map[uint32]uint64{ + 0: 1000, + 42: 1000, + 60: 2000, + }, + + cexBalances: map[string]map[uint32]uint64{ + "Binance": { + 42: 2000, + 0: 3000, + 60: 2000, + }, + "Kraken": { + 42: 4000, + 60: 2000, + }, + }, + + wantErr: true, + }, + + // "CEX same asset on different chains" + { + name: "CEX combine amount and percentages, too high error", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 60001, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, + { + Host: "host1", + BaseAsset: 966001, + QuoteAsset: 60, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, + }, + + assetBalances: map[uint32]uint64{ + 0: 1000, + 60: 2000, + 60001: 2000, + 966001: 2000, + }, + + cexBalances: map[string]map[uint32]uint64{ + "Binance": { + 0: 3000, + 60: 2000, + 60001: 2000, + 966001: 2000, + 61001: 2000, + }, + }, + + wantReserves: map[string]map[uint32]uint64{ + dexMarketID("host1", 60001, 0): { + 60001: 1000, + 0: 500, + }, + dexMarketID("host1", 966001, 60): { + 966001: 1000, + 60: 1000, + }, + }, + + wantCEXReserves: map[string]map[uint32]uint64{ + dexMarketID("host1", 60001, 0): { + 60001: 1000, + 0: 1500, + }, + dexMarketID("host1", 966001, 60): { + 966001: 1000, + 60: 1000, + }, + }, + }, + + // "CEX same asset on different chains, too high error" + { + name: "CEX combine amount and percentages, too high error", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 60001, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, + { + Host: "host1", + BaseAsset: 966001, + QuoteAsset: 60, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 51, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, + }, + }, + + assetBalances: map[uint32]uint64{ + 0: 1000, + 60: 2000, + 60001: 2000, + 966001: 2000, + }, + + cexBalances: map[string]map[uint32]uint64{ + "Binance": { + 0: 3000, + 60: 2000, + 60001: 2000, + 966001: 2000, + 61001: 2000, + }, + }, + + wantErr: true, + }, + } + + runTest := func(test *ttest) { + tCore.setAssetBalances(test.assetBalances) + + mm, done := tNewMarketMaker(t, tCore) + defer done() + + cexes := make(map[string]libxc.CEX) + for cexName, balances := range test.cexBalances { + cex := newTCEX() + cexes[cexName] = cex + cex.balances = make(map[uint32]*libxc.ExchangeBalance) + for assetID, balance := range balances { + cex.balances[assetID] = &libxc.ExchangeBalance{ + Available: balance, + } + } + } + + err := mm.setupBalances(test.cfgs, cexes) + if test.wantErr { + if err == nil { + t.Fatalf("%s: expected error, got nil", test.name) + } + return + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + for botID, wantReserve := range test.wantReserves { + botReserves := mm.botBalances[botID] + for assetID, wantReserve := range wantReserve { + if botReserves.balances[assetID].Available != wantReserve { + t.Fatalf("%s: unexpected reserve for bot %s, asset %d. "+ + "want %d, got %d", test.name, botID, assetID, wantReserve, + botReserves.balances[assetID]) + } + } + + wantCEXReserves := test.wantCEXReserves[botID] + for assetID, wantReserve := range wantCEXReserves { + if botReserves.cexBalances[assetID] != wantReserve { + t.Fatalf("%s: unexpected cex reserve for bot %s, asset %d. "+ + "want %d, got %d", test.name, botID, assetID, wantReserve, + botReserves.cexBalances[assetID]) + } + } + } + } + + for _, test := range tests { + runTest(test) + } +} + +func TestSegregatedCoreMaxSell(t *testing.T) { + tCore := newTCore() + tCore.isAccountLocker[60] = true + dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) + dcrEthID := fmt.Sprintf("%s-%d-%d", "host1", 42, 60) + + // Whatever is returned from PreOrder is returned from this function. + // What we need to test is what is passed to PreOrder. + orderEstimate := &core.OrderEstimate{ + Swap: &asset.PreSwap{ + Estimate: &asset.SwapEstimate{ + Lots: 5, + Value: 5e8, + MaxFees: 1600, + RealisticWorstCase: 12010, + RealisticBestCase: 6008, + }, + }, + Redeem: &asset.PreRedeem{ + Estimate: &asset.RedeemEstimate{ + RealisticBestCase: 2800, + RealisticWorstCase: 6500, + }, + }, + } + tCore.orderEstimate = orderEstimate + + expectedResult := &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + Value: 5e8, + MaxFees: 1600, + RealisticWorstCase: 12010, + RealisticBestCase: 6008, + }, + Redeem: &asset.RedeemEstimate{ + RealisticBestCase: 2800, + RealisticWorstCase: 6500, + }, + } + + tests := []struct { + name string + cfg *BotConfig + assetBalances map[uint32]uint64 + market *core.Market + swapFees uint64 + redeemFees uint64 + refundFees uint64 + + expectPreOrderParam *core.TradeForm + wantErr bool + }{ + { + name: "ok", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 4 * 1e6, }, swapFees: 1000, redeemFees: 1000, @@ -812,7 +1286,7 @@ func TestSegregatedCoreMaxSell(t *testing.T) { t.Fatalf("%s: unexpected error: %v", test.name, err) } - err = mm.setupBalances([]*BotConfig{test.cfg}) + err = mm.setupBalances([]*BotConfig{test.cfg}, nil) if err != nil { t.Fatalf("%s: unexpected error: %v", test.name, err) } @@ -1119,7 +1593,7 @@ func TestSegregatedCoreMaxBuy(t *testing.T) { t.Fatalf("%s: unexpected error: %v", test.name, err) } - err = mm.setupBalances([]*BotConfig{test.cfg}) + err = mm.setupBalances([]*BotConfig{test.cfg}, nil) if err != nil { t.Fatalf("%s: unexpected error: %v", test.name, err) } @@ -3681,22 +4155,230 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { market: &core.Market{ LotSize: 5e6, }, - swapFees: 1000, - redeemFees: 1000, - isAccountLocker: map[uint32]bool{0: true}, - wantErr: true, + swapFees: 1000, + redeemFees: 1000, + isAccountLocker: map[uint32]bool{0: true}, + wantErr: true, + }, + // "edge enough balance for multi buy" + { + name: "edge enough balance for multi buy", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 5e6, + QuoteBalanceType: Amount, + QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2500, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e7, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + multiTradeRes: []*core.Order{{ + ID: id, + LockedAmt: calc.BaseToQuote(5e7, 5e6) + 1000, + RedeemLockedAmt: 0, + Sell: true, + FeesPaid: &core.FeeBreakdown{ + Funding: 400, + }, + }, { + ID: id2, + LockedAmt: calc.BaseToQuote(52e7, 5e6) + 1000, + RedeemLockedAmt: 0, + Sell: true, + }, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: 100, + FundingOrder: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, + }, + 42: { + Available: 5e6, + }, + }, + }, + // "edge not enough balance for multi buy" + { + name: "edge not enough balance for multi buy", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 5e6, + QuoteBalanceType: Amount, + QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2499, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e7, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + wantErr: true, + }, + // "edge enough balance for multi sell" + { + name: "edge enough balance for multi sell", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1e7 + 2500, + QuoteBalanceType: Amount, + QuoteBalance: 5e6, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e8, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Sell: true, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + multiTradeRes: []*core.Order{{ + ID: id, + LockedAmt: 5e6 + 1000, + RedeemLockedAmt: 0, + Sell: true, + FeesPaid: &core.FeeBreakdown{ + Funding: 400, + }, + }, { + ID: id2, + LockedAmt: 5e6 + 1000, + RedeemLockedAmt: 0, + Sell: true, + }, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: 5e6, + }, + 42: { + Available: 100, + FundingOrder: 1e7 + 2000, + }, + }, + }, + // "edge not enough balance for multi sell" + { + name: "edge not enough balance for multi sell", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1e7 + 2499, + QuoteBalanceType: Amount, + QuoteBalance: 5e6, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e8, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Sell: true, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + wantErr: true, }, - // "edge enough balance for multi buy" + // "edge enough balance for multi buy with redeem fees" { - name: "edge enough balance for multi buy", + name: "edge enough balance for multi buy with redeem fees", cfg: &BotConfig{ Host: "host1", BaseAsset: 42, QuoteAsset: 0, BaseBalanceType: Amount, - BaseBalance: 5e6, + BaseBalance: 2000, QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2500, + QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, }, assetBalances: map[uint32]uint64{ 0: 1e8, @@ -3721,45 +4403,43 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { market: &core.Market{ LotSize: 5e6, }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, + swapFees: 1000, + redeemFees: 1000, multiTradeRes: []*core.Order{{ ID: id, LockedAmt: calc.BaseToQuote(5e7, 5e6) + 1000, - RedeemLockedAmt: 0, + RedeemLockedAmt: 1000, Sell: true, - FeesPaid: &core.FeeBreakdown{ - Funding: 400, - }, }, { ID: id2, LockedAmt: calc.BaseToQuote(52e7, 5e6) + 1000, - RedeemLockedAmt: 0, + RedeemLockedAmt: 1000, Sell: true, }, }, postTradeBalances: map[uint32]*botBalance{ 0: { - Available: 100, + Available: 0, FundingOrder: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, }, 42: { - Available: 5e6, + Available: 0, + FundingOrder: 2000, }, }, + isAccountLocker: map[uint32]bool{42: true}, }, - // "edge not enough balance for multi buy" + // "edge not enough balance for multi buy due to redeem fees" { - name: "edge not enough balance for multi buy", + name: "edge not enough balance for multi buy due to redeem fees", cfg: &BotConfig{ Host: "host1", BaseAsset: 42, QuoteAsset: 0, BaseBalanceType: Amount, - BaseBalance: 5e6, + BaseBalance: 1999, QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2499, + QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, }, assetBalances: map[uint32]uint64{ 0: 1e8, @@ -3784,22 +4464,22 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { market: &core.Market{ LotSize: 5e6, }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, - wantErr: true, + swapFees: 1000, + redeemFees: 1000, + wantErr: true, + isAccountLocker: map[uint32]bool{42: true}, }, - // "edge enough balance for multi sell" + // "edge enough balance for multi sell with redeem fees" { - name: "edge enough balance for multi sell", + name: "edge enough balance for multi sell with redeem fees", cfg: &BotConfig{ Host: "host1", BaseAsset: 42, QuoteAsset: 0, BaseBalanceType: Amount, - BaseBalance: 1e7 + 2500, + BaseBalance: 1e7 + 2000, QuoteBalanceType: Amount, - QuoteBalance: 5e6, + QuoteBalance: 2000, }, assetBalances: map[uint32]uint64{ 0: 1e8, @@ -3825,387 +4505,875 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { market: &core.Market{ LotSize: 5e6, }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, + swapFees: 1000, + redeemFees: 1000, multiTradeRes: []*core.Order{{ ID: id, LockedAmt: 5e6 + 1000, - RedeemLockedAmt: 0, + RedeemLockedAmt: 1000, Sell: true, - FeesPaid: &core.FeeBreakdown{ - Funding: 400, - }, }, { ID: id2, LockedAmt: 5e6 + 1000, - RedeemLockedAmt: 0, + RedeemLockedAmt: 1000, Sell: true, }, }, + isAccountLocker: map[uint32]bool{0: true}, postTradeBalances: map[uint32]*botBalance{ 0: { - Available: 5e6, + Available: 0, + FundingOrder: 2000, }, 42: { - Available: 100, + Available: 0, FundingOrder: 1e7 + 2000, }, }, }, - // "edge not enough balance for multi sell" + // "edge not enough balance for multi sell due to redeem fees" + { + name: "edge enough balance for multi sell with redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1e7 + 2000, + QuoteBalanceType: Amount, + QuoteBalance: 1999, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e8, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Sell: true, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + isAccountLocker: map[uint32]bool{0: true}, + wantErr: true, + }, + } + + runTest := func(test *test) { + if test.multiTradeOnly && !testMultiTrade { + return + } + + mktID := dexMarketID(test.cfg.Host, test.cfg.BaseAsset, test.cfg.QuoteAsset) + + tCore := newTCore() + tCore.setAssetBalances(test.assetBalances) + tCore.market = test.market + var sell bool + if test.multiTradeOnly { + sell = test.multiTrade.Sell + } else { + sell = test.trade.Sell + } + + if sell { + tCore.sellSwapFees = test.swapFees + tCore.sellRedeemFees = test.redeemFees + tCore.sellRefundFees = test.refundFees + } else { + tCore.buySwapFees = test.swapFees + tCore.buyRedeemFees = test.redeemFees + tCore.buyRefundFees = test.refundFees + } + + if test.isAccountLocker == nil { + tCore.isAccountLocker = make(map[uint32]bool) + } else { + tCore.isAccountLocker = test.isAccountLocker + } + tCore.maxFundingFees = test.maxFundingFees + + if testMultiTrade { + if test.multiTradeOnly { + tCore.multiTradeResult = test.multiTradeRes + } else { + tCore.multiTradeResult = []*core.Order{test.tradeRes} + } + } else { + tCore.tradeResult = test.tradeRes + } + tCore.noteFeed = make(chan core.Notification) + + mm, done := tNewMarketMaker(t, tCore) + defer done() + mm.doNotKillWhenBotsStop = true + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + mm.UpdateBotConfig(test.cfg) + err := mm.Run(ctx, []byte{}, nil) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + segregatedCore := mm.wrappedCoreForBot(mktID) + + if testMultiTrade { + + if test.multiTradeOnly { + _, err = segregatedCore.MultiTrade([]byte{}, test.multiTrade) + } else { + _, err = segregatedCore.MultiTrade([]byte{}, &core.MultiTradeForm{ + Host: test.trade.Host, + Sell: test.trade.Sell, + Base: test.trade.Base, + Quote: test.trade.Quote, + Placements: []*core.QtyRate{ + { + Qty: test.trade.Qty, + Rate: test.trade.Rate, + }, + }, + Options: test.trade.Options, + }) + } + } else { + _, err = segregatedCore.Trade([]byte{}, test.trade) + } + if test.wantErr { + if err == nil { + t.Fatalf("%s: expected error but did not get", test.name) + } + return + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + if err := assetBalancesMatch(test.postTradeBalances, mktID, mm); err != nil { + t.Fatalf("%s: unexpected post trade balance: %v", test.name, err) + } + + dummyNote := &core.BondRefundNote{} + for i, noteAndBalances := range test.notifications { + tCore.noteFeed <- noteAndBalances.note + tCore.noteFeed <- dummyNote + + if err := assetBalancesMatch(noteAndBalances.balance, mktID, mm); err != nil { + t.Fatalf("%s: unexpected balances after note %d: %v", test.name, i, err) + } + } + } + + for _, test := range tests { + runTest(&test) + } +} + +func cexBalancesMatch(expected map[uint32]uint64, botName string, mm *MarketMaker) error { + for assetID, exp := range expected { + actual := mm.botBalances[botName].cexBalances[assetID] + if exp != actual { + return fmt.Errorf("asset %d expected %d != actual %d", assetID, exp, actual) + } + } + + return nil +} + +func TestSegregatedCEXTrade(t *testing.T) { + type noteAndBalances struct { + note *libxc.TradeUpdate + balances map[uint32]uint64 + } + + tradeID := "abc" + + type test struct { + name string + + cfg *BotConfig + assetBalances map[uint32]uint64 + cexBalances map[uint32]uint64 + baseAsset uint32 + quoteAsset uint32 + sell bool + rate uint64 + qty uint64 + postTradeBals map[uint32]uint64 + notes []*noteAndBalances + } + + tests := []test{ + // "sell trade fully filled" { - name: "edge not enough balance for multi sell", + name: "sell trade fully filled", cfg: &BotConfig{ - Host: "host1", + Host: "host", BaseAsset: 42, QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1e7 + 2499, - QuoteBalanceType: Amount, - QuoteBalance: 5e6, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, }, assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e8, + 42: 1e7, + 0: 1e7, }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Sell: true, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, + cexBalances: map[uint32]uint64{ + 42: 1e7, + 0: 1e7, + }, + baseAsset: 42, + quoteAsset: 0, + sell: true, + rate: 5e7, + qty: 2e6, + postTradeBals: map[uint32]uint64{ + 42: 1e7 - 2e6, + 0: 1e7, + }, + notes: []*noteAndBalances{ + { + note: &libxc.TradeUpdate{ + TradeID: tradeID, + BaseFilled: 1e6, + QuoteFilled: calc.BaseToQuote(5.1e7, 1e6), }, - { - Qty: 5e6, - Rate: 5e7, + balances: map[uint32]uint64{ + 42: 1e7 - 2e6, + 0: 1e7 + calc.BaseToQuote(5.1e7, 1e6), + }, + }, + { + note: &libxc.TradeUpdate{ + TradeID: tradeID, + BaseFilled: 2e6, + QuoteFilled: calc.BaseToQuote(5.05e7, 2e6), + Complete: true, + }, + balances: map[uint32]uint64{ + 42: 1e7 - 2e6, + 0: 1e7 + calc.BaseToQuote(5.05e7, 2e6), }, }, }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, - wantErr: true, }, - // "edge enough balance for multi buy with redeem fees" + // "buy trade fully filled" { - name: "edge enough balance for multi buy with redeem fees", + name: "buy trade fully filled", cfg: &BotConfig{ - Host: "host1", + Host: "host", BaseAsset: 42, QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 2000, - QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, }, assetBalances: map[uint32]uint64{ - 0: 1e8, 42: 1e7, + 0: 1e7, }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, - }, - { - Qty: 5e6, - Rate: 5e7, - }, - }, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - multiTradeRes: []*core.Order{{ - ID: id, - LockedAmt: calc.BaseToQuote(5e7, 5e6) + 1000, - RedeemLockedAmt: 1000, - Sell: true, - }, { - ID: id2, - LockedAmt: calc.BaseToQuote(52e7, 5e6) + 1000, - RedeemLockedAmt: 1000, - Sell: true, + cexBalances: map[uint32]uint64{ + 42: 1e7, + 0: 1e7, }, + baseAsset: 42, + quoteAsset: 0, + sell: false, + rate: 5e7, + qty: 2e6, + postTradeBals: map[uint32]uint64{ + 42: 1e7, + 0: 1e7 - calc.BaseToQuote(5e7, 2e6), }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: 0, - FundingOrder: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, + notes: []*noteAndBalances{ + { + note: &libxc.TradeUpdate{ + TradeID: tradeID, + BaseFilled: 1e6, + QuoteFilled: calc.BaseToQuote(4.9e7, 1e6), + }, + balances: map[uint32]uint64{ + 42: 1e7 + 1e6, + 0: 1e7 - calc.BaseToQuote(5e7, 2e6), + }, }, - 42: { - Available: 0, - FundingOrder: 2000, + { + note: &libxc.TradeUpdate{ + TradeID: tradeID, + BaseFilled: 2e6, + QuoteFilled: calc.BaseToQuote(4.95e7, 2e6), + Complete: true, + }, + balances: map[uint32]uint64{ + 42: 1e7 + 2e6, + 0: 1e7 - calc.BaseToQuote(4.95e7, 2e6), + }, }, }, - isAccountLocker: map[uint32]bool{42: true}, }, - // "edge not enough balance for multi buy due to redeem fees" + // "sell trade partially filled" { - name: "edge not enough balance for multi buy due to redeem fees", + name: "sell trade partially filled", cfg: &BotConfig{ - Host: "host1", + Host: "host", BaseAsset: 42, QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1999, - QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, }, assetBalances: map[uint32]uint64{ - 0: 1e8, 42: 1e7, + 0: 1e7, }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, + cexBalances: map[uint32]uint64{ + 42: 1e7, + 0: 1e7, + }, + baseAsset: 42, + quoteAsset: 0, + sell: true, + rate: 5e7, + qty: 2e6, + postTradeBals: map[uint32]uint64{ + 42: 1e7 - 2e6, + 0: 1e7, + }, + notes: []*noteAndBalances{ + { + note: &libxc.TradeUpdate{ + TradeID: tradeID, + BaseFilled: 1e6, + QuoteFilled: calc.BaseToQuote(5.1e7, 1e6), }, - { - Qty: 5e6, - Rate: 5e7, + balances: map[uint32]uint64{ + 42: 1e7 - 2e6, + 0: 1e7 + calc.BaseToQuote(5.1e7, 1e6), + }, + }, + { + note: &libxc.TradeUpdate{ + TradeID: tradeID, + BaseFilled: 1e6, + QuoteFilled: calc.BaseToQuote(5.1e7, 1e6), + Complete: true, + }, + balances: map[uint32]uint64{ + 42: 1e7 - 1e6, + 0: 1e7 + calc.BaseToQuote(5.1e7, 1e6), }, }, }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - wantErr: true, - isAccountLocker: map[uint32]bool{42: true}, }, - // "edge enough balance for multi sell with redeem fees" + // "buy trade partially filled" { - name: "edge enough balance for multi sell with redeem fees", + name: "buy trade partially filled", cfg: &BotConfig{ - Host: "host1", + Host: "host", BaseAsset: 42, QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1e7 + 2000, - QuoteBalanceType: Amount, - QuoteBalance: 2000, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, }, assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e8, + 42: 1e7, + 0: 1e7, }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Sell: true, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, + cexBalances: map[uint32]uint64{ + 42: 1e7, + 0: 1e7, + }, + baseAsset: 42, + quoteAsset: 0, + sell: false, + rate: 5e7, + qty: 2e6, + postTradeBals: map[uint32]uint64{ + 42: 1e7, + 0: 1e7 - calc.BaseToQuote(5e7, 2e6), + }, + notes: []*noteAndBalances{ + { + note: &libxc.TradeUpdate{ + TradeID: tradeID, + BaseFilled: 1e6, + QuoteFilled: calc.BaseToQuote(4.9e7, 1e6), }, - { - Qty: 5e6, - Rate: 5e7, + balances: map[uint32]uint64{ + 42: 1e7 + 1e6, + 0: 1e7 - calc.BaseToQuote(5e7, 2e6), + }, + }, + { + note: &libxc.TradeUpdate{ + TradeID: tradeID, + BaseFilled: 1e6, + QuoteFilled: calc.BaseToQuote(4.9e7, 1e6), + Complete: true, + }, + balances: map[uint32]uint64{ + 42: 1e7 + 1e6, + 0: 1e7 - calc.BaseToQuote(4.9e7, 1e6), }, }, }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - multiTradeRes: []*core.Order{{ - ID: id, - LockedAmt: 5e6 + 1000, - RedeemLockedAmt: 1000, - Sell: true, - }, { - ID: id2, - LockedAmt: 5e6 + 1000, - RedeemLockedAmt: 1000, - Sell: true, + }, + } + + runTest := func(tt test) { + tCore := newTCore() + tCore.setAssetBalances(tt.assetBalances) + mm, done := tNewMarketMaker(t, tCore) + defer done() + + cex := newTCEX() + cex.balances = make(map[uint32]*libxc.ExchangeBalance) + cex.tradeID = tradeID + for assetID, balance := range tt.cexBalances { + cex.balances[assetID] = &libxc.ExchangeBalance{ + Available: balance, + } + } + + botCfgs := []*BotConfig{tt.cfg} + cexes := map[string]libxc.CEX{ + tt.cfg.CEXCfg.Name: cex, + } + + mm.setupBalances(botCfgs, cexes) + + mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseAsset, tt.cfg.QuoteAsset) + wrappedCEX := mm.wrappedCEXForBot(mktID, cex) + + _, unsubscribe := wrappedCEX.SubscribeTradeUpdates() + defer unsubscribe() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, err := wrappedCEX.Trade(ctx, tt.baseAsset, tt.quoteAsset, tt.sell, tt.rate, tt.qty) + if err != nil { + t.Fatalf("%s: unexpected Trade error: %v", tt.name, err) + } + + err = cexBalancesMatch(tt.postTradeBals, mktID, mm) + if err != nil { + t.Fatalf("%s: post trade bals do not match: %v", tt.name, err) + } + + for i, note := range tt.notes { + cex.tradeUpdates <- note.note + // send dummy update + cex.tradeUpdates <- &libxc.TradeUpdate{ + TradeID: "", + } + err = cexBalancesMatch(note.balances, mktID, mm) + if err != nil { + t.Fatalf("%s: balances do not match after note %d: %v", tt.name, i, err) + } + } + } + + for _, test := range tests { + runTest(test) + } +} + +func TestSegregatedCEXDeposit(t *testing.T) { + cexName := "Binance" + + type test struct { + name string + dexBalances map[uint32]uint64 + cexBalances map[uint32]uint64 + cfg *BotConfig + depositAmt uint64 + depositAsset uint32 + cexConfirm bool + cexReceivedAmt uint64 + isWithdrawer bool + + expError bool + expDexBalances map[uint32]*botBalance + expCexBalances map[uint32]uint64 + } + + tests := []test{ + { + name: "ok", + dexBalances: map[uint32]uint64{ + 42: 1e8, + 0: 1e8, }, + cexBalances: map[uint32]uint64{ + 42: 1e7, + 0: 1e8, }, - isAccountLocker: map[uint32]bool{0: true}, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: 0, - FundingOrder: 2000, + cfg: &BotConfig{ + Host: "dex.com", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: cexName, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, }, + }, + depositAmt: 4e7, + depositAsset: 42, + cexConfirm: true, + cexReceivedAmt: 4e7 - 2000, + isWithdrawer: true, + expDexBalances: map[uint32]*botBalance{ 42: { - Available: 0, - FundingOrder: 1e7 + 2000, + Available: 1e8 - 4e7, + }, + 0: { + Available: 1e8, }, }, + expCexBalances: map[uint32]uint64{ + 42: 1e7 + 4e7 - 2000, + 0: 1e8, + }, }, - // "edge not enough balance for multi sell due to redeem fees" { - name: "edge enough balance for multi sell with redeem fees", + name: "insufficient balance", + dexBalances: map[uint32]uint64{ + 42: 4e7 - 1, + 0: 1e8, + }, + cexBalances: map[uint32]uint64{ + 42: 1e7, + 0: 1e8, + }, cfg: &BotConfig{ - Host: "host1", + Host: "dex.com", BaseAsset: 42, QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1e7 + 2000, - QuoteBalanceType: Amount, - QuoteBalance: 1999, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: cexName, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, }, - assetBalances: map[uint32]uint64{ - 0: 1e8, + depositAmt: 4e7, + depositAsset: 42, + cexConfirm: true, + cexReceivedAmt: 4e7 - 2000, + isWithdrawer: true, + expError: true, + }, + { + name: "cex failed to confirm deposit", + dexBalances: map[uint32]uint64{ 42: 1e8, + 0: 1e8, }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Sell: true, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, - }, - { - Qty: 5e6, - Rate: 5e7, - }, + cexBalances: map[uint32]uint64{ + 42: 1e7, + 0: 1e8, + }, + cfg: &BotConfig{ + Host: "dex.com", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: cexName, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, }, }, - market: &core.Market{ - LotSize: 5e6, + depositAmt: 4e7, + depositAsset: 42, + cexConfirm: false, + cexReceivedAmt: 4e7 - 2000, + isWithdrawer: true, + expDexBalances: map[uint32]*botBalance{ + 42: { + Available: 1e8 - 4e7, + }, + 0: { + Available: 1e8, + }, + }, + expCexBalances: map[uint32]uint64{ + 42: 1e7, + 0: 1e8, }, - swapFees: 1000, - redeemFees: 1000, - isAccountLocker: map[uint32]bool{0: true}, - wantErr: true, }, } - runTest := func(test *test) { - if test.multiTradeOnly && !testMultiTrade { - return + runTest := func(tt test) { + tCore := newTCore() + tCore.isWithdrawer[tt.depositAsset] = tt.isWithdrawer + tCore.setAssetBalances(tt.dexBalances) + + cex := newTCEX() + for assetID, balance := range tt.cexBalances { + cex.balances[assetID] = &libxc.ExchangeBalance{ + Available: balance, + } } + cex.depositConfirmed = tt.cexConfirm + cex.confirmDepositAmt = tt.cexReceivedAmt + cex.depositAddress = hex.EncodeToString(encode.RandomBytes(32)) - mktID := dexMarketID(test.cfg.Host, test.cfg.BaseAsset, test.cfg.QuoteAsset) + mm, done := tNewMarketMaker(t, tCore) + defer done() + mm.setupBalances([]*BotConfig{tt.cfg}, map[string]libxc.CEX{cexName: cex}) + mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseAsset, tt.cfg.QuoteAsset) + wrappedCEX := mm.wrappedCEXForBot(mktID, cex) - tCore := newTCore() - tCore.setAssetBalances(test.assetBalances) - tCore.market = test.market - var sell bool - if test.multiTradeOnly { - sell = test.multiTrade.Sell - } else { - sell = test.trade.Sell + wg := sync.WaitGroup{} + wg.Add(1) + onConfirm := func() { + wg.Done() } - - if sell { - tCore.sellSwapFees = test.swapFees - tCore.sellRedeemFees = test.redeemFees - tCore.sellRefundFees = test.refundFees - } else { - tCore.buySwapFees = test.swapFees - tCore.buyRedeemFees = test.redeemFees - tCore.buyRefundFees = test.refundFees + err := wrappedCEX.Deposit(context.Background(), tt.depositAsset, tt.depositAmt, onConfirm) + if err != nil { + if tt.expError { + return + } + t.Fatalf("%s: unexpected error: %v", tt.name, err) + } + if tt.expError { + t.Fatalf("%s: expected error but did not get", tt.name) } - if test.isAccountLocker == nil { - tCore.isAccountLocker = make(map[uint32]bool) - } else { - tCore.isAccountLocker = test.isAccountLocker + wg.Wait() + + if err := assetBalancesMatch(tt.expDexBalances, mktID, mm); err != nil { + t.Fatalf("%s: %v", tt.name, err) } - tCore.maxFundingFees = test.maxFundingFees + if err := cexBalancesMatch(tt.expCexBalances, mktID, mm); err != nil { + t.Fatalf("%s: %v", tt.name, err) + } + if tCore.lastSendArgs.address != cex.depositAddress { + t.Fatalf("%s: did not send to deposit address", tt.name) + } + if tCore.lastSendArgs.subtract != tt.isWithdrawer { + t.Fatalf("%s: withdrawer %v != subtract %v", tt.name, tt.isWithdrawer, tCore.lastSendArgs.subtract) + } + if tCore.lastSendArgs.value != tt.depositAmt { + t.Fatalf("%s: send value %d != expected %d", tt.name, tCore.lastSendArgs.value, tt.depositAmt) + } + } - if testMultiTrade { - if test.multiTradeOnly { - tCore.multiTradeResult = test.multiTradeRes - } else { - tCore.multiTradeResult = []*core.Order{test.tradeRes} + for _, test := range tests { + runTest(test) + } +} + +func TestSegregatedCEXWithdraw(t *testing.T) { + cexName := "Binance" + + type test struct { + name string + dexBalances map[uint32]uint64 + cexBalances map[uint32]uint64 + cfg *BotConfig + withdrawAmt uint64 + withdrawAsset uint32 + cexWithdrawnAmt uint64 + + expError bool + expDexBalances map[uint32]*botBalance + expCexBalances map[uint32]uint64 + } + + tests := []test{ + { + name: "ok", + dexBalances: map[uint32]uint64{ + 42: 1e7, + 0: 1e8, + }, + cexBalances: map[uint32]uint64{ + 42: 1e8, + 0: 1e8, + }, + cfg: &BotConfig{ + Host: "dex.com", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: cexName, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, + }, + withdrawAmt: 4e7, + withdrawAsset: 42, + cexWithdrawnAmt: 4e7 - 2000, + expDexBalances: map[uint32]*botBalance{ + 42: { + Available: 5e7 - 2000, + }, + 0: { + Available: 1e8, + }, + }, + expCexBalances: map[uint32]uint64{ + 42: 1e8 - 4e7, + 0: 1e8, + }, + }, + { + name: "insufficient balance", + dexBalances: map[uint32]uint64{ + 42: 1e8, + 0: 1e8, + }, + cexBalances: map[uint32]uint64{ + 42: 4e7 - 1, + 0: 1e8, + }, + cfg: &BotConfig{ + Host: "dex.com", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: cexName, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, + withdrawAmt: 4e7, + withdrawAsset: 42, + cexWithdrawnAmt: 4e7 - 2000, + expError: true, + }, + } + + runTest := func(tt test) { + tCore := newTCore() + tCore.setAssetBalances(tt.dexBalances) + tCore.newDepositAddress = hex.EncodeToString(encode.RandomBytes(32)) + + cex := newTCEX() + for assetID, balance := range tt.cexBalances { + cex.balances[assetID] = &libxc.ExchangeBalance{ + Available: balance, } - } else { - tCore.tradeResult = test.tradeRes } - tCore.noteFeed = make(chan core.Notification) + cex.withdrawAmt = tt.cexWithdrawnAmt + cex.withdrawTxID = hex.EncodeToString(encode.RandomBytes(32)) mm, done := tNewMarketMaker(t, tCore) defer done() - mm.doNotKillWhenBotsStop = true - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - mm.UpdateBotConfig(test.cfg) - err := mm.Run(ctx, []byte{}, nil) - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } - - segregatedCore := mm.wrappedCoreForBot(mktID) - - if testMultiTrade { + mm.setupBalances([]*BotConfig{tt.cfg}, map[string]libxc.CEX{cexName: cex}) + mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseAsset, tt.cfg.QuoteAsset) + wrappedCEX := mm.wrappedCEXForBot(mktID, cex) - if test.multiTradeOnly { - _, err = segregatedCore.MultiTrade([]byte{}, test.multiTrade) - } else { - _, err = segregatedCore.MultiTrade([]byte{}, &core.MultiTradeForm{ - Host: test.trade.Host, - Sell: test.trade.Sell, - Base: test.trade.Base, - Quote: test.trade.Quote, - Placements: []*core.QtyRate{ - { - Qty: test.trade.Qty, - Rate: test.trade.Rate, - }, - }, - Options: test.trade.Options, - }) - } - } else { - _, err = segregatedCore.Trade([]byte{}, test.trade) + wg := sync.WaitGroup{} + wg.Add(1) + onConfirm := func() { + wg.Done() } - if test.wantErr { - if err == nil { - t.Fatalf("%s: expected error but did not get", test.name) + err := wrappedCEX.Withdraw(context.Background(), tt.withdrawAsset, tt.withdrawAmt, onConfirm) + if err != nil { + if tt.expError { + return } - return + t.Fatalf("%s: unexpected error: %v", tt.name, err) } - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) + if tt.expError { + t.Fatalf("%s: expected error but did not get", tt.name) } + wg.Wait() - if err := assetBalancesMatch(test.postTradeBalances, mktID, mm); err != nil { - t.Fatalf("%s: unexpected post trade balance: %v", test.name, err) + if err := assetBalancesMatch(tt.expDexBalances, mktID, mm); err != nil { + t.Fatalf("%s: %v", tt.name, err) } - - dummyNote := &core.BondRefundNote{} - for i, noteAndBalances := range test.notifications { - tCore.noteFeed <- noteAndBalances.note - tCore.noteFeed <- dummyNote - - if err := assetBalancesMatch(noteAndBalances.balance, mktID, mm); err != nil { - t.Fatalf("%s: unexpected balances after note %d: %v", test.name, i, err) - } + if err := cexBalancesMatch(tt.expCexBalances, mktID, mm); err != nil { + t.Fatalf("%s: %v", tt.name, err) + } + if cex.lastWithdrawArgs.address != tCore.newDepositAddress { + t.Fatalf("%s: did not send to deposit address", tt.name) } } for _, test := range tests { - runTest(&test) + runTest(test) } } diff --git a/client/mm/wrapped_cex.go b/client/mm/wrapped_cex.go new file mode 100644 index 0000000000..e5f14f4717 --- /dev/null +++ b/client/mm/wrapped_cex.go @@ -0,0 +1,292 @@ +// 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 mm + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/client/mm/libxc" + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" +) + +type cexTrade struct { + qty uint64 + rate uint64 + sell bool + fromAsset uint32 + toAsset uint32 + baseFilled uint64 + quoteFilled uint64 +} + +type cex interface { + Balance(assetID uint32) (*libxc.ExchangeBalance, error) + CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error + SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error + SubscribeTradeUpdates() (updates <-chan *libxc.TradeUpdate, unsubscribe func()) + Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (string, error) + VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) + Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error + Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error +} + +// wrappedCEX implements the CEX interface. A separate instance should be +// created for each arbitrage bot, and it will behave as if the entire balance +// on the CEX is the amount that was allocated to the bot. +type wrappedCEX struct { + libxc.CEX + + mm *MarketMaker + botID string + log dex.Logger + + subscriptionIDMtx sync.RWMutex + subscriptionID *int + + tradesMtx sync.RWMutex + trades map[string]*cexTrade +} + +var _ cex = (*wrappedCEX)(nil) + +func (w *wrappedCEX) Balance(assetID uint32) (*libxc.ExchangeBalance, error) { + return &libxc.ExchangeBalance{ + Available: w.mm.botCEXBalance(w.botID, assetID), + }, nil +} + +func (w *wrappedCEX) Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { + balance := w.mm.botBalance(w.botID, assetID) + if balance < amount { + return fmt.Errorf("bot has insufficient balance to deposit %d. required: %v, have: %v", assetID, amount, balance) + } + + addr, err := w.CEX.GetDepositAddress(ctx, assetID) + if err != nil { + return err + } + + txID, _, err := w.mm.core.Send([]byte{}, assetID, amount, addr, w.mm.isWithdrawer(assetID)) + if err != nil { + return err + } + + // TODO: special handling for wallets that do not support withdrawing. + w.mm.modifyBotBalance(w.botID, []*balanceMod{{balanceModDecrease, assetID, balTypeAvailable, amount}}) + + go func() { + conf := func(confirmed bool, amt uint64) { + if confirmed { + w.mm.modifyBotCEXBalance(w.botID, assetID, amt, balanceModIncrease) + } + onConfirm() + } + w.CEX.ConfirmDeposit(ctx, txID, conf) + }() + + return nil +} + +func (w *wrappedCEX) Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { + symbol := dex.BipIDSymbol(assetID) + + balance := w.mm.botCEXBalance(w.botID, assetID) + if balance < amount { + return fmt.Errorf("bot has insufficient balance to withdraw %s. required: %v, have: %v", symbol, amount, balance) + } + + addr, err := w.mm.core.NewDepositAddress(assetID) + if err != nil { + return err + } + + conf := func(withdrawnAmt uint64, txID string) { + go func() { + checkTransaction := func() bool { + _, err := w.mm.core.TransactionConfirmations(assetID, txID) + if err == nil { + // Assign to balance to the bot as long as the wallet + // knows about the transaction. + w.mm.modifyBotBalance(w.botID, []*balanceMod{{balanceModIncrease, assetID, balTypeAvailable, withdrawnAmt}}) + onConfirm() + return true + } + if !errors.Is(err, asset.CoinNotFoundError) { + w.log.Errorf("error checking transaction confirmations: %v", err) + } + return false + } + + if checkTransaction() { + return + } + + ticker := time.NewTicker(time.Second * 20) + giveUp := time.NewTimer(time.Minute * 10) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if checkTransaction() { + return + } + case <-giveUp.C: + w.log.Errorf("timed out waiting for withdrawal confirmation") + return + } + } + }() + } + + err = w.CEX.Withdraw(ctx, assetID, amount, addr, conf) + if err != nil { + return err + } + + w.mm.modifyBotCEXBalance(w.botID, assetID, amount, balanceModDecrease) + return nil +} + +func (w *wrappedCEX) handleTradeUpdate(update *libxc.TradeUpdate) { + w.tradesMtx.Lock() + defer w.tradesMtx.Unlock() + + trade, found := w.trades[update.TradeID] + if !found { + w.log.Errorf("wrappedCEX: trade ID %s not found", update.TradeID) + return + } + + if trade.sell && update.QuoteFilled > trade.quoteFilled { + quoteFilledDelta := update.QuoteFilled - trade.quoteFilled + w.mm.modifyBotCEXBalance(w.botID, trade.toAsset, quoteFilledDelta, balanceModIncrease) + trade.quoteFilled = update.QuoteFilled + trade.baseFilled = update.BaseFilled + } + + if !trade.sell && update.BaseFilled > trade.baseFilled { + baseFilledDelta := update.BaseFilled - trade.baseFilled + w.mm.modifyBotCEXBalance(w.botID, trade.toAsset, baseFilledDelta, balanceModIncrease) + trade.baseFilled = update.BaseFilled + trade.quoteFilled = update.QuoteFilled + } + + if !update.Complete { + return + } + + if trade.sell && trade.qty > trade.baseFilled { + unfilledQty := trade.qty - trade.baseFilled + w.mm.modifyBotCEXBalance(w.botID, trade.fromAsset, unfilledQty, balanceModIncrease) + } + + if !trade.sell && calc.BaseToQuote(trade.rate, trade.qty) > trade.quoteFilled { + unfilledQty := calc.BaseToQuote(trade.rate, trade.qty) - trade.quoteFilled + w.mm.modifyBotCEXBalance(w.botID, trade.fromAsset, unfilledQty, balanceModIncrease) + } + + delete(w.trades, update.TradeID) +} + +func (w *wrappedCEX) SubscribeTradeUpdates() (<-chan *libxc.TradeUpdate, func()) { + w.subscriptionIDMtx.Lock() + defer w.subscriptionIDMtx.Unlock() + if w.subscriptionID != nil { + w.log.Errorf("SubscribeTradeUpdates called more than once by bot %s", w.botID) + return nil, nil + } + + updates, unsubscribe, subscriptionID := w.CEX.SubscribeTradeUpdates() + w.subscriptionID = &subscriptionID + + ctx, cancel := context.WithCancel(context.Background()) + forwardUnsubscribe := func() { + cancel() + unsubscribe() + } + forwardUpdates := make(chan *libxc.TradeUpdate, 256) + go func() { + for { + select { + case <-ctx.Done(): + return + case note := <-updates: + w.handleTradeUpdate(note) + forwardUpdates <- note + } + } + }() + + return forwardUpdates, forwardUnsubscribe +} + +func (w *wrappedCEX) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (string, error) { + var fromAssetID, toAssetID uint32 + var fromAssetQty uint64 + if sell { + fromAssetID = baseID + toAssetID = quoteID + fromAssetQty = qty + } else { + fromAssetID = quoteID + toAssetID = baseID + fromAssetQty = calc.BaseToQuote(rate, qty) + } + + fromAssetBal := w.mm.botCEXBalance(w.botID, fromAssetID) + if fromAssetBal < fromAssetQty { + return "", fmt.Errorf("asset bal < required for trade (%d < %d)", fromAssetBal, fromAssetQty) + } + + w.mm.modifyBotCEXBalance(w.botID, fromAssetID, fromAssetQty, balanceModDecrease) + var success bool + defer func() { + if !success { + w.mm.modifyBotCEXBalance(w.botID, fromAssetID, fromAssetQty, balanceModIncrease) + } + }() + + w.tradesMtx.Lock() + defer w.tradesMtx.Unlock() + + w.subscriptionIDMtx.RLock() + defer w.subscriptionIDMtx.RUnlock() + if w.subscriptionID == nil { + return "", fmt.Errorf("Trade called before SubscribeTradeUpdates") + } + + tradeID, err := w.CEX.Trade(ctx, baseID, quoteID, sell, rate, qty, *w.subscriptionID) + if err != nil { + return "", err + } + + success = true + w.trades[tradeID] = &cexTrade{ + qty: qty, + sell: sell, + fromAsset: fromAssetID, + toAsset: toAssetID, + rate: rate, + } + + return tradeID, nil +} + +// wrappedCoreForBot returns a wrappedCore for the specified bot. +func (m *MarketMaker) wrappedCEXForBot(botID string, cex libxc.CEX) *wrappedCEX { + return &wrappedCEX{ + CEX: cex, + botID: botID, + log: m.log, + mm: m, + trades: make(map[string]*cexTrade), + } +} From 101c9f4c063e87d752d8fcf1f0aceea233ba348e Mon Sep 17 00:00:00 2001 From: martonp Date: Fri, 13 Oct 2023 03:06:24 -0400 Subject: [PATCH 4/9] client/mm: Simple arb bot auto-rebalancing This implements auto-rebalancing in the simple arbitrage strategy. This is an optional functionality that allows the user to specify a minimum balance of each asset that should be on both the DEX and the CEX. If the balance dips below this amount, then either a deposit or withdrawal will be done to have an equal amount of the asset on both exchanges. --- client/mm/mm_simple_arb.go | 135 ++++++-- client/mm/mm_simple_arb_test.go | 561 ++++++++++++++++++++++++-------- 2 files changed, 545 insertions(+), 151 deletions(-) diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index 06a6a051e7..fea01b1a3b 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -21,8 +21,6 @@ import ( // SimpleArbConfig is the configuration for an arbitrage bot that only places // orders when there is a profitable arbitrage opportunity. type SimpleArbConfig struct { - // CEXName is the name of the cex that the bot will arbitrage. - CEXName string `json:"cexName"` // ProfitTrigger is the minimum profit before a cross-exchange trade // sequence is initiated. Range: 0 < ProfitTrigger << 1. For example, if // the ProfitTrigger is 0.01 and a trade sequence would produce a 1% profit @@ -38,6 +36,14 @@ type SimpleArbConfig struct { BaseOptions map[string]string `json:"baseOptions"` // QuoteOptions are the multi-order options for the quote asset wallet. QuoteOptions map[string]string `json:"quoteOptions"` + // AutoRebalance set to true means that if the base or quote asset balance + // dips below MinBaseAmt or MinQuoteAmt respectively, the bot will deposit + // or withdraw funds from the CEX to have an equal amount on both the DEX + // and the CEX. If it is not possible to bring both the DEX and CEX balances + // above the minimum amount, no action will be taken. + AutoRebalance bool `json:"autoRebalance"` + MinBaseAmt uint64 `json:"minBaseAmt"` + MinQuoteAmt uint64 `json:"minQuoteAmt"` } func (c *SimpleArbConfig) Validate() error { @@ -73,20 +79,97 @@ type simpleArbMarketMaker struct { host string baseID uint32 quoteID uint32 - cex libxc.CEX + cex cex // cexTradeUpdatesID is passed to the Trade function of the cex // so that the cex knows to send update notifications for the // trade back to this bot. - cexTradeUpdatesID int - core clientCore - log dex.Logger - cfg *SimpleArbConfig - mkt *core.Market - book dexOrderBook - rebalanceRunning atomic.Bool + core clientCore + log dex.Logger + cfg *SimpleArbConfig + mkt *core.Market + book dexOrderBook + rebalanceRunning atomic.Bool activeArbsMtx sync.RWMutex activeArbs []*arbSequence + + // If pendingBaseRebalance/pendingQuoteRebalance are true, it means + // there is a pending deposit/withdrawal of the base/quote asset, + // and no other deposits/withdrawals of that asset should happen + // until it is complete. + pendingBaseRebalance atomic.Bool + pendingQuoteRebalance atomic.Bool +} + +// rebalanceAsset checks if the balance of an asset on the dex and cex are +// below the minimum amount, and if so, deposits or withdraws funds from the +// CEX to make the balances equal. If it is not possible to bring both the DEX +// and CEX balances above the minimum amount, no action will be taken. +func (a *simpleArbMarketMaker) rebalanceAsset(base bool) { + var assetID uint32 + var minAmount uint64 + if base { + assetID = a.baseID + minAmount = a.cfg.MinBaseAmt + } else { + assetID = a.quoteID + minAmount = a.cfg.MinQuoteAmt + } + + dexBalance, err := a.core.AssetBalance(assetID) + if err != nil { + a.log.Errorf("Error getting asset %d balance: %v", assetID, err) + return + } + + cexBalance, err := a.cex.Balance(assetID) + if err != nil { + a.log.Errorf("Error getting asset %d balance on cex: %v", assetID, err) + return + } + + if (dexBalance.Available+cexBalance.Available)/2 < minAmount { + a.log.Warnf("Cannot rebalance asset %d because balance is too low on both DEX and CEX", assetID) + return + } + + var requireDeposit bool + if cexBalance.Available < minAmount { + requireDeposit = true + } else if dexBalance.Available >= minAmount { + // No need for withdrawal or deposit. + return + } + + onConfirm := func() { + if base { + a.pendingBaseRebalance.Store(false) + } else { + a.pendingQuoteRebalance.Store(false) + } + } + + if requireDeposit { + amt := (dexBalance.Available+cexBalance.Available)/2 - cexBalance.Available + err = a.cex.Deposit(a.ctx, assetID, amt, onConfirm) + if err != nil { + a.log.Errorf("Error depositing %d to cex: %v", assetID, err) + return + } + } else { + amt := (dexBalance.Available+cexBalance.Available)/2 - dexBalance.Available + err = a.cex.Withdraw(a.ctx, assetID, amt, onConfirm) + if err != nil { + a.log.Errorf("Error withdrawing %d from cex: %v", assetID, err) + return + } + } + + if base { + a.pendingBaseRebalance.Store(true) + } else { + a.pendingQuoteRebalance.Store(true) + } } // rebalance checks if there is an arbitrage opportunity between the dex and cex, @@ -118,20 +201,29 @@ func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) { } } + if a.cfg.AutoRebalance && len(remainingArbs) == 0 { + if !a.pendingBaseRebalance.Load() { + a.rebalanceAsset(true) + } + if !a.pendingQuoteRebalance.Load() { + a.rebalanceAsset(false) + } + } + a.activeArbs = remainingArbs } // arbExists checks if an arbitrage opportunity exists. func (a *simpleArbMarketMaker) arbExists() (exists, sellOnDex bool, lotsToArb, dexRate, cexRate uint64) { - cexBaseBalance, err := a.cex.Balance(dex.BipIDSymbol(a.baseID)) + cexBaseBalance, err := a.cex.Balance(a.baseID) if err != nil { - a.log.Errorf("failed to get cex balance for %v: %v", dex.BipIDSymbol(a.baseID), err) + a.log.Errorf("failed to get cex balance for %v: %v", a.baseID, err) return false, false, 0, 0, 0 } - cexQuoteBalance, err := a.cex.Balance(dex.BipIDSymbol(a.quoteID)) + cexQuoteBalance, err := a.cex.Balance(a.quoteID) if err != nil { - a.log.Errorf("failed to get cex balance for %v: %v", dex.BipIDSymbol(a.quoteID), err) + a.log.Errorf("failed to get cex balance for %v: %v", a.quoteID, err) return false, false, 0, 0, 0 } @@ -193,7 +285,7 @@ func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool, cexBaseBalance, c } } - cexAvg, cexExtrema, cexFilled, err := a.cex.VWAP(dex.BipIDSymbol(a.baseID), dex.BipIDSymbol(a.quoteID), sellOnDEX, numLots*lotSize) + cexAvg, cexExtrema, cexFilled, err := a.cex.VWAP(a.baseID, a.quoteID, sellOnDEX, numLots*lotSize) if err != nil { a.log.Errorf("error calculating cex VWAP: %v", err) return @@ -269,7 +361,7 @@ func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, ce defer a.activeArbsMtx.Unlock() // Place cex order first. If placing dex order fails then can freely cancel cex order. - cexTradeID, err := a.cex.Trade(a.ctx, dex.BipIDSymbol(a.baseID), dex.BipIDSymbol(a.quoteID), !sellOnDex, cexRate, lotsToArb*a.mkt.LotSize, a.cexTradeUpdatesID) + cexTradeID, err := a.cex.Trade(a.ctx, a.baseID, a.quoteID, !sellOnDex, cexRate, lotsToArb*a.mkt.LotSize) if err != nil { a.log.Errorf("error placing cex order: %v", err) return @@ -303,7 +395,7 @@ func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, ce a.log.Errorf("expected 1 dex order, got %v", len(dexOrders)) } - err := a.cex.CancelTrade(a.ctx, dex.BipIDSymbol(a.baseID), dex.BipIDSymbol(a.quoteID), cexTradeID) + err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, cexTradeID) if err != nil { a.log.Errorf("error canceling cex order: %v", err) // TODO: keep retrying failed cancel @@ -360,7 +452,7 @@ func (a *simpleArbMarketMaker) selfMatch(sell bool, rate uint64) bool { // if they have not yet been filled. func (a *simpleArbMarketMaker) cancelArbSequence(arb *arbSequence) { if !arb.cexOrderFilled { - err := a.cex.CancelTrade(a.ctx, dex.BipIDSymbol(a.baseID), dex.BipIDSymbol(a.quoteID), arb.cexOrderID) + err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, arb.cexOrderID) if err != nil { a.log.Errorf("failed to cancel cex trade ID %s: %v", arb.cexOrderID, err) } @@ -445,15 +537,14 @@ func (a *simpleArbMarketMaker) run() { } a.book = book - err = a.cex.SubscribeMarket(a.ctx, dex.BipIDSymbol(a.baseID), dex.BipIDSymbol(a.quoteID)) + err = a.cex.SubscribeMarket(a.ctx, a.baseID, a.quoteID) if err != nil { a.log.Errorf("Failed to subscribe to cex market: %v", err) return } - tradeUpdates, unsubscribe, tradeUpdatesID := a.cex.SubscribeTradeUpdates() + tradeUpdates, unsubscribe := a.cex.SubscribeTradeUpdates() defer unsubscribe() - a.cexTradeUpdatesID = tradeUpdatesID wg := &sync.WaitGroup{} @@ -516,7 +607,7 @@ func (a *simpleArbMarketMaker) cancelAllOrders() { } } -func RunSimpleArbBot(ctx context.Context, cfg *BotConfig, c clientCore, cex libxc.CEX, log dex.Logger) { +func RunSimpleArbBot(ctx context.Context, cfg *BotConfig, c clientCore, cex cex, log dex.Logger) { if cfg.SimpleArbConfig == nil { // implies bug in caller log.Errorf("No arb config provided. Exiting.") diff --git a/client/mm/mm_simple_arb_test.go b/client/mm/mm_simple_arb_test.go index 5d21a4f583..641e839a04 100644 --- a/client/mm/mm_simple_arb_test.go +++ b/client/mm/mm_simple_arb_test.go @@ -30,39 +30,51 @@ type dexOrder struct { } type cexOrder struct { - baseSymbol, quoteSymbol string - qty, rate uint64 - sell bool + baseID, quoteID uint32 + qty, rate uint64 + sell bool } -type tCEX struct { - bidsVWAP map[uint64]vwapResult - asksVWAP map[uint64]vwapResult - vwapErr error - balances map[string]*libxc.ExchangeBalance - balanceErr error - - tradeID string - tradeErr error - lastTrade *cexOrder - - cancelledTrades []string - cancelTradeErr error +type withdrawArgs struct { + address string + amt uint64 + assetID uint32 +} - tradeUpdates chan *libxc.TradeUpdate - tradeUpdatesID int +type tCEX struct { + bidsVWAP map[uint64]vwapResult + asksVWAP map[uint64]vwapResult + vwapErr error + balances map[uint32]*libxc.ExchangeBalance + balanceErr error + tradeID string + tradeErr error + lastTrade *cexOrder + cancelledTrades []string + cancelTradeErr error + tradeUpdates chan *libxc.TradeUpdate + tradeUpdatesID int + lastConfirmDepositTx string + confirmDepositAmt uint64 + depositConfirmed bool + depositAddress string + withdrawAmt uint64 + withdrawTxID string + lastWithdrawArgs *withdrawArgs } func newTCEX() *tCEX { return &tCEX{ bidsVWAP: make(map[uint64]vwapResult), asksVWAP: make(map[uint64]vwapResult), - balances: make(map[string]*libxc.ExchangeBalance), + balances: make(map[uint32]*libxc.ExchangeBalance), cancelledTrades: make([]string, 0), tradeUpdates: make(chan *libxc.TradeUpdate), } } +var _ libxc.CEX = (*tCEX)(nil) + func (c *tCEX) Connect(ctx context.Context) (*sync.WaitGroup, error) { return nil, nil } @@ -72,29 +84,30 @@ func (c *tCEX) Balances() (map[uint32]*libxc.ExchangeBalance, error) { func (c *tCEX) Markets() ([]*libxc.Market, error) { return nil, nil } -func (c *tCEX) Balance(symbol string) (*libxc.ExchangeBalance, error) { - return c.balances[symbol], c.balanceErr +func (c *tCEX) Balance(assetID uint32) (*libxc.ExchangeBalance, error) { + return c.balances[assetID], c.balanceErr } -func (c *tCEX) Trade(ctx context.Context, baseSymbol, quoteSymbol string, sell bool, rate, qty uint64, updaterID int) (string, error) { +func (c *tCEX) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64, updaterID int) (string, error) { if c.tradeErr != nil { return "", c.tradeErr } - c.lastTrade = &cexOrder{baseSymbol, quoteSymbol, qty, rate, sell} + c.lastTrade = &cexOrder{baseID, quoteID, qty, rate, sell} return c.tradeID, nil } -func (c *tCEX) CancelTrade(ctx context.Context, baseSymbol, quoteSymbol, tradeID string) error { +func (c *tCEX) CancelTrade(ctx context.Context, seID, quoteID uint32, tradeID string) error { if c.cancelTradeErr != nil { return c.cancelTradeErr } c.cancelledTrades = append(c.cancelledTrades, tradeID) return nil } -func (c *tCEX) SubscribeMarket(ctx context.Context, baseSymbol, quoteSymbol string) error { +func (c *tCEX) SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error { return nil } -func (c *tCEX) UnsubscribeMarket(baseSymbol, quoteSymbol string) { +func (c *tCEX) UnsubscribeMarket(baseID, quoteID uint32) error { + return nil } -func (c *tCEX) VWAP(baseSymbol, quoteSymbol string, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { +func (c *tCEX) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { if c.vwapErr != nil { return 0, 0, false, c.vwapErr } @@ -119,8 +132,113 @@ func (c *tCEX) SubscribeTradeUpdates() (<-chan *libxc.TradeUpdate, func(), int) func (c *tCEX) SubscribeCEXUpdates() (<-chan interface{}, func()) { return nil, func() {} } +func (c *tCEX) GetDepositAddress(ctx context.Context, assetID uint32) (string, error) { + return c.depositAddress, nil +} -var _ libxc.CEX = (*tCEX)(nil) +func (c *tCEX) Withdraw(ctx context.Context, assetID uint32, qty uint64, address string, onComplete func(uint64, string)) error { + c.lastWithdrawArgs = &withdrawArgs{ + address: address, + amt: qty, + assetID: assetID, + } + onComplete(c.withdrawAmt, c.withdrawTxID) + return nil +} + +func (c *tCEX) ConfirmDeposit(ctx context.Context, txID string, onConfirm func(bool, uint64)) { + c.lastConfirmDepositTx = txID + onConfirm(c.depositConfirmed, c.confirmDepositAmt) +} + +type tWrappedCEX struct { + bidsVWAP map[uint64]vwapResult + asksVWAP map[uint64]vwapResult + vwapErr error + balances map[uint32]*libxc.ExchangeBalance + balanceErr error + tradeID string + tradeErr error + lastTrade *cexOrder + cancelledTrades []string + cancelTradeErr error + tradeUpdates chan *libxc.TradeUpdate + lastWithdrawArgs *withdrawArgs + lastDepositArgs *withdrawArgs + confirmDeposit func() + confirmWithdraw func() +} + +func newTWrappedCEX() *tWrappedCEX { + return &tWrappedCEX{ + bidsVWAP: make(map[uint64]vwapResult), + asksVWAP: make(map[uint64]vwapResult), + balances: make(map[uint32]*libxc.ExchangeBalance), + cancelledTrades: make([]string, 0), + tradeUpdates: make(chan *libxc.TradeUpdate), + } +} + +var _ cex = (*tWrappedCEX)(nil) + +func (c *tWrappedCEX) Balance(assetID uint32) (*libxc.ExchangeBalance, error) { + return c.balances[assetID], c.balanceErr +} +func (c *tWrappedCEX) CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error { + if c.cancelTradeErr != nil { + return c.cancelTradeErr + } + c.cancelledTrades = append(c.cancelledTrades, tradeID) + return nil +} +func (c *tWrappedCEX) SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error { + return nil +} +func (c *tWrappedCEX) SubscribeTradeUpdates() (updates <-chan *libxc.TradeUpdate, unsubscribe func()) { + return c.tradeUpdates, func() {} +} +func (c *tWrappedCEX) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (string, error) { + if c.tradeErr != nil { + return "", c.tradeErr + } + c.lastTrade = &cexOrder{baseID, quoteID, qty, rate, sell} + return c.tradeID, nil +} +func (c *tWrappedCEX) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { + if c.vwapErr != nil { + return 0, 0, false, c.vwapErr + } + + if sell { + res, found := c.asksVWAP[qty] + if !found { + return 0, 0, false, nil + } + return res.avg, res.extrema, true, nil + } + + res, found := c.bidsVWAP[qty] + if !found { + return 0, 0, false, nil + } + return res.avg, res.extrema, true, nil +} +func (c *tWrappedCEX) Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { + c.lastDepositArgs = &withdrawArgs{ + assetID: assetID, + amt: amount, + } + c.confirmDeposit = onConfirm + return nil +} +func (c *tWrappedCEX) Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { + c.lastWithdrawArgs = &withdrawArgs{ + assetID: assetID, + amt: amount, + } + c.confirmWithdraw = onConfirm + return nil +} func TestArbRebalance(t *testing.T) { mkt := &core.Market{ @@ -244,6 +362,11 @@ func TestArbRebalance(t *testing.T) { cexAsksExtrema: []uint64{2.5e6, 2.7e6}, } + type assetAmt struct { + assetID uint32 + amt uint64 + } + type test struct { name string books *testBooks @@ -251,17 +374,26 @@ func TestArbRebalance(t *testing.T) { dexMaxBuy *core.MaxOrderEstimate dexMaxSellErr error dexMaxBuyErr error - cexBalances map[string]*libxc.ExchangeBalance - dexVWAPErr error - cexVWAPErr error - cexTradeErr error - existingArbs []*arbSequence + // The strategy uses maxSell/maxBuy to determine how much it can trade. + // dexBalances is just used for auto rebalancing. + dexBalances map[uint32]uint64 + cexBalances map[uint32]*libxc.ExchangeBalance + dexVWAPErr error + cexVWAPErr error + cexTradeErr error + existingArbs []*arbSequence + pendingBaseRebalance bool + pendingQuoteRebalance bool + autoRebalance bool + minBaseAmt uint64 + minQuoteAmt uint64 expectedDexOrder *dexOrder expectedCexOrder *cexOrder expectedDEXCancels []dex.Bytes expectedCEXCancels []string - //expectedActiveArbs []*arbSequence + expectedWithdrawal *assetAmt + expectedDeposit *assetAmt } tests := []test{ @@ -279,9 +411,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, }, // "1 lot, buy on dex, sell on cex" @@ -298,9 +430,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -308,11 +440,11 @@ func TestArbRebalance(t *testing.T) { sell: false, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2.2e6, - sell: true, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2.2e6, + sell: true, }, }, // "1 lot, buy on dex, sell on cex, but dex base balance not enough" @@ -329,9 +461,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: mkt.LotSize / 2}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: mkt.LotSize / 2}, }, }, // "2 lot, buy on dex, sell on cex, but dex quote balance only enough for 1" @@ -349,9 +481,9 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ @@ -360,11 +492,11 @@ func TestArbRebalance(t *testing.T) { sell: false, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2.2e6, - sell: true, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2.2e6, + sell: true, }, }, // "1 lot, sell on dex, buy on cex" @@ -381,9 +513,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -391,11 +523,11 @@ func TestArbRebalance(t *testing.T) { sell: true, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2e6, - sell: false, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2e6, + sell: false, }, }, // "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1" @@ -412,9 +544,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -422,11 +554,11 @@ func TestArbRebalance(t *testing.T) { sell: true, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2e6, - sell: false, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2e6, + sell: false, }, }, // "1 lot, sell on dex, buy on cex" @@ -443,9 +575,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -453,11 +585,11 @@ func TestArbRebalance(t *testing.T) { sell: true, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2e6, - sell: false, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2e6, + sell: false, }, }, // "2 lots arb still above profit trigger, but second not worth it on its own" @@ -474,9 +606,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -484,11 +616,11 @@ func TestArbRebalance(t *testing.T) { sell: false, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2.2e6, - sell: true, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2.2e6, + sell: true, }, }, // "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1" @@ -505,9 +637,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, + 42: {Available: 1e19}, }, expectedDexOrder: &dexOrder{ lots: 1, @@ -515,11 +647,11 @@ func TestArbRebalance(t *testing.T) { sell: true, }, expectedCexOrder: &cexOrder{ - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: mkt.LotSize, - rate: 2e6, - sell: false, + baseID: 42, + quoteID: 0, + qty: mkt.LotSize, + rate: 2e6, + sell: false, }, }, // "cex no asks" @@ -549,9 +681,9 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, }, // "dex no asks" @@ -581,9 +713,9 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, }, // "dex max sell error" @@ -595,9 +727,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, dexMaxSellErr: errors.New(""), }, @@ -610,9 +742,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, dexMaxBuyErr: errors.New(""), }, @@ -630,9 +762,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, dexVWAPErr: errors.New(""), }, @@ -650,9 +782,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, cexVWAPErr: errors.New(""), }, @@ -671,9 +803,9 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, existingArbs: []*arbSequence{{ @@ -741,9 +873,9 @@ func TestArbRebalance(t *testing.T) { }, expectedCEXCancels: []string{cexTradeIDs[1], cexTradeIDs[3]}, expectedDEXCancels: []dex.Bytes{orderIDs[1][:], orderIDs[2][:]}, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, }, // "already max active arbs" @@ -760,9 +892,9 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, existingArbs: []*arbSequence{ { @@ -821,16 +953,112 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, cexTradeErr: errors.New(""), }, + // "no arb, base needs withdrawal, quote needs deposit" + { + name: "no arb, base needs withdrawal, quote needs deposit", + books: noArbBooks, + dexMaxSell: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexBalances: map[uint32]uint64{ + 42: 1e14, + 0: 1e17, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 42: {Available: 1e19}, + 0: {Available: 1e10}, + }, + autoRebalance: true, + minBaseAmt: 1e16, + minQuoteAmt: 1e12, + expectedWithdrawal: &assetAmt{ + assetID: 42, + amt: 4.99995e18, + }, + expectedDeposit: &assetAmt{ + assetID: 0, + amt: 4.9999995e16, + }, + }, + // "no arb, quote needs withdrawal, base needs deposit" + { + name: "no arb, quote needs withdrawal, base needs deposit", + books: noArbBooks, + dexMaxSell: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexBalances: map[uint32]uint64{ + 42: 1e19, + 0: 1e10, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 42: {Available: 1e14}, + 0: {Available: 1e17}, + }, + autoRebalance: true, + minBaseAmt: 1e16, + minQuoteAmt: 1e12, + expectedWithdrawal: &assetAmt{ + assetID: 0, + amt: 4.9999995e16, + }, + expectedDeposit: &assetAmt{ + assetID: 42, + amt: 4.99995e18, + }, + }, + // "no arb, quote needs withdrawal, base needs deposit, already pending" + { + name: "no arb, quote needs withdrawal, base needs deposit, already pending", + books: noArbBooks, + dexMaxSell: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexBalances: map[uint32]uint64{ + 42: 1e19, + 0: 1e10, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 42: {Available: 1e14}, + 0: {Available: 1e17}, + }, + autoRebalance: true, + minBaseAmt: 1e16, + minQuoteAmt: 1e12, + pendingBaseRebalance: true, + pendingQuoteRebalance: true, + }, } runTest := func(test *test) { - cex := newTCEX() + cex := newTWrappedCEX() cex.vwapErr = test.cexVWAPErr cex.balances = test.cexBalances cex.tradeErr = test.cexTradeErr @@ -840,6 +1068,7 @@ func TestArbRebalance(t *testing.T) { tCore.maxSellEstimate = test.dexMaxSell tCore.maxSellErr = test.dexMaxSellErr tCore.maxBuyErr = test.dexMaxBuyErr + tCore.setAssetBalances(test.dexBalances) if test.expectedDexOrder != nil { tCore.multiTradeResult = []*core.Order{ { @@ -882,9 +1111,15 @@ func TestArbRebalance(t *testing.T) { ProfitTrigger: profitTrigger, MaxActiveArbs: maxActiveArbs, NumEpochsLeaveOpen: numEpochsLeaveOpen, + AutoRebalance: test.autoRebalance, + MinBaseAmt: test.minBaseAmt, + MinQuoteAmt: test.minQuoteAmt, }, } + arbEngine.pendingBaseRebalance.Store(test.pendingBaseRebalance) + arbEngine.pendingQuoteRebalance.Store(test.pendingQuoteRebalance) + go arbEngine.run() dummyNote := &core.BookUpdate{} @@ -967,6 +1202,74 @@ func TestArbRebalance(t *testing.T) { t.Fatalf("%s: expected cex cancel %s but got %s", test.name, test.expectedCEXCancels[i], cex.cancelledTrades[i]) } } + + // Test auto rebalancing + expectBasePending := test.pendingBaseRebalance + expectQuotePending := test.pendingQuoteRebalance + if test.expectedWithdrawal != nil { + if cex.lastWithdrawArgs == nil { + t.Fatalf("%s: expected withdrawal %+v but got none", test.name, test.expectedWithdrawal) + } + if test.expectedWithdrawal.assetID != cex.lastWithdrawArgs.assetID { + t.Fatalf("%s: expected withdrawal asset %d but got %d", test.name, test.expectedWithdrawal.assetID, cex.lastWithdrawArgs.assetID) + } + if test.expectedWithdrawal.amt != cex.lastWithdrawArgs.amt { + t.Fatalf("%s: expected withdrawal amt %d but got %d", test.name, test.expectedWithdrawal.amt, cex.lastWithdrawArgs.amt) + } + if test.expectedWithdrawal.assetID == arbEngine.baseID { + expectBasePending = true + } else { + expectQuotePending = true + } + } else if cex.lastWithdrawArgs != nil { + t.Fatalf("%s: expected no withdrawal but got %+v", test.name, cex.lastWithdrawArgs) + } + if test.expectedDeposit != nil { + if cex.lastDepositArgs == nil { + t.Fatalf("%s: expected deposit %+v but got none", test.name, test.expectedDeposit) + } + if test.expectedDeposit.assetID != cex.lastDepositArgs.assetID { + t.Fatalf("%s: expected deposit asset %d but got %d", test.name, test.expectedDeposit.assetID, cex.lastDepositArgs.assetID) + } + if test.expectedDeposit.amt != cex.lastDepositArgs.amt { + t.Fatalf("%s: expected deposit amt %d but got %d", test.name, test.expectedDeposit.amt, cex.lastDepositArgs.amt) + } + if test.expectedDeposit.assetID == arbEngine.baseID { + expectBasePending = true + } else { + expectQuotePending = true + } + + } else if cex.lastDepositArgs != nil { + t.Fatalf("%s: expected no deposit but got %+v", test.name, cex.lastDepositArgs) + } + if expectBasePending != arbEngine.pendingBaseRebalance.Load() { + t.Fatalf("%s: expected base pending %v but got %v", test.name, expectBasePending, !expectBasePending) + } + if expectQuotePending != arbEngine.pendingQuoteRebalance.Load() { + t.Fatalf("%s: expected base pending %v but got %v", test.name, expectBasePending, !expectBasePending) + } + + // Make sure that when withdraw/deposit is confirmed, the pending field + // gets set back to false. + if cex.confirmWithdraw != nil { + cex.confirmWithdraw() + if cex.lastWithdrawArgs.assetID == arbEngine.baseID && arbEngine.pendingBaseRebalance.Load() { + t.Fatalf("%s: pending base rebalance was not reset after confirmation", test.name) + } + if cex.lastWithdrawArgs.assetID != arbEngine.baseID && arbEngine.pendingQuoteRebalance.Load() { + t.Fatalf("%s: pending quote rebalance was not reset after confirmation", test.name) + } + } + if cex.confirmDeposit != nil { + cex.confirmDeposit() + if cex.lastDepositArgs.assetID == arbEngine.baseID && arbEngine.pendingBaseRebalance.Load() { + t.Fatalf("%s: pending base rebalance was not reset after confirmation", test.name) + } + if cex.lastDepositArgs.assetID != arbEngine.baseID && arbEngine.pendingQuoteRebalance.Load() { + t.Fatalf("%s: pending quote rebalance was not reset after confirmation", test.name) + } + } } for _, test := range tests { @@ -1049,7 +1352,7 @@ func TestArbDexTradeUpdates(t *testing.T) { } runTest := func(test *test) { - cex := newTCEX() + cex := newTWrappedCEX() tCore := newTCore() ctx, cancel := context.WithCancel(context.Background()) @@ -1171,7 +1474,7 @@ func TestCexTradeUpdates(t *testing.T) { } runTest := func(test *test) { - cex := newTCEX() + cex := newTWrappedCEX() tCore := newTCore() ctx, cancel := context.WithCancel(context.Background()) From 75cdf162fcce15cd38f39cea66844f1d3be5048f Mon Sep 17 00:00:00 2001 From: martonp Date: Sun, 22 Oct 2023 21:14:08 -0400 Subject: [PATCH 5/9] Review updates --- client/mm/libxc/binance.go | 38 +++++++++--------- client/mm/mm_simple_arb.go | 32 ++++++++++++---- client/mm/mm_simple_arb_test.go | 68 +++++++++++++++++++++++++++++++++ client/mm/wrapped_cex.go | 12 +++--- 4 files changed, 119 insertions(+), 31 deletions(-) diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index 38cfe55234..1cc20ade9c 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -568,27 +568,27 @@ func (bnc *binance) GetDepositAddress(ctx context.Context, assetID uint32) (stri } func (bnc *binance) ConfirmDeposit(ctx context.Context, txID string, onConfirm func(bool, uint64)) { - const pendingStatus = 0 - const successStatus = 1 - const creditedStatus = 6 - const wrongDepositStatus = 7 - const waitingUserConfirmStatus = 8 - - type depositHistory struct { - Amount float64 `json:"amount,string"` - Coin string `json:"coin"` - Network string `json:"network"` - Status int `json:"status"` - Address string `json:"address"` - AddressTag string `json:"addressTag"` - TxID string `json:"txId"` - InsertTime int64 `json:"insertTime"` - TransferType int `json:"transferType"` - ConfirmTimes string `json:"confirmTimes"` - } + const ( + pendingStatus = 0 + successStatus = 1 + creditedStatus = 6 + wrongDepositStatus = 7 + waitingUserConfirmStatus = 8 + ) checkDepositStatus := func() (success, done bool) { - resp := []*depositHistory{} + var resp []*struct { + Amount float64 `json:"amount,string"` + Coin string `json:"coin"` + Network string `json:"network"` + Status int `json:"status"` + Address string `json:"address"` + AddressTag string `json:"addressTag"` + TxID string `json:"txId"` + InsertTime int64 `json:"insertTime"` + TransferType int `json:"transferType"` + ConfirmTimes string `json:"confirmTimes"` + } err := bnc.getAPI(ctx, "/sapi/v1/capital/deposit/hisrec", nil, true, true, resp) if err != nil { bnc.log.Errorf("error getting deposit status: %v", err) diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index fea01b1a3b..e405e1f109 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -40,10 +40,14 @@ type SimpleArbConfig struct { // dips below MinBaseAmt or MinQuoteAmt respectively, the bot will deposit // or withdraw funds from the CEX to have an equal amount on both the DEX // and the CEX. If it is not possible to bring both the DEX and CEX balances - // above the minimum amount, no action will be taken. - AutoRebalance bool `json:"autoRebalance"` - MinBaseAmt uint64 `json:"minBaseAmt"` - MinQuoteAmt uint64 `json:"minQuoteAmt"` + // above the minimum amount, no action will be taken. Also, if the amount + // required to bring the balances to equal is less than MinBaseTransfer or + // MinQuoteTransfer, no action will be taken. + AutoRebalance bool `json:"autoRebalance"` + MinBaseAmt uint64 `json:"minBaseAmt"` + MinBaseTransfer uint64 `json:"minBaseTransfer"` + MinQuoteAmt uint64 `json:"minQuoteAmt"` + MinQuoteTransfer uint64 `json:"minQuoteTransfer"` } func (c *SimpleArbConfig) Validate() error { @@ -108,28 +112,32 @@ type simpleArbMarketMaker struct { func (a *simpleArbMarketMaker) rebalanceAsset(base bool) { var assetID uint32 var minAmount uint64 + var minTransferAmount uint64 if base { assetID = a.baseID minAmount = a.cfg.MinBaseAmt + minTransferAmount = a.cfg.MinBaseTransfer } else { assetID = a.quoteID minAmount = a.cfg.MinQuoteAmt + minTransferAmount = a.cfg.MinQuoteTransfer } + symbol := dex.BipIDSymbol(assetID) dexBalance, err := a.core.AssetBalance(assetID) if err != nil { - a.log.Errorf("Error getting asset %d balance: %v", assetID, err) + a.log.Errorf("Error getting %s balance: %v", symbol, err) return } cexBalance, err := a.cex.Balance(assetID) if err != nil { - a.log.Errorf("Error getting asset %d balance on cex: %v", assetID, err) + a.log.Errorf("Error getting %s balance on cex: %v", symbol, err) return } if (dexBalance.Available+cexBalance.Available)/2 < minAmount { - a.log.Warnf("Cannot rebalance asset %d because balance is too low on both DEX and CEX", assetID) + a.log.Warnf("Cannot rebalance %s because balance is too low on both DEX and CEX", symbol) return } @@ -151,6 +159,11 @@ func (a *simpleArbMarketMaker) rebalanceAsset(base bool) { if requireDeposit { amt := (dexBalance.Available+cexBalance.Available)/2 - cexBalance.Available + if amt < minTransferAmount { + a.log.Warnf("Amount required to rebalance %s (%d) is less than the min transfer amount %v", + symbol, amt, minTransferAmount) + return + } err = a.cex.Deposit(a.ctx, assetID, amt, onConfirm) if err != nil { a.log.Errorf("Error depositing %d to cex: %v", assetID, err) @@ -158,6 +171,11 @@ func (a *simpleArbMarketMaker) rebalanceAsset(base bool) { } } else { amt := (dexBalance.Available+cexBalance.Available)/2 - dexBalance.Available + if amt < minTransferAmount { + a.log.Warnf("Amount required to rebalance %s (%d) is less than the min transfer amount %v", + symbol, amt, minTransferAmount) + return + } err = a.cex.Withdraw(a.ctx, assetID, amt, onConfirm) if err != nil { a.log.Errorf("Error withdrawing %d from cex: %v", assetID, err) diff --git a/client/mm/mm_simple_arb_test.go b/client/mm/mm_simple_arb_test.go index 641e839a04..d342dbb25c 100644 --- a/client/mm/mm_simple_arb_test.go +++ b/client/mm/mm_simple_arb_test.go @@ -386,7 +386,9 @@ func TestArbRebalance(t *testing.T) { pendingQuoteRebalance bool autoRebalance bool minBaseAmt uint64 + minBaseTransfer uint64 minQuoteAmt uint64 + minQuoteTransfer uint64 expectedDexOrder *dexOrder expectedCexOrder *cexOrder @@ -993,6 +995,70 @@ func TestArbRebalance(t *testing.T) { amt: 4.9999995e16, }, }, + // "no arb, base needs withdrawal, quote needs deposit, edge of min transfer amount" + { + name: "no arb, base needs withdrawal, quote needs deposit, edge of min transfer amount", + books: noArbBooks, + dexMaxSell: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexBalances: map[uint32]uint64{ + 42: 9.5e15, + 0: 1.1e12, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 42: {Available: 1.1e16}, + 0: {Available: 9.5e11}, + }, + autoRebalance: true, + minBaseAmt: 1e16, + minQuoteAmt: 1e12, + minBaseTransfer: (1.1e16+9.5e15)/2 - 9.5e15, + minQuoteTransfer: (1.1e12+9.5e11)/2 - 9.5e11, + expectedWithdrawal: &assetAmt{ + assetID: 42, + amt: (1.1e16+9.5e15)/2 - 9.5e15, + }, + expectedDeposit: &assetAmt{ + assetID: 0, + amt: (1.1e12+9.5e11)/2 - 9.5e11, + }, + }, + // "no arb, base needs withdrawal, quote needs deposit, below min transfer amount" + { + name: "no arb, base needs withdrawal, quote needs deposit, below min transfer amount", + books: noArbBooks, + dexMaxSell: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexBalances: map[uint32]uint64{ + 42: 9.5e15, + 0: 1.1e12, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 42: {Available: 1.1e16}, + 0: {Available: 9.5e11}, + }, + autoRebalance: true, + minBaseAmt: 1e16, + minQuoteAmt: 1e12, + minBaseTransfer: (1.1e16+9.5e15)/2 - 9.5e15 + 1, + minQuoteTransfer: (1.1e12+9.5e11)/2 - 9.5e11 + 1, + }, // "no arb, quote needs withdrawal, base needs deposit" { name: "no arb, quote needs withdrawal, base needs deposit", @@ -1114,6 +1180,8 @@ func TestArbRebalance(t *testing.T) { AutoRebalance: test.autoRebalance, MinBaseAmt: test.minBaseAmt, MinQuoteAmt: test.minQuoteAmt, + MinBaseTransfer: test.minBaseTransfer, + MinQuoteTransfer: test.minQuoteTransfer, }, } diff --git a/client/mm/wrapped_cex.go b/client/mm/wrapped_cex.go index e5f14f4717..d576722da9 100644 --- a/client/mm/wrapped_cex.go +++ b/client/mm/wrapped_cex.go @@ -130,6 +130,8 @@ func (w *wrappedCEX) Withdraw(ctx context.Context, assetID uint32, amount uint64 ticker := time.NewTicker(time.Second * 20) giveUp := time.NewTimer(time.Minute * 10) + defer ticker.Stop() + defer giveUp.Stop() for { select { case <-ctx.Done(): @@ -254,16 +256,16 @@ func (w *wrappedCEX) Trade(ctx context.Context, baseID, quoteID uint32, sell boo } }() - w.tradesMtx.Lock() - defer w.tradesMtx.Unlock() - w.subscriptionIDMtx.RLock() - defer w.subscriptionIDMtx.RUnlock() + subscriptionID := w.subscriptionID + w.subscriptionIDMtx.RUnlock() if w.subscriptionID == nil { return "", fmt.Errorf("Trade called before SubscribeTradeUpdates") } - tradeID, err := w.CEX.Trade(ctx, baseID, quoteID, sell, rate, qty, *w.subscriptionID) + w.tradesMtx.Lock() + defer w.tradesMtx.Unlock() + tradeID, err := w.CEX.Trade(ctx, baseID, quoteID, sell, rate, qty, *subscriptionID) if err != nil { return "", err } From 2b1feeade15ae9153e28a1c455b202d2604046fe Mon Sep 17 00:00:00 2001 From: Brian Stafford Date: Fri, 20 Oct 2023 15:44:20 -0500 Subject: [PATCH 6/9] without new return args --- client/asset/btc/btc.go | 12 ++++++------ client/asset/btc/btc_test.go | 6 +++--- client/asset/btc/livetest/livetest.go | 4 ++-- client/asset/dcr/dcr.go | 20 ++++++++++++-------- client/asset/dcr/dcr_test.go | 12 ++++++------ client/asset/dcr/simnet_test.go | 4 ++-- client/asset/eth/eth.go | 24 ++++++++++++++---------- client/asset/eth/eth_test.go | 6 +++--- client/asset/eth/fundingcoin.go | 4 ++++ client/asset/interface.go | 8 ++++++-- client/asset/zec/zec.go | 6 +++--- client/core/core.go | 15 ++++++++++----- client/core/core_test.go | 12 ++++++++---- client/mm/config.go | 2 +- client/mm/mm.go | 25 +++++++++++++------------ client/mm/mm_arb_market_maker.go | 12 ++++++------ 16 files changed, 99 insertions(+), 73 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 010dfe5ec1..521a8f2385 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -4209,23 +4209,23 @@ func (btc *baseWallet) EstimateRegistrationTxFee(feeRate uint64) uint64 { // Withdraw withdraws funds to the specified address. Fees are subtracted from // the value. feeRate is in units of sats/byte. // Withdraw satisfies asset.Withdrawer. -func (btc *baseWallet) Withdraw(address string, value, feeRate uint64) (string, asset.Coin, error) { +func (btc *baseWallet) Withdraw(address string, value, feeRate uint64) (asset.Coin, error) { txHash, vout, sent, err := btc.send(address, value, btc.feeRateWithFallback(feeRate), true) if err != nil { - return "", nil, err + return nil, err } - return txHash.String(), NewOutput(txHash, vout, sent), nil + return NewOutput(txHash, vout, sent), nil } // Send sends the exact value to the specified address. This is different from // Withdraw, which subtracts the tx fees from the amount sent. feeRate is in // units of sats/byte. -func (btc *baseWallet) Send(address string, value, feeRate uint64) (string, asset.Coin, error) { +func (btc *baseWallet) Send(address string, value, feeRate uint64) (asset.Coin, error) { txHash, vout, sent, err := btc.send(address, value, btc.feeRateWithFallback(feeRate), false) if err != nil { - return "", nil, err + return nil, err } - return txHash.String(), NewOutput(txHash, vout, sent), nil + return NewOutput(txHash, vout, sent), nil } // SendTransaction broadcasts a valid fully-signed transaction. diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index df85978fff..2fd90ae1a1 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -3729,11 +3729,11 @@ func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType st wallet, node, shutdown := tNewWallet(segwit, walletType) defer shutdown() const feeSuggestion = 100 - sender := func(addr string, val uint64) (string, asset.Coin, error) { + sender := func(addr string, val uint64) (asset.Coin, error) { return wallet.Send(addr, val, defaultFee) } if senderType == tWithdrawSender { - sender = func(addr string, val uint64) (string, asset.Coin, error) { + sender = func(addr string, val uint64) (asset.Coin, error) { return wallet.Withdraw(addr, val, feeSuggestion) } } @@ -3895,7 +3895,7 @@ func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType st node.listUnspent = test.unspents wallet.bondReserves.Store(test.bondReserves) - _, _, err := sender(addr.String(), test.val) + _, err := sender(addr.String(), test.val) if test.expectErr { if err == nil { t.Fatalf("%s: no error for expected error", test.name) diff --git a/client/asset/btc/livetest/livetest.go b/client/asset/btc/livetest/livetest.go index 368f1489d9..ba1859afcc 100644 --- a/client/asset/btc/livetest/livetest.go +++ b/client/asset/btc/livetest/livetest.go @@ -561,7 +561,7 @@ func Run(t *testing.T, cfg *Config) { // Test Send. tLogger.Info("Testing Send") - _, coin, err := rig.secondWallet.Send(address, cfg.LotSize, defaultFee) + coin, err := rig.secondWallet.Send(address, cfg.LotSize, defaultFee) if err != nil { t.Fatalf("error sending: %v", err) } @@ -573,7 +573,7 @@ func Run(t *testing.T, cfg *Config) { // Test Withdraw. withdrawer, _ := rig.secondWallet.Wallet.(asset.Withdrawer) tLogger.Info("Testing Withdraw") - _, coin, err = withdrawer.Withdraw(address, cfg.LotSize, defaultFee) + coin, err = withdrawer.Withdraw(address, cfg.LotSize, defaultFee) if err != nil { t.Fatalf("error withdrawing: %v", err) } diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 1c652b1058..098a1eec92 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -357,6 +357,10 @@ func (op *output) ID() dex.Bytes { return toCoinID(op.txHash(), op.vout()) } +func (op *output) TxID() string { + return op.txHash().String() +} + // String is a string representation of the coin. func (op *output) String() string { return op.pt.String() @@ -4271,31 +4275,31 @@ func (dcr *ExchangeWallet) SendTransaction(rawTx []byte) ([]byte, error) { // Withdraw withdraws funds to the specified address. Fees are subtracted from // the value. feeRate is in units of atoms/byte. // Withdraw satisfies asset.Withdrawer. -func (dcr *ExchangeWallet) Withdraw(address string, value, feeRate uint64) (string, asset.Coin, error) { +func (dcr *ExchangeWallet) Withdraw(address string, value, feeRate uint64) (asset.Coin, error) { addr, err := stdaddr.DecodeAddress(address, dcr.chainParams) if err != nil { - return "", nil, fmt.Errorf("invalid address: %s", address) + return nil, fmt.Errorf("invalid address: %s", address) } msgTx, sentVal, err := dcr.withdraw(addr, value, dcr.feeRateWithFallback(feeRate)) if err != nil { - return "", nil, err + return nil, err } - return msgTx.CachedTxHash().String(), newOutput(msgTx.CachedTxHash(), 0, sentVal, wire.TxTreeRegular), nil + return newOutput(msgTx.CachedTxHash(), 0, sentVal, wire.TxTreeRegular), nil } // Send sends the exact value to the specified address. This is different from // Withdraw, which subtracts the tx fees from the amount sent. feeRate is in // units of atoms/byte. -func (dcr *ExchangeWallet) Send(address string, value, feeRate uint64) (string, asset.Coin, error) { +func (dcr *ExchangeWallet) Send(address string, value, feeRate uint64) (asset.Coin, error) { addr, err := stdaddr.DecodeAddress(address, dcr.chainParams) if err != nil { - return "", nil, fmt.Errorf("invalid address: %s", address) + return nil, fmt.Errorf("invalid address: %s", address) } msgTx, sentVal, err := dcr.sendToAddress(addr, value, dcr.feeRateWithFallback(feeRate)) if err != nil { - return "", nil, err + return nil, err } - return msgTx.CachedTxHash().String(), newOutput(msgTx.CachedTxHash(), 0, sentVal, wire.TxTreeRegular), nil + return newOutput(msgTx.CachedTxHash(), 0, sentVal, wire.TxTreeRegular), nil } // ValidateSecret checks that the secret satisfies the contract. diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 84b5138577..0791d1cef8 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -3365,14 +3365,14 @@ func testSender(t *testing.T, senderType tSenderType) { var unspentVal uint64 = 100e8 const feeSuggestion = 100 funName := "Send" - sender := func(addr string, val uint64) (string, asset.Coin, error) { + sender := func(addr string, val uint64) (asset.Coin, error) { return wallet.Send(addr, val, feeSuggestion) } if senderType == tWithdrawSender { funName = "Withdraw" // For withdraw, test with unspent total = withdraw value unspentVal = sendVal - sender = func(addr string, val uint64) (string, asset.Coin, error) { + sender = func(addr string, val uint64) (asset.Coin, error) { return wallet.Withdraw(addr, val, feeSuggestion) } } @@ -3391,13 +3391,13 @@ func testSender(t *testing.T, senderType tSenderType) { }} //node.unspent = append(node.unspent, node.unspent[0]) - _, _, err := sender(addr, sendVal) + _, err := sender(addr, sendVal) if err != nil { t.Fatalf(funName+" error: %v", err) } // invalid address - _, _, err = sender("badaddr", sendVal) + _, err = sender("badaddr", sendVal) if err == nil { t.Fatalf("no error for bad address: %v", err) } @@ -3405,7 +3405,7 @@ func testSender(t *testing.T, senderType tSenderType) { // GetRawChangeAddress error if senderType == tSendSender { // withdraw test does not get a change address node.changeAddrErr = tErr - _, _, err = sender(addr, sendVal) + _, err = sender(addr, sendVal) if err == nil { t.Fatalf("no error for rawchangeaddress: %v", err) } @@ -3413,7 +3413,7 @@ func testSender(t *testing.T, senderType tSenderType) { } // good again - _, _, err = sender(addr, sendVal) + _, err = sender(addr, sendVal) if err != nil { t.Fatalf(funName+" error afterwards: %v", err) } diff --git a/client/asset/dcr/simnet_test.go b/client/asset/dcr/simnet_test.go index f373527e40..462c2c6735 100644 --- a/client/asset/dcr/simnet_test.go +++ b/client/asset/dcr/simnet_test.go @@ -571,14 +571,14 @@ func runTest(t *testing.T, splitTx bool) { } // Test Send - _, coin, err := rig.beta().Send(alphaAddress, 1e8, defaultFee) + coin, err := rig.beta().Send(alphaAddress, 1e8, defaultFee) if err != nil { t.Fatalf("error sending fees: %v", err) } tLogger.Infof("fee paid with tx %s", coin.String()) // Test Withdraw - _, coin, err = rig.beta().Withdraw(alphaAddress, 5e7, tDCR.MaxFeeRate/4) + coin, err = rig.beta().Withdraw(alphaAddress, 5e7, tDCR.MaxFeeRate/4) if err != nil { t.Fatalf("error withdrawing: %v", err) } diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index b5da423bb0..d82ea6f572 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -1408,6 +1408,10 @@ func (c *coin) ID() dex.Bytes { return c.id[:] } +func (c *coin) TxID() string { + return c.String() +} + // String is a string representation of the coin. func (c *coin) String() string { return c.id.String() @@ -3144,14 +3148,14 @@ func (w *assetWallet) SwapConfirmations(ctx context.Context, coinID dex.Bytes, c // Send sends the exact value to the specified address. The provided fee rate is // ignored since all sends will use an internally derived fee rate. -func (w *ETHWallet) Send(addr string, value, _ uint64) (string, asset.Coin, error) { +func (w *ETHWallet) Send(addr string, value, _ uint64) (asset.Coin, error) { if err := isValidSend(addr, value, false); err != nil { - return "", nil, err + return nil, err } maxFee, maxFeeRate, err := w.canSend(value, true, false) if err != nil { - return "", nil, err + return nil, err } // TODO: Subtract option. // if avail < value+maxFee { @@ -3160,37 +3164,37 @@ func (w *ETHWallet) Send(addr string, value, _ uint64) (string, asset.Coin, erro tx, err := w.sendToAddr(common.HexToAddress(addr), value, maxFeeRate) if err != nil { - return "", nil, err + return nil, err } txHash := tx.Hash() w.addToTxHistory(tx.Nonce(), -int64(value), maxFee, 0, w.assetID, txHash[:], asset.Send) - return txHash.String(), &coin{id: txHash, value: value}, nil + return &coin{id: txHash, value: value}, nil } // Send sends the exact value to the specified address. Fees are taken from the // parent wallet. The provided fee rate is ignored since all sends will use an // internally derived fee rate. -func (w *TokenWallet) Send(addr string, value, _ uint64) (string, asset.Coin, error) { +func (w *TokenWallet) Send(addr string, value, _ uint64) (asset.Coin, error) { if err := isValidSend(addr, value, false); err != nil { - return "", nil, err + return nil, err } maxFee, maxFeeRate, err := w.canSend(value, true, false) if err != nil { - return "", nil, err + return nil, err } tx, err := w.sendToAddr(common.HexToAddress(addr), value, maxFeeRate) if err != nil { - return "", nil, err + return nil, err } txHash := tx.Hash() w.addToTxHistory(tx.Nonce(), -int64(value), maxFee, 0, w.assetID, txHash[:], asset.Send) - return txHash.String(), &coin{id: txHash, value: value}, nil + return &coin{id: txHash, value: value}, nil } // ValidateSecret checks that the secret satisfies the contract. diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index c7dd3f30dd..d86eedf9f9 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -4340,7 +4340,7 @@ func testSend(t *testing.T, assetID uint32) { node.tokenContractor.bal = dexeth.GweiToWei(val - test.sendAdj) node.bal = dexeth.GweiToWei(tokenFees - test.feeAdj) } - _, coin, err := w.Send(test.addr, val, 0) + coin, err := w.Send(test.addr, val, 0) if test.wantErr { if err == nil { t.Fatalf("expected error for test %v", test.name) @@ -5253,7 +5253,7 @@ func testEstimateVsActualSendFees(t *testing.T, assetID uint32) { if err != nil { t.Fatalf("error converting canSend to gwei: %v", err) } - _, _, err = w.Send(testAddr, canSendGwei, 0) + _, err = w.Send(testAddr, canSendGwei, 0) if err != nil { t.Fatalf("error sending: %v", err) } @@ -5261,7 +5261,7 @@ func testEstimateVsActualSendFees(t *testing.T, assetID uint32) { tokenVal := uint64(10e9) node.tokenContractor.bal = dexeth.GweiToWei(tokenVal) node.bal = dexeth.GweiToWei(fee) - _, _, err = w.Send(testAddr, tokenVal, 0) + _, err = w.Send(testAddr, tokenVal, 0) if err != nil { t.Fatalf("error sending: %v", err) } diff --git a/client/asset/eth/fundingcoin.go b/client/asset/eth/fundingcoin.go index f792b1ccb0..d590f1376d 100644 --- a/client/asset/eth/fundingcoin.go +++ b/client/asset/eth/fundingcoin.go @@ -32,6 +32,10 @@ func (c *fundingCoin) ID() dex.Bytes { return []byte(c.addr.String()) } +func (c *fundingCoin) TxID() string { + return "" +} + // Value returns the value reserved in the funding coin. func (c *fundingCoin) Value() uint64 { return c.amt diff --git a/client/asset/interface.go b/client/asset/interface.go index e527fb8deb..49d6a0b53f 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -531,7 +531,7 @@ type Wallet interface { TransactionConfirmations(ctx context.Context, txID string) (confs uint32, err error) // Send sends the exact value to the specified address. This is different // from Withdraw, which subtracts the tx fees from the amount sent. - Send(address string, value, feeRate uint64) (txID string, coin Coin, err error) + Send(address string, value, feeRate uint64) (coin Coin, err error) // EstimateRegistrationTxFee returns an estimate for the tx fee needed to // pay the registration fee using the provided feeRate. EstimateRegistrationTxFee(feeRate uint64) uint64 @@ -643,7 +643,7 @@ type Recoverer interface { type Withdrawer interface { // Withdraw withdraws funds to the specified address. Fees are subtracted // from the value. - Withdraw(address string, value, feeRate uint64) (txID string, coin Coin, err error) + Withdraw(address string, value, feeRate uint64) (coin Coin, err error) } // Sweeper is a wallet that can clear the entire balance of the wallet/account @@ -1222,6 +1222,10 @@ type Coin interface { Value() uint64 } +type TxCoin interface { + TxID() string +} + type RecoveryCoin interface { // RecoveryID is an ID that can be used to re-establish funding state during // startup. If a Coin implements RecoveryCoin, the RecoveryID will be used diff --git a/client/asset/zec/zec.go b/client/asset/zec/zec.go index 51f356e261..bde86c37af 100644 --- a/client/asset/zec/zec.go +++ b/client/asset/zec/zec.go @@ -2115,12 +2115,12 @@ func (w *zecWallet) EstimateSendTxFee( return } -func (w *zecWallet) Send(address string, value, feeRate uint64) (string, asset.Coin, error) { +func (w *zecWallet) Send(address string, value, feeRate uint64) (asset.Coin, error) { txHash, vout, sent, err := w.send(address, value, false) if err != nil { - return "", nil, err + return nil, err } - return txHash.String(), btc.NewOutput(txHash, vout, sent), nil + return btc.NewOutput(txHash, vout, sent), nil } // TransactionConfirmations gets the number of confirmations for the specified diff --git a/client/core/core.go b/client/core/core.go index 44fcc5138f..75fd371253 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -4335,7 +4335,7 @@ func (c *Core) Register(form *RegisterForm) (*RegisterResult, error) { "Do NOT manually send funds to this address even if this fails.", regRes.Address, dc.acct.id, regRes.Fee, regFeeAssetSymbol) feeRate := c.feeSuggestionAny(feeAsset.ID, dc) - _, coin, err := wallet.Send(regRes.Address, regRes.Fee, feeRate) + coin, err := wallet.Send(regRes.Address, regRes.Fee, feeRate) if err != nil { return nil, newError(feeSendErr, "error paying registration fee: %w", err) } @@ -5486,13 +5486,12 @@ func (c *Core) Send(pw []byte, assetID uint32, value uint64, address string, sub } var coin asset.Coin - var txID string feeSuggestion := c.feeSuggestionAny(assetID) if !subtract { - txID, coin, err = wallet.Wallet.Send(address, value, feeSuggestion) + coin, err = wallet.Wallet.Send(address, value, feeSuggestion) } else { if withdrawer, isWithdrawer := wallet.Wallet.(asset.Withdrawer); isWithdrawer { - txID, coin, err = withdrawer.Withdraw(address, value, feeSuggestion) + coin, err = withdrawer.Withdraw(address, value, feeSuggestion) } else { return "", nil, fmt.Errorf("wallet does not support subtracting network fee from withdraw amount") } @@ -5508,7 +5507,13 @@ func (c *Core) Send(pw []byte, assetID uint32, value uint64, address string, sub c.notify(newSendNote(TopicSendSuccess, subject, details, db.Success)) c.updateAssetBalance(assetID) - return txID, coin, nil + + txCoin, is := coin.(asset.TxCoin) + if !is { + return "", nil, fmt.Errorf("Send successful, but returned coin is not a TxCoin") + } + + return txCoin.TxID(), coin, nil } // TransactionConfirmations returns the number of confirmations of diff --git a/client/core/core_test.go b/client/core/core_test.go index aa357f5062..c4c3979180 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -606,6 +606,10 @@ func (c *tCoin) ID() dex.Bytes { return c.id } +func (c *tCoin) TxID() string { + return "" +} + func (c *tCoin) String() string { return hex.EncodeToString(c.id) } @@ -932,19 +936,19 @@ func (w *TXCWallet) ConfirmTime(id dex.Bytes, nConfs uint32) (time.Time, error) return time.Time{}, nil } -func (w *TXCWallet) Send(address string, value, feeSuggestion uint64) (string, asset.Coin, error) { +func (w *TXCWallet) Send(address string, value, feeSuggestion uint64) (asset.Coin, error) { w.sendFeeSuggestion = feeSuggestion w.sendCoin.val = value - return "", w.sendCoin, w.sendErr + return w.sendCoin, w.sendErr } func (w *TXCWallet) SendTransaction(rawTx []byte) ([]byte, error) { return w.feeCoinSent, w.sendTxnErr } -func (w *TXCWallet) Withdraw(address string, value, feeSuggestion uint64) (string, asset.Coin, error) { +func (w *TXCWallet) Withdraw(address string, value, feeSuggestion uint64) (asset.Coin, error) { w.sendFeeSuggestion = feeSuggestion - return "", w.sendCoin, w.sendErr + return w.sendCoin, w.sendErr } func (w *TXCWallet) EstimateRegistrationTxFee(feeRate uint64) uint64 { diff --git a/client/mm/config.go b/client/mm/config.go index 0cc3a8086c..3d02be1b33 100644 --- a/client/mm/config.go +++ b/client/mm/config.go @@ -68,7 +68,7 @@ func (c *BotConfig) requiresPriceOracle() bool { } func (c *BotConfig) requiresCEX() bool { - return c.SimpleArbConfig != nil || c.MMWithCEXConfig != nil + return c.SimpleArbConfig != nil || c.ArbMarketMakerConfig != nil } func dexMarketID(host string, base, quote uint32) string { diff --git a/client/mm/mm.go b/client/mm/mm.go index 6acd6e66d9..0422ca3f28 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -1111,9 +1111,10 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s go func(cfg *BotConfig) { defer wg.Done() logger := m.log.SubLogger(fmt.Sprintf("SimpleArbitrage-%s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset)) - cex, err := getConnectedCEX(cfg.SimpleArbConfig.CEXName) - if err != nil { - logger.Errorf("Failed to connect to CEX: %v", err) + mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + cex, found := cexes[cfg.CEXCfg.Name] + if !found { + logger.Errorf("Cannot start %s bot due to CEX not starting", mktID) return } mkt := MarketWithHost{cfg.Host, cfg.BaseAsset, cfg.QuoteAsset} @@ -1121,12 +1122,6 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s defer func() { m.markBotAsRunning(mkt, false) }() - mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) - cex, found := cexes[cfg.CEXCfg.Name] - if !found { - logger.Errorf("Cannot start %s bot due to CEX not starting", mktID) - return - } RunSimpleArbBot(m.ctx, cfg, m.wrappedCoreForBot(mktID), m.wrappedCEXForBot(mktID, cex), logger) }(cfg) case cfg.ArbMarketMakerConfig != nil: @@ -1134,11 +1129,17 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s go func(cfg *BotConfig) { defer wg.Done() logger := m.log.SubLogger(fmt.Sprintf("ArbMarketMaker-%s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset)) - cex, err := getConnectedCEX(cfg.ArbMarketMakerConfig.CEXName) - if err != nil { - logger.Errorf("Failed to connect to CEX: %v", err) + cex, found := cexes[cfg.CEXCfg.Name] + mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + if !found { + logger.Errorf("Cannot start %s bot due to CEX not starting", mktID) return } + mkt := MarketWithHost{cfg.Host, cfg.BaseAsset, cfg.QuoteAsset} + m.markBotAsRunning(mkt, true) + defer func() { + m.markBotAsRunning(mkt, false) + }() RunArbMarketMaker(m.ctx, cfg, m.core, cex, logger) }(cfg) default: diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index b3a70c20d8..697e7e2d35 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -158,7 +158,7 @@ func (a *arbMarketMaker) processDEXMatch(o *core.Order, match *core.Match) { a.cexTradesMtx.Lock() defer a.cexTradesMtx.Unlock() - tradeID, err := a.cex.Trade(a.ctx, dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote), !o.Sell, cexRate, match.Qty, a.cexTradeUpdatesID) + tradeID, err := a.cex.Trade(a.ctx, a.base, a.quote, !o.Sell, cexRate, match.Qty, a.cexTradeUpdatesID) if err != nil { a.log.Errorf("Error sending trade to CEX: %v", err) return @@ -218,7 +218,7 @@ func (a *arbMarketMaker) processDEXOrderNote(note *core.OrderNote) { } func (a *arbMarketMaker) vwap(sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { - return a.cex.VWAP(dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote), sell, qty) + return a.cex.VWAP(a.base, a.quote, sell, qty) } type arbMMRebalancer interface { @@ -273,7 +273,7 @@ func (a *arbMarketMaker) cancelCEXTrades() { for tradeID, epoch := range a.cexTrades { if currEpoch-epoch >= a.cfg.NumEpochsLeaveOpen { - err := a.cex.CancelTrade(a.ctx, dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote), tradeID) + err := a.cex.CancelTrade(a.ctx, a.base, a.quote, tradeID) if err != nil { a.log.Errorf("Error canceling CEX trade %s: %v", tradeID, err) } @@ -314,13 +314,13 @@ func arbMarketMakerRebalance(newEpoch uint64, a arbMMRebalancer, c clientCore, c return } - baseCEXBalance, err := cex.Balance(mkt.BaseSymbol) + baseCEXBalance, err := cex.Balance(mkt.BaseID) if err != nil { log.Errorf("error getting base CEX balance: %v", err) return } - quoteCEXBalance, err := cex.Balance(mkt.QuoteSymbol) + quoteCEXBalance, err := cex.Balance(mkt.QuoteID) if err != nil { log.Errorf("error getting quote CEX balance: %v", err) return @@ -572,7 +572,7 @@ func (a *arbMarketMaker) run() { a.updateFeeRates() - err = a.cex.SubscribeMarket(a.ctx, dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote)) + err = a.cex.SubscribeMarket(a.ctx, a.base, a.quote) if err != nil { a.log.Errorf("Failed to subscribe to cex market: %v", err) return From 92301e70d2588018a239d4fc43ac7dce7c5bda35 Mon Sep 17 00:00:00 2001 From: martonp Date: Mon, 23 Oct 2023 23:34:24 -0400 Subject: [PATCH 7/9] Update wbtc and weth handling --- client/asset/polygon/polygon.go | 4 +- client/mm/libxc/binance.go | 109 ++++++++++++++++++++------- client/mm/libxc/binance_live_test.go | 35 ++++++++- client/mm/libxc/binance_test.go | 19 +++++ client/webserver/site/src/js/doc.ts | 3 +- dex/bip-id.go | 4 +- dex/networks/polygon/params.go | 4 +- server/asset/polygon/polygon.go | 4 +- 8 files changed, 141 insertions(+), 41 deletions(-) diff --git a/client/asset/polygon/polygon.go b/client/asset/polygon/polygon.go index 4609a642e8..ac5e0486c9 100644 --- a/client/asset/polygon/polygon.go +++ b/client/asset/polygon/polygon.go @@ -53,8 +53,8 @@ const ( var ( simnetTokenID, _ = dex.BipSymbolID("dextt.polygon") usdcTokenID, _ = dex.BipSymbolID("usdc.polygon") - wethTokenID, _ = dex.BipSymbolID("eth.polygon") - wbtcTokenID, _ = dex.BipSymbolID("btc.polygon") + wethTokenID, _ = dex.BipSymbolID("weth.polygon") + wbtcTokenID, _ = dex.BipSymbolID("wbtc.polygon") // WalletInfo defines some general information about a Polygon Wallet(EVM // Compatible). diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index 1cc20ade9c..c32f767b98 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -82,13 +82,51 @@ type bncAssetConfig struct { } // TODO: check all symbols -func mapDEXSymbolToBinance(symbol string) string { - if symbol == "POLYGON" { - return "MATIC" +var dexToBinanceSymbol = map[string]string{ + "POLYGON": "MATIC", + "WETH": "ETH", +} + +var binanceToDexSymbol = make(map[string]string) + +func init() { + for key, value := range dexToBinanceSymbol { + binanceToDexSymbol[value] = key + } +} + +func mapDexToBinanceSymbol(symbol string) string { + if binanceSymbol, found := dexToBinanceSymbol[symbol]; found { + return binanceSymbol } return symbol } +func binanceCoinNetworkToDexSymbol(coin, network string) string { + if coin == "ETH" && network == "ETH" { + return "eth" + } + + var dexSymbol, dexNetwork string + if symbol, found := binanceToDexSymbol[coin]; found { + dexSymbol = strings.ToLower(symbol) + } else { + dexSymbol = strings.ToLower(coin) + } + + if symbol, found := binanceToDexSymbol[network]; found && network != "ETH" { + dexNetwork = strings.ToLower(symbol) + } else { + dexNetwork = strings.ToLower(network) + } + + if dexSymbol == dexNetwork { + return dexSymbol + } + + return fmt.Sprintf("%s.%s", dexSymbol, dexNetwork) +} + func bncAssetCfg(assetID uint32) (*bncAssetConfig, error) { symbol := dex.BipIDSymbol(assetID) if symbol == "" { @@ -97,8 +135,7 @@ func bncAssetCfg(assetID uint32) (*bncAssetConfig, error) { coin := strings.ToUpper(symbol) chain := strings.ToUpper(symbol) - if token := asset.TokenInfo(assetID); token != nil { - parts := strings.Split(symbol, ".") + if parts := strings.Split(symbol, "."); len(parts) > 1 { coin = strings.ToUpper(parts[0]) chain = strings.ToUpper(parts[1]) } @@ -110,8 +147,8 @@ func bncAssetCfg(assetID uint32) (*bncAssetConfig, error) { return &bncAssetConfig{ symbol: symbol, - coin: mapDEXSymbolToBinance(coin), - chain: mapDEXSymbolToBinance(chain), + coin: mapDexToBinanceSymbol(coin), + chain: mapDexToBinanceSymbol(chain), conversionFactor: ui.Conventional.ConversionFactor, }, nil } @@ -226,6 +263,12 @@ func (bnc *binance) getCoinInfo(ctx context.Context) error { tokenIDs := make(map[string][]uint32) for _, nfo := range coins { + if nfo.Coin == "WBTC" { + bnc.log.Infof("WBTC INFO: %+v", nfo) + for _, netInfo := range nfo.NetworkList { + bnc.log.Infof("%+v", netInfo) + } + } tokenSymbol := strings.ToLower(nfo.Coin) chainIDs, isToken := dex.TokenChains[tokenSymbol] if !isToken { @@ -523,6 +566,8 @@ func (bnc *binance) Withdraw(ctx context.Context, assetID uint32, qty uint64, ad return false, 0, "" } + bnc.log.Tracef("Withdrawal status: %+v", status) + amt := status.Amount * float64(assetCfg.conversionFactor) return status.Status == 6, uint64(amt), status.TxID } @@ -576,45 +621,52 @@ func (bnc *binance) ConfirmDeposit(ctx context.Context, txID string, onConfirm f waitingUserConfirmStatus = 8 ) - checkDepositStatus := func() (success, done bool) { - var resp []*struct { - Amount float64 `json:"amount,string"` - Coin string `json:"coin"` - Network string `json:"network"` - Status int `json:"status"` - Address string `json:"address"` - AddressTag string `json:"addressTag"` - TxID string `json:"txId"` - InsertTime int64 `json:"insertTime"` - TransferType int `json:"transferType"` - ConfirmTimes string `json:"confirmTimes"` + checkDepositStatus := func() (success, 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"` } - err := bnc.getAPI(ctx, "/sapi/v1/capital/deposit/hisrec", nil, true, true, resp) + err := bnc.getAPI(ctx, "/sapi/v1/capital/deposit/hisrec", nil, true, true, &resp) if err != nil { bnc.log.Errorf("error getting deposit status: %v", err) - return false, false + return false, false, 0 } for _, status := range resp { if status.TxID == txID { switch status.Status { case successStatus, creditedStatus: - return true, true + dexSymbol := binanceCoinNetworkToDexSymbol(status.Coin, status.Network) + assetID, found := dex.BipSymbolID(dexSymbol) + if !found { + bnc.log.Errorf("Failed to find DEX asset ID for Coin: %s, Network %s", status.Coin, status.Network) + return false, true, 0 + } + ui, err := asset.UnitInfo(assetID) + if err != nil { + bnc.log.Errorf("Failed to find unit info for asset ID %d", assetID) + return false, true, 0 + } + amount := uint64(status.Amount * float64(ui.Conventional.ConversionFactor)) + return true, true, amount case pendingStatus: - return false, false + return false, false, 0 case waitingUserConfirmStatus: // This shouldn't ever happen. bnc.log.Errorf("Deposit %s to binance requires user confirmation!") - return false, false + return false, true, 0 case wrongDepositStatus: - return false, true + return false, true, 0 default: bnc.log.Errorf("Deposit %s to binance has an unknown status %d", status.Status) } } } - return false, false + return false, false, 0 } go func() { @@ -626,10 +678,9 @@ func (bnc *binance) ConfirmDeposit(ctx context.Context, txID string, onConfirm f case <-ctx.Done(): return case <-ticker.C: - success, done := checkDepositStatus() + success, done, amt := checkDepositStatus() if done { - // TODO: get amount - onConfirm(success, 0) + onConfirm(success, amt) return } } diff --git a/client/mm/libxc/binance_live_test.go b/client/mm/libxc/binance_live_test.go index a63eacccb6..e5266a43ba 100644 --- a/client/mm/libxc/binance_live_test.go +++ b/client/mm/libxc/binance_live_test.go @@ -73,6 +73,15 @@ func init() { }, }, }, &asset.WalletDefinition{}, dex.Mainnet, dex.Testnet, dex.Simnet) + asset.RegisterToken(966002, &dex.Token{ + ParentID: 966, + Name: "WETH", + UnitInfo: dex.UnitInfo{ + Conventional: dex.Denomination{ + ConversionFactor: 1e9, + }, + }, + }, &asset.WalletDefinition{}, dex.Mainnet, dex.Testnet, dex.Simnet) } func TestConnect(t *testing.T) { @@ -235,7 +244,7 @@ func TestVWAP(t *testing.T) { } func TestWithdrawal(t *testing.T) { - bnc := tNewBinance(t, dex.Testnet) + bnc := tNewBinance(t, dex.Mainnet) ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) defer cancel() @@ -251,7 +260,7 @@ func TestWithdrawal(t *testing.T) { wg.Done() } - err = bnc.Withdraw(ctx, 60001, 4e10, "", onComplete) + err = bnc.Withdraw(ctx, 966, 2e10, "", onComplete) if err != nil { fmt.Printf("withdrawal error: %v", err) return @@ -260,6 +269,28 @@ func TestWithdrawal(t *testing.T) { wg.Wait() } +func TestConfirmDeposit(t *testing.T) { + bnc := tNewBinance(t, dex.Mainnet) + ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) + defer cancel() + + _, err := bnc.Connect(ctx) + if err != nil { + t.Fatalf("Connect error: %v", err) + } + + wg := sync.WaitGroup{} + wg.Add(1) + onComplete := func(success bool, amount uint64) { + t.Logf("deposit complete: %v, %v", success, amount) + wg.Done() + } + + bnc.ConfirmDeposit(ctx, "", onComplete) + + wg.Wait() +} + func TestGetDepositAddress(t *testing.T) { bnc := tNewBinance(t, dex.Mainnet) ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) diff --git a/client/mm/libxc/binance_test.go b/client/mm/libxc/binance_test.go index d8dd463c3f..d76c49288f 100644 --- a/client/mm/libxc/binance_test.go +++ b/client/mm/libxc/binance_test.go @@ -38,3 +38,22 @@ func TestSubscribeTradeUpdates(t *testing.T) { t.Fatalf("id2 not found") } } + +func TestBinanceToDexSymbol(t *testing.T) { + tests := map[[2]string]string{ + {"ETH", "ETH"}: "eth", + {"ETH", "MATIC"}: "weth.polygon", + {"MATIC", "MATIC"}: "polygon", + {"USDC", "ETH"}: "usdc.eth", + {"USDC", "MATIC"}: "usdc.polygon", + {"BTC", "BTC"}: "btc", + {"WBTC", "ETH"}: "wbtc.eth", + } + + for test, expected := range tests { + dexSymbol := binanceCoinNetworkToDexSymbol(test[0], test[1]) + if expected != dexSymbol { + t.Fatalf("expected %s but got %v", expected, dexSymbol) + } + } +} diff --git a/client/webserver/site/src/js/doc.ts b/client/webserver/site/src/js/doc.ts index 4f26267370..d14c19722a 100644 --- a/client/webserver/site/src/js/doc.ts +++ b/client/webserver/site/src/js/doc.ts @@ -37,8 +37,7 @@ const BipIDs: Record = { 966000: 'dextt.polygon', 966001: 'usdc.polygon', 966002: 'weth.polygon', - 966003: 'wbtc.polygon', - 147: 'zcl' + 966003: 'wbtc.polygon' } const BipSymbolIDs: Record = {}; diff --git a/dex/bip-id.go b/dex/bip-id.go index 46ba91d10a..26b3ff1ef7 100644 --- a/dex/bip-id.go +++ b/dex/bip-id.go @@ -619,8 +619,8 @@ var bipIDs = map[uint32]string{ // Polygon reserved token range 966000-966999 966000: "dextt.polygon", 966001: "usdc.polygon", - 966002: "eth.polygon", - 966003: "btc.polygon", + 966002: "weth.polygon", + 966003: "wbtc.polygon", // END Polygon reserved token range 1171337: "ilt", 1313114: "etho", diff --git a/dex/networks/polygon/params.go b/dex/networks/polygon/params.go index b54fb7166a..868da483a0 100644 --- a/dex/networks/polygon/params.go +++ b/dex/networks/polygon/params.go @@ -57,8 +57,8 @@ var ( testTokenID, _ = dex.BipSymbolID("dextt.polygon") usdcTokenID, _ = dex.BipSymbolID("usdc.polygon") - wethTokenID, _ = dex.BipSymbolID("eth.polygon") - wbtcTokenID, _ = dex.BipSymbolID("btc.polygon") + wethTokenID, _ = dex.BipSymbolID("weth.polygon") + wbtcTokenID, _ = dex.BipSymbolID("wbtc.polygon") Tokens = map[uint32]*dexeth.Token{ testTokenID: TestToken, diff --git a/server/asset/polygon/polygon.go b/server/asset/polygon/polygon.go index 399cee1acd..9f8e3faf88 100644 --- a/server/asset/polygon/polygon.go +++ b/server/asset/polygon/polygon.go @@ -63,8 +63,8 @@ const ( var ( testTokenID, _ = dex.BipSymbolID("dextt.polygon") usdcID, _ = dex.BipSymbolID("usdc.polygon") - wethTokenID, _ = dex.BipSymbolID("eth.polygon") - wbtcTokenID, _ = dex.BipSymbolID("btc.polygon") + wethTokenID, _ = dex.BipSymbolID("weth.polygon") + wbtcTokenID, _ = dex.BipSymbolID("wbtc.polygon") // blockPollInterval is the delay between calls to bestBlockHash to check // for new blocks. Modify at compile time via blockPollIntervalStr: From 996b2a6c10264056a9312b09ce3669937940face Mon Sep 17 00:00:00 2001 From: martonp Date: Sun, 26 Nov 2023 22:04:29 -0500 Subject: [PATCH 8/9] Joe review updates --- client/asset/btc/btc_test.go | 1 + client/asset/btc/types.go | 5 ++ client/asset/dcr/dcr_test.go | 1 + client/asset/eth/eth_test.go | 3 + client/asset/eth/fundingcoin.go | 4 + client/asset/interface.go | 9 +-- client/core/core.go | 7 +- client/mm/config.go | 2 +- client/mm/libxc/binance.go | 27 ++++--- client/mm/mm_arb_market_maker.go | 7 +- client/mm/mm_arb_market_maker_test.go | 102 +++++++++++++------------- client/mm/mm_test.go | 1 + client/mm/sample-config.json | 7 ++ client/mm/wrapped_cex.go | 28 ++++--- client/rpcserver/handlers_test.go | 3 + client/webserver/live_test.go | 4 + client/webserver/webserver_test.go | 4 + dex/testing/dcrdex/harness.sh | 4 +- 18 files changed, 133 insertions(+), 86 deletions(-) diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index 2fd90ae1a1..9ea90088d6 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -2489,6 +2489,7 @@ func (c *tCoin) ID() dex.Bytes { return make([]byte, 36) } func (c *tCoin) String() string { return hex.EncodeToString(c.id) } +func (c *tCoin) TxID() string { return hex.EncodeToString(c.id) } func (c *tCoin) Value() uint64 { return 100 } func TestReturnCoins(t *testing.T) { diff --git a/client/asset/btc/types.go b/client/asset/btc/types.go index d06e46397f..c33bbb6ac9 100644 --- a/client/asset/btc/types.go +++ b/client/asset/btc/types.go @@ -69,6 +69,11 @@ func (op *Output) String() string { return op.Pt.String() } +// TxID is the ID of the transaction used to create the coin. +func (op *Output) TxID() string { + return op.txHash().String() +} + // txHash returns the pointer of the wire.OutPoint's Hash. func (op *Output) txHash() *chainhash.Hash { return &op.Pt.TxHash diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 0791d1cef8..5a6b7e46aa 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -1065,6 +1065,7 @@ func (c *tCoin) ID() dex.Bytes { func (c *tCoin) String() string { return hex.EncodeToString(c.id) } func (c *tCoin) Value() uint64 { return 100 } func (c *tCoin) Confirmations(ctx context.Context) (uint32, error) { return 2, nil } +func (c *tCoin) TxID() string { return hex.EncodeToString(c.id) } func TestReturnCoins(t *testing.T) { wallet, node, shutdown := tNewWallet() diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index d86eedf9f9..141e482a2f 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -1483,6 +1483,9 @@ func (*badCoin) ID() dex.Bytes { func (*badCoin) String() string { return "abc" } +func (*badCoin) TxID() string { + return "abc" +} func (b *badCoin) Value() uint64 { return uint64(*b) } diff --git a/client/asset/eth/fundingcoin.go b/client/asset/eth/fundingcoin.go index d590f1376d..536cbd1779 100644 --- a/client/asset/eth/fundingcoin.go +++ b/client/asset/eth/fundingcoin.go @@ -93,6 +93,10 @@ func (c *tokenFundingCoin) ID() dex.Bytes { return []byte(c.addr.String()) } +func (c *tokenFundingCoin) TxID() string { + return "" +} + // ID creates a byte slice that can be decoded with DecodeCoinID. func (c *tokenFundingCoin) RecoveryID() dex.Bytes { b := make([]byte, tokenFundingCoinIDSize) diff --git a/client/asset/interface.go b/client/asset/interface.go index 49d6a0b53f..8fdaa2dfc0 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -526,8 +526,9 @@ type Wallet interface { // payment. This method need not be supported by all assets. Those assets // which do no support DEX registration fees will return an ErrUnsupported. RegFeeConfirmations(ctx context.Context, coinID dex.Bytes) (confs uint32, err error) - // TransactionConfirmations gets the number of confirmations for the specified - // transaction. + // TransactionConfirmations gets the number of confirmations for the + // specified transaction. If the wallet does not know about the + // transaction, asset.CoinNotFoundError is returned. TransactionConfirmations(ctx context.Context, txID string) (confs uint32, err error) // Send sends the exact value to the specified address. This is different // from Withdraw, which subtracts the tx fees from the amount sent. @@ -1220,9 +1221,7 @@ type Coin interface { String() string // Value is the available quantity, in atoms/satoshi. Value() uint64 -} - -type TxCoin interface { + // TxID is the ID of the transaction that created the coin. TxID() string } diff --git a/client/core/core.go b/client/core/core.go index 75fd371253..98709521fc 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -5508,12 +5508,7 @@ func (c *Core) Send(pw []byte, assetID uint32, value uint64, address string, sub c.updateAssetBalance(assetID) - txCoin, is := coin.(asset.TxCoin) - if !is { - return "", nil, fmt.Errorf("Send successful, but returned coin is not a TxCoin") - } - - return txCoin.TxID(), coin, nil + return coin.TxID(), coin, nil } // TransactionConfirmations returns the number of confirmations of diff --git a/client/mm/config.go b/client/mm/config.go index 3d02be1b33..43a1dc1698 100644 --- a/client/mm/config.go +++ b/client/mm/config.go @@ -27,7 +27,7 @@ type CEXConfig struct { APISecret string `json:"apiSecret"` } -// BotCEXCfg is the specifies the CEX that a bot uses and the initial balances +// BotCEXCfg specifies the CEX that a bot uses and the initial balances // that should be allocated to the bot on that CEX. type BotCEXCfg struct { Name string `json:"name"` diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index c32f767b98..75adb8101b 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -446,12 +446,12 @@ func (bnc *binance) Trade(ctx context.Context, baseID, quoteID uint32, sell bool baseCfg, err := bncAssetCfg(baseID) if err != nil { - return "", fmt.Errorf("error getting asset cfg for %d", baseID) + return "", fmt.Errorf("error getting asset cfg for %d: %w", baseID, err) } quoteCfg, err := bncAssetCfg(quoteID) if err != nil { - return "", fmt.Errorf("error getting asset cfg for %d", quoteID) + return "", fmt.Errorf("error getting asset cfg for %d: %w", quoteID, err) } slug := baseCfg.coin + quoteCfg.coin @@ -511,6 +511,9 @@ func (bnc *binance) assetPrecision(coin string) (int, error) { return 0, fmt.Errorf("asset %s not found", coin) } +// 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. func (bnc *binance) Withdraw(ctx context.Context, assetID uint32, qty uint64, address string, onComplete func(uint64, string)) error { assetCfg, err := bncAssetCfg(assetID) if err != nil { @@ -559,6 +562,7 @@ func (bnc *binance) Withdraw(ctx context.Context, assetID uint32, qty uint64, ad for _, s := range withdrawHistoryResponse { if s.ID == withdrawResp.ID { status = s + break } } if status == nil { @@ -591,6 +595,7 @@ func (bnc *binance) Withdraw(ctx context.Context, assetID uint32, qty uint64, ad return nil } +// GetDepositAddress returns a deposit address for an asset. func (bnc *binance) GetDepositAddress(ctx context.Context, assetID uint32) (string, error) { assetCfg, err := bncAssetCfg(assetID) if err != nil { @@ -612,6 +617,8 @@ func (bnc *binance) GetDepositAddress(ctx context.Context, assetID uint32) (stri return resp.Address, nil } +// 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(bool, uint64)) { const ( pendingStatus = 0 @@ -714,12 +721,12 @@ func (bnc *binance) SubscribeTradeUpdates() (<-chan *TradeUpdate, func(), int) { func (bnc *binance) CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error { baseCfg, err := bncAssetCfg(baseID) if err != nil { - return fmt.Errorf("error getting asset cfg for %d", baseID) + return fmt.Errorf("error getting asset cfg for %d: %w", baseID, err) } quoteCfg, err := bncAssetCfg(quoteID) if err != nil { - return fmt.Errorf("error getting asset cfg for %d", quoteID) + return fmt.Errorf("error getting asset cfg for %d: %w", quoteID, err) } slug := baseCfg.coin + quoteCfg.coin @@ -1331,12 +1338,12 @@ func (bnc *binance) startMarketDataStream(ctx context.Context, baseSymbol, quote func (bnc *binance) UnsubscribeMarket(baseID, quoteID uint32) (err error) { baseCfg, err := bncAssetCfg(baseID) if err != nil { - return fmt.Errorf("error getting asset cfg for %d", baseID) + return fmt.Errorf("error getting asset cfg for %d: %w", baseID, err) } quoteCfg, err := bncAssetCfg(quoteID) if err != nil { - return fmt.Errorf("error getting asset cfg for %d", quoteID) + return fmt.Errorf("error getting asset cfg for %d: %w", quoteID, err) } bnc.stopMarketDataStream(strings.ToLower(baseCfg.coin + quoteCfg.coin)) @@ -1348,12 +1355,12 @@ func (bnc *binance) UnsubscribeMarket(baseID, quoteID uint32) (err error) { func (bnc *binance) SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error { baseCfg, err := bncAssetCfg(baseID) if err != nil { - return fmt.Errorf("error getting asset cfg for %d", baseID) + return fmt.Errorf("error getting asset cfg for %d: %w", baseID, err) } quoteCfg, err := bncAssetCfg(quoteID) if err != nil { - return fmt.Errorf("error getting asset cfg for %d", quoteID) + return fmt.Errorf("error getting asset cfg for %d: %w", quoteID, err) } return bnc.startMarketDataStream(ctx, baseCfg.coin, quoteCfg.coin) @@ -1368,12 +1375,12 @@ func (bnc *binance) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (avgPric baseCfg, err := bncAssetCfg(baseID) if err != nil { - return fail(fmt.Errorf("error getting asset cfg for %d", baseID)) + return fail(fmt.Errorf("error getting asset cfg for %d: %w", baseID, err)) } quoteCfg, err := bncAssetCfg(quoteID) if err != nil { - return fail(fmt.Errorf("error getting asset cfg for %d", quoteID)) + return fail(fmt.Errorf("error getting asset cfg for %d: %w", quoteID, err)) } slug := strings.ToLower(baseCfg.coin + quoteCfg.coin) diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index 697e7e2d35..d771ba1c4d 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -72,7 +72,6 @@ type ArbMarketMakingPlacement struct { // placement. This rate is .007 BTC/DCR. Therefore it will place a sell order // at .00707 BTC/DCR (.007 BTC/DCR * 1.01). type ArbMarketMakerConfig struct { - CEXName string `json:"cexName"` BuyPlacements []*ArbMarketMakingPlacement `json:"buyPlacements"` SellPlacements []*ArbMarketMakingPlacement `json:"sellPlacements"` Profit float64 `json:"profit"` @@ -570,7 +569,11 @@ func (a *arbMarketMaker) run() { } a.book = book - a.updateFeeRates() + err = a.updateFeeRates() + if err != nil { + a.log.Errorf("Failed to get fees: %v", err) + return + } err = a.cex.SubscribeMarket(a.ctx, a.base, a.quote) if err != nil { diff --git a/client/mm/mm_arb_market_maker_test.go b/client/mm/mm_arb_market_maker_test.go index 04863c62be..a6e451cf3c 100644 --- a/client/mm/mm_arb_market_maker_test.go +++ b/client/mm/mm_arb_market_maker_test.go @@ -118,7 +118,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { rebalancer *tArbMMRebalancer cfg *ArbMarketMakerConfig dexBalances map[uint32]uint64 - cexBalances map[string]*libxc.ExchangeBalance + cexBalances map[uint32]*libxc.ExchangeBalance expectedCancels []dex.Bytes expectedBuys []*rateLots @@ -158,9 +158,9 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: lotSize * 3, 0: calc.BaseToQuote(1e6, 3*lotSize), }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedBuys: []*rateLots{{ rate: 1.881e6, @@ -205,9 +205,9 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: lotSize * 3, 0: calc.BaseToQuote(1e6, 3*lotSize), }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, }, // "existing orders outside drift tolerance" @@ -246,9 +246,9 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: lotSize * 3, 0: calc.BaseToQuote(1e6, 3*lotSize), }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedCancels: []dex.Bytes{ orderIDs[0][:], @@ -293,9 +293,9 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: lotSize * 3, 0: calc.BaseToQuote(1e6, 3*lotSize), }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedCancels: []dex.Bytes{ orderIDs[1][:], @@ -331,9 +331,9 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 2*(lotSize+sellFees.swap) + sellFees.funding, 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedBuys: []*rateLots{ { @@ -388,9 +388,9 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 2*(lotSize+sellFees.swap) + sellFees.funding - 1, 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding - 1, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: 1e19}, - "dcr": {Available: 1e19}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, }, expectedBuys: []*rateLots{ { @@ -435,9 +435,9 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 1e19, 0: 1e19, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize)}, - "dcr": {Available: 2 * mkt.LotSize}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize)}, + 42: {Available: 2 * mkt.LotSize}, }, expectedBuys: []*rateLots{ { @@ -492,9 +492,9 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 1e19, 0: 1e19, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) - 1}, - "dcr": {Available: 2*mkt.LotSize - 1}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) - 1}, + 42: {Available: 2*mkt.LotSize - 1}, }, expectedBuys: []*rateLots{ { @@ -551,9 +551,9 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 1e19, 0: 1e19, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize)}, - "dcr": {Available: 2 * mkt.LotSize}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize)}, + 42: {Available: 2 * mkt.LotSize}, }, expectedBuys: []*rateLots{ { @@ -612,9 +612,9 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 1e19, 0: 1e19, }, - cexBalances: map[string]*libxc.ExchangeBalance{ - "btc": {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) - 1}, - "dcr": {Available: 2*mkt.LotSize - 1}, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) - 1}, + 42: {Available: 2*mkt.LotSize - 1}, }, }, } @@ -773,18 +773,18 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { }, expectedCEXTrades: []*cexOrder{ { - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: lotSize, - rate: divideRate(8e5, 1+profit), - sell: false, + baseID: 42, + quoteID: 0, + qty: lotSize, + rate: divideRate(8e5, 1+profit), + sell: false, }, { - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: lotSize, - rate: multiplyRate(6e5, 1+profit), - sell: true, + baseID: 42, + quoteID: 0, + qty: lotSize, + rate: multiplyRate(6e5, 1+profit), + sell: true, }, nil, nil, @@ -856,18 +856,18 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { }, expectedCEXTrades: []*cexOrder{ { - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: lotSize, - rate: divideRate(8e5, 1+profit), - sell: false, + baseID: 42, + quoteID: 0, + qty: lotSize, + rate: divideRate(8e5, 1+profit), + sell: false, }, { - baseSymbol: "dcr", - quoteSymbol: "btc", - qty: lotSize, - rate: multiplyRate(6e5, 1+profit), - sell: true, + baseID: 42, + quoteID: 0, + qty: lotSize, + rate: multiplyRate(6e5, 1+profit), + sell: true, }, nil, nil, diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go index d1fb81b576..a86f57b0da 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -5328,6 +5328,7 @@ func TestSegregatedCEXWithdraw(t *testing.T) { runTest := func(tt test) { tCore := newTCore() tCore.setAssetBalances(tt.dexBalances) + tCore.txConfs = 1 tCore.newDepositAddress = hex.EncodeToString(encode.RandomBytes(32)) cex := newTCEX() diff --git a/client/mm/sample-config.json b/client/mm/sample-config.json index 53fc6eb269..f6993f6404 100644 --- a/client/mm/sample-config.json +++ b/client/mm/sample-config.json @@ -8,6 +8,13 @@ "quoteBalanceType": 0, "baseBalance": 100, "quoteBalance": 100, + "cexCfg": { + "name": "Binance", + "baseBalanceType": 0, + "quoteBalanceType": 0, + "baseBalance": 100, + "quoteBalance": 100 + }, "arbMarketMakerConfig": { "cexName": "Binance", "buyPlacements": [ diff --git a/client/mm/wrapped_cex.go b/client/mm/wrapped_cex.go index d576722da9..438b903140 100644 --- a/client/mm/wrapped_cex.go +++ b/client/mm/wrapped_cex.go @@ -56,12 +56,15 @@ type wrappedCEX struct { var _ cex = (*wrappedCEX)(nil) +// Balance returns the balance of the bot on the CEX. func (w *wrappedCEX) Balance(assetID uint32) (*libxc.ExchangeBalance, error) { return &libxc.ExchangeBalance{ Available: w.mm.botCEXBalance(w.botID, assetID), }, nil } +// Deposit deposits funds to the CEX. The deposited funds will be removed from +// the bot's wallet balance and allocated to the bot's CEX balance. func (w *wrappedCEX) Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { balance := w.mm.botBalance(w.botID, assetID) if balance < amount { @@ -94,6 +97,8 @@ func (w *wrappedCEX) Deposit(ctx context.Context, assetID uint32, amount uint64, return nil } +// Withdraw withdraws funds from the CEX. The withdrawn funds will be removed +// from the bot's CEX balance and added to the bot's wallet balance. func (w *wrappedCEX) Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { symbol := dex.BipIDSymbol(assetID) @@ -110,17 +115,18 @@ func (w *wrappedCEX) Withdraw(ctx context.Context, assetID uint32, amount uint64 conf := func(withdrawnAmt uint64, txID string) { go func() { checkTransaction := func() bool { - _, err := w.mm.core.TransactionConfirmations(assetID, txID) - if err == nil { - // Assign to balance to the bot as long as the wallet - // knows about the transaction. + confs, err := w.mm.core.TransactionConfirmations(assetID, txID) + if err != nil { + if !errors.Is(err, asset.CoinNotFoundError) { + w.log.Errorf("error checking transaction confirmations: %v", err) + } + return false + } + if confs > 0 { w.mm.modifyBotBalance(w.botID, []*balanceMod{{balanceModIncrease, assetID, balTypeAvailable, withdrawnAmt}}) onConfirm() return true } - if !errors.Is(err, asset.CoinNotFoundError) { - w.log.Errorf("error checking transaction confirmations: %v", err) - } return false } @@ -128,8 +134,8 @@ func (w *wrappedCEX) Withdraw(ctx context.Context, assetID uint32, amount uint64 return } - ticker := time.NewTicker(time.Second * 20) - giveUp := time.NewTimer(time.Minute * 10) + ticker := time.NewTicker(time.Minute * 1) + giveUp := time.NewTimer(2 * time.Hour) defer ticker.Stop() defer giveUp.Stop() for { @@ -198,6 +204,8 @@ func (w *wrappedCEX) handleTradeUpdate(update *libxc.TradeUpdate) { delete(w.trades, update.TradeID) } +// SubscibeTradeUpdates subscribes to trade updates for the bot's trades on the +// CEX. This should be called before making any trades, and only once. func (w *wrappedCEX) SubscribeTradeUpdates() (<-chan *libxc.TradeUpdate, func()) { w.subscriptionIDMtx.Lock() defer w.subscriptionIDMtx.Unlock() @@ -230,6 +238,8 @@ func (w *wrappedCEX) SubscribeTradeUpdates() (<-chan *libxc.TradeUpdate, func()) return forwardUpdates, forwardUnsubscribe } +// Trade executes a trade on the CEX. The trade will be executed using the +// bot's CEX balance. func (w *wrappedCEX) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (string, error) { var fromAssetID, toAssetID uint32 var fromAssetQty uint64 diff --git a/client/rpcserver/handlers_test.go b/client/rpcserver/handlers_test.go index ab94f4b9a0..5299b664f9 100644 --- a/client/rpcserver/handlers_test.go +++ b/client/rpcserver/handlers_test.go @@ -836,6 +836,9 @@ func (tCoin) ID() dex.Bytes { func (tCoin) String() string { return "" } +func (tCoin) TxID() string { + return "" +} func (tCoin) Value() uint64 { return 0 } diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 2d6b70932c..c380cd8373 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -478,6 +478,10 @@ func (c *tCoin) String() string { return hex.EncodeToString(c.id) } +func (c *tCoin) TxID() string { + return hex.EncodeToString(c.id) +} + func (c *tCoin) Value() uint64 { return 0 } diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index 89e4c998da..c3284f573c 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -48,6 +48,10 @@ func (c *tCoin) String() string { return hex.EncodeToString(c.id) } +func (c *tCoin) TxID() string { + return hex.EncodeToString(c.id) +} + func (c *tCoin) Value() uint64 { return 0 } diff --git a/dex/testing/dcrdex/harness.sh b/dex/testing/dcrdex/harness.sh index 80b112f404..909676afa8 100755 --- a/dex/testing/dcrdex/harness.sh +++ b/dex/testing/dcrdex/harness.sh @@ -144,8 +144,8 @@ if [ $ETH_ON -eq 0 ]; then "parcelSize": 4 }, { - "base": "BTC_simnet", - "quote": "ETH_simnet", + "base": "ETH_simnet", + "quote": "BTC_simnet", "lotSize": 1000000, "rateStep": 1000, "epochDuration": ${EPOCH_DURATION}, From c3cb5836453663946f8e0c1717a07e542f0b8dc3 Mon Sep 17 00:00:00 2001 From: martonp Date: Fri, 1 Dec 2023 00:55:12 -0500 Subject: [PATCH 9/9] Buck review updates --- client/asset/interface.go | 4 +- client/asset/zec/zec.go | 11 +- client/cmd/testbinance/main.go | 311 +++++++++- client/core/core.go | 52 +- client/core/core_test.go | 16 +- client/mm/config.go | 12 + client/mm/libxc/binance.go | 8 +- client/mm/libxc/binance_live_test.go | 67 ++- client/mm/libxc/interface.go | 12 +- client/mm/mm.go | 14 +- client/mm/mm_arb_market_maker.go | 389 +++++++++++-- client/mm/mm_arb_market_maker_test.go | 782 +++++++++++++++++++++++++- client/mm/mm_basic.go | 18 +- client/mm/mm_simple_arb.go | 26 +- client/mm/mm_simple_arb_test.go | 55 +- client/mm/mm_test.go | 25 +- client/mm/sample-config.json | 6 + client/mm/wrapped_cex.go | 19 +- client/mm/wrapped_core.go | 1 + client/rpcserver/handlers.go | 2 +- client/rpcserver/rpcserver.go | 2 +- client/rpcserver/rpcserver_test.go | 4 +- client/webserver/api.go | 2 +- client/webserver/live_test.go | 4 +- client/webserver/site/src/js/doc.ts | 3 +- client/webserver/webserver.go | 2 +- client/webserver/webserver_test.go | 4 +- dex/testing/dcrdex/harness.sh | 2 +- dex/testing/loadbot/mantle.go | 2 +- 29 files changed, 1651 insertions(+), 204 deletions(-) diff --git a/client/asset/interface.go b/client/asset/interface.go index 8fdaa2dfc0..294cada58d 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -532,7 +532,7 @@ type Wallet interface { TransactionConfirmations(ctx context.Context, txID string) (confs uint32, err error) // Send sends the exact value to the specified address. This is different // from Withdraw, which subtracts the tx fees from the amount sent. - Send(address string, value, feeRate uint64) (coin Coin, err error) + Send(address string, value, feeRate uint64) (Coin, error) // EstimateRegistrationTxFee returns an estimate for the tx fee needed to // pay the registration fee using the provided feeRate. EstimateRegistrationTxFee(feeRate uint64) uint64 @@ -644,7 +644,7 @@ type Recoverer interface { type Withdrawer interface { // Withdraw withdraws funds to the specified address. Fees are subtracted // from the value. - Withdraw(address string, value, feeRate uint64) (coin Coin, err error) + Withdraw(address string, value, feeRate uint64) (Coin, error) } // Sweeper is a wallet that can clear the entire balance of the wallet/account diff --git a/client/asset/zec/zec.go b/client/asset/zec/zec.go index bde86c37af..0cb658f0f9 100644 --- a/client/asset/zec/zec.go +++ b/client/asset/zec/zec.go @@ -2126,7 +2126,16 @@ func (w *zecWallet) Send(address string, value, feeRate uint64) (asset.Coin, err // TransactionConfirmations gets the number of confirmations for the specified // transaction. func (w *zecWallet) TransactionConfirmations(ctx context.Context, txID string) (confs uint32, err error) { - return + txHash, err := chainhash.NewHashFromStr(txID) + if err != nil { + return 0, fmt.Errorf("error decoding txid %q: %w", txID, err) + } + res, err := getWalletTransaction(w, txHash) + if err != nil { + return 0, err + } + + return uint32(res.Confirmations), nil } // send the value to the address, with the given fee rate. If subtract is true, diff --git a/client/cmd/testbinance/main.go b/client/cmd/testbinance/main.go index 046844bd5b..920856e867 100644 --- a/client/cmd/testbinance/main.go +++ b/client/cmd/testbinance/main.go @@ -1,12 +1,13 @@ package main /* - * Starts an http server that responds with a hardcoded result to the binance API's - * "/sapi/v1/capital/config/getall" endpoint. Binance's testnet does not support the - * "sapi" endpoints, and this is the only "sapi" endpoint that we use. + * Starts an http server that responds to some of the binance api's endpoints. + * The "runserver" command starts the server, and other commands are used to + * update the server's state. */ import ( + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -18,6 +19,7 @@ import ( "decred.org/dcrdex/client/websocket" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/encode" ) const ( @@ -29,24 +31,73 @@ var ( log = dex.StdOutLogger("TBNC", dex.LevelDebug) ) +func printUsage() { + fmt.Println("Commands:") + fmt.Println(" runserver") + fmt.Println(" complete-withdrawal ") + fmt.Println(" complete-deposit ") +} + func main() { - if err := mainErr(); err != nil { - fmt.Fprint(os.Stderr, err, "\n") + if len(os.Args) < 2 { + printUsage() os.Exit(1) } - os.Exit(0) + + cmd := os.Args[1] + + 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() + } } -func mainErr() error { +func runServer() error { f := &fakeBinance{ wsServer: websocket.New(nil, log.SubLogger("WS")), balances: map[string]*balance{ "eth": { - free: 1000.123432, + free: 0.5, locked: 0, }, "btc": { - free: 1000.21314123, + free: 10, locked: 0, }, "ltc": { @@ -62,22 +113,84 @@ func mainErr() error { locked: 0, }, }, + withdrawalHistory: make([]*transfer, 0), + depositHistory: make([]*transfer, 0), } + + // 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) + + // Handlers for updating fake binance state + http.HandleFunc("/completewithdrawal", f.handleCompleteWithdrawal) + http.HandleFunc("/completedeposit", f.handleCompleteDeposit) return http.ListenAndServe(":37346", nil) } +// 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 + } + resp, err := http.DefaultClient.Do(req) + 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 nil +} + +// 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 + } + resp, err := http.DefaultClient.Do(req) + 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 nil +} + type balance struct { free float64 locked float64 } +type transfer struct { + id string + amt float64 + txID string + coin string + network string +} + type fakeBinance struct { wsServer *websocket.Server balanceMtx sync.RWMutex balances map[string]*balance + + withdrawalHistoryMtx sync.RWMutex + withdrawalHistory []*transfer + + depositHistoryMtx sync.RWMutex + depositHistory []*transfer } func (f *fakeBinance) handleWalletCoinsReq(w http.ResponseWriter, r *http.Request) { @@ -85,6 +198,186 @@ func (f *fakeBinance) handleWalletCoinsReq(w http.ResponseWriter, r *http.Reques writeJSONWithStatus(w, ci, http.StatusOK) } +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, + }) + } + f.depositHistoryMtx.RUnlock() + + fmt.Println("\n\nSending deposit history: ") + for _, d := range resp { + fmt.Printf("%+v\n", d) + } + + writeJSONWithStatus(w, resp, http.StatusOK) +} + +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" + } + + resp := struct { + Address string `json:"address"` + }{ + Address: address, + } + + writeJSONWithStatus(w, resp, http.StatusOK) +} + +func (f *fakeBinance) handleWithdrawal(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + err := r.ParseForm() + if err != nil { + fmt.Println("Error parsing form: ", err) + http.Error(w, "Error parsing form", http.StatusBadRequest) + return + } + + amountStr := r.Form.Get("amount") + amt, err := strconv.ParseFloat(amountStr, 64) + if err != nil { + fmt.Println("Error parsing amount: ", err) + http.Error(w, "Error parsing amount", http.StatusBadRequest) + return + } + + withdrawalID := hex.EncodeToString(encode.RandomBytes(32)) + fmt.Printf("\n\nWithdraw called: %+v\nResponding with ID: %s\n", r.Form, withdrawalID) + + f.withdrawalHistoryMtx.Lock() + f.withdrawalHistory = append(f.withdrawalHistory, &transfer{ + id: withdrawalID, + amt: amt * 0.99, + }) + f.withdrawalHistoryMtx.Unlock() + + resp := struct { + ID string `json:"id"` + }{withdrawalID} + writeJSONWithStatus(w, resp, http.StatusOK) +} + +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 { + var status int + if w.txID == "" { + status = 2 + } else { + status = withdrawalCompleteStatus + } + withdrawalHistory = append(withdrawalHistory, &withdrawalHistoryStatus{ + ID: w.id, + Amount: w.amt, + Status: status, + TxID: w.txID, + }) + } + f.withdrawalHistoryMtx.RUnlock() + + fmt.Println("\n\nSending withdrawal history: ") + for _, w := range withdrawalHistory { + fmt.Printf("%+v\n", w) + } + + writeJSONWithStatus(w, withdrawalHistory, http.StatusOK) +} + +func (f *fakeBinance) handleCompleteWithdrawal(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + txid := r.URL.Query().Get("txid") + + if id == "" || txid == "" { + http.Error(w, "Missing id or txid", http.StatusBadRequest) + return + } + + f.withdrawalHistoryMtx.Lock() + for _, w := range f.withdrawalHistory { + if w.id == id { + fmt.Println("\nUpdated withdrawal history") + w.txID = txid + break + } + } + f.withdrawalHistoryMtx.Unlock() + + 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") + + amt, err := strconv.ParseFloat(amtStr, 64) + if err != nil { + fmt.Println("Error parsing amount: ", err) + http.Error(w, "Error parsing amount", http.StatusBadRequest) + return + } + + if txid == "" { + http.Error(w, "Missing txid", http.StatusBadRequest) + return + } + + 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 fakeBinanceNetworkInfo struct { Coin string `json:"coin"` MinConfirm int `json:"minConfirm"` diff --git a/client/core/core.go b/client/core/core.go index 98709521fc..8fa58a92af 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -3741,26 +3741,32 @@ func (c *Core) setWalletPassword(wallet *xcWallet, newPW []byte, crypter encrypt } // NewDepositAddress retrieves a new deposit address from the specified asset's -// wallet, saves it to the database, and emits a notification. +// wallet, saves it to the database, and emits a notification. If the wallet +// does not support generating new addresses, the current address will be +// returned. func (c *Core) NewDepositAddress(assetID uint32) (string, error) { w, exists := c.wallet(assetID) if !exists { return "", newError(missingWalletErr, "no wallet found for %s", unbip(assetID)) } - // Retrieve a fresh deposit address. - addr, err := w.refreshDepositAddress() - if err != nil { - return "", err - } - - if err = c.storeDepositAddress(w.dbID, addr); err != nil { - return "", err + var addr string + if _, ok := w.Wallet.(asset.NewAddresser); ok { + // Retrieve a fresh deposit address. + var err error + addr, err = w.refreshDepositAddress() + if err != nil { + return "", err + } + if err = c.storeDepositAddress(w.dbID, addr); err != nil { + return "", err + } + // Update wallet state in the User data struct and emit a WalletStateNote. + c.notify(newWalletStateNote(w.state())) + } else { + addr = w.address } - // Update wallet state in the User data struct and emit a WalletStateNote. - c.notify(newWalletStateNote(w.state())) - return addr, nil } @@ -5455,7 +5461,7 @@ func (c *Core) feeSuggestion(dc *dexConnection, assetID uint32) (feeSuggestion u // Send initiates either send or withdraw from an exchange wallet. if subtract // is true, fees are subtracted from the value else fees are taken from the // exchange wallet. -func (c *Core) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) { +func (c *Core) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) { var crypter encrypt.Crypter // Empty password can be provided if wallet is already unlocked. Webserver // and RPCServer should not allow empty password, but this is used for @@ -5464,25 +5470,25 @@ func (c *Core) Send(pw []byte, assetID uint32, value uint64, address string, sub var err error crypter, err = c.encryptionKey(pw) if err != nil { - return "", nil, fmt.Errorf("Trade password error: %w", err) + return nil, fmt.Errorf("Trade password error: %w", err) } defer crypter.Close() } if value == 0 { - return "", nil, fmt.Errorf("cannot send/withdraw zero %s", unbip(assetID)) + return nil, fmt.Errorf("cannot send/withdraw zero %s", unbip(assetID)) } wallet, found := c.wallet(assetID) if !found { - return "", nil, newError(missingWalletErr, "no wallet found for %s", unbip(assetID)) + return nil, newError(missingWalletErr, "no wallet found for %s", unbip(assetID)) } err := c.connectAndUnlock(crypter, wallet) if err != nil { - return "", nil, err + return nil, err } if err = wallet.checkPeersAndSyncStatus(); err != nil { - return "", nil, err + return nil, err } var coin asset.Coin @@ -5493,13 +5499,13 @@ func (c *Core) Send(pw []byte, assetID uint32, value uint64, address string, sub if withdrawer, isWithdrawer := wallet.Wallet.(asset.Withdrawer); isWithdrawer { coin, err = withdrawer.Withdraw(address, value, feeSuggestion) } else { - return "", nil, fmt.Errorf("wallet does not support subtracting network fee from withdraw amount") + return nil, fmt.Errorf("wallet does not support subtracting network fee from withdraw amount") } } if err != nil { subject, details := c.formatDetails(TopicSendError, unbip(assetID), err) c.notify(newSendNote(TopicSendError, subject, details, db.ErrorLevel)) - return "", nil, err + return nil, err } sentValue := wallet.Info().UnitInfo.ConventionalString(coin.Value()) @@ -5508,11 +5514,11 @@ func (c *Core) Send(pw []byte, assetID uint32, value uint64, address string, sub c.updateAssetBalance(assetID) - return coin.TxID(), coin, nil + return coin, nil } -// TransactionConfirmations returns the number of confirmations of -// a transaction. +// TransactionConfirmations returns the number of confirmations of a +// transaction. func (c *Core) TransactionConfirmations(assetID uint32, txid string) (confirmations uint32, err error) { wallet, err := c.connectedWallet(assetID) if err != nil { diff --git a/client/core/core_test.go b/client/core/core_test.go index c4c3979180..740933f311 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -2740,7 +2740,7 @@ func TestSend(t *testing.T) { address := "addr" // Successful - _, coin, err := tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) + coin, err := tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) if err != nil { t.Fatalf("Send error: %v", err) } @@ -2749,13 +2749,13 @@ func TestSend(t *testing.T) { } // 0 value - _, _, err = tCore.Send(tPW, tUTXOAssetA.ID, 0, address, false) + _, err = tCore.Send(tPW, tUTXOAssetA.ID, 0, address, false) if err == nil { t.Fatalf("no error for zero value send") } // no wallet - _, _, err = tCore.Send(tPW, 12345, 1e8, address, false) + _, err = tCore.Send(tPW, 12345, 1e8, address, false) if err == nil { t.Fatalf("no error for unknown wallet") } @@ -2763,7 +2763,7 @@ func TestSend(t *testing.T) { // connect error wallet.hookedUp = false tWallet.connectErr = tErr - _, _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) + _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) if err == nil { t.Fatalf("no error for wallet connect error") } @@ -2771,7 +2771,7 @@ func TestSend(t *testing.T) { // Send error tWallet.sendErr = tErr - _, _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) + _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) if err == nil { t.Fatalf("no error for wallet send error") } @@ -2779,7 +2779,7 @@ func TestSend(t *testing.T) { // Check the coin. tWallet.sendCoin = &tCoin{id: []byte{'a'}} - _, coin, err = tCore.Send(tPW, tUTXOAssetA.ID, 3e8, address, false) + coin, err = tCore.Send(tPW, tUTXOAssetA.ID, 3e8, address, false) if err != nil { t.Fatalf("coin check error: %v", err) } @@ -2805,7 +2805,7 @@ func TestSend(t *testing.T) { wallet.Wallet = feeRater - _, coin, err = tCore.Send(tPW, tUTXOAssetA.ID, 2e8, address, false) + coin, err = tCore.Send(tPW, tUTXOAssetA.ID, 2e8, address, false) if err != nil { t.Fatalf("FeeRater Withdraw/send error: %v", err) } @@ -2819,7 +2819,7 @@ func TestSend(t *testing.T) { // wallet is not synced wallet.synced = false - _, _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) + _, err = tCore.Send(tPW, tUTXOAssetA.ID, 1e8, address, false) if err == nil { t.Fatalf("Expected error for a non-synchronized wallet") } diff --git a/client/mm/config.go b/client/mm/config.go index 43a1dc1698..92c19e7ed4 100644 --- a/client/mm/config.go +++ b/client/mm/config.go @@ -74,3 +74,15 @@ func (c *BotConfig) requiresCEX() bool { func dexMarketID(host string, base, quote uint32) string { return fmt.Sprintf("%s-%d-%d", host, base, quote) } + +// AutoRebalanceConfig determines how the bot will automatically rebalance its +// assets between the CEX and DEX. If the base or quote asset dips below the +// minimum amount, a transfer will take place, but only if both balances can be +// brought above the minimum amount and the transfer amount would be above the +// minimum transfer amount. +type AutoRebalanceConfig struct { + MinBaseAmt uint64 `json:"minBaseAmt"` + MinBaseTransfer uint64 `json:"minBaseTransfer"` + MinQuoteAmt uint64 `json:"minQuoteAmt"` + MinQuoteTransfer uint64 `json:"minQuoteTransfer"` +} diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index 75adb8101b..14da3656c9 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -263,12 +263,6 @@ func (bnc *binance) getCoinInfo(ctx context.Context) error { tokenIDs := make(map[string][]uint32) for _, nfo := range coins { - if nfo.Coin == "WBTC" { - bnc.log.Infof("WBTC INFO: %+v", nfo) - for _, netInfo := range nfo.NetworkList { - bnc.log.Infof("%+v", netInfo) - } - } tokenSymbol := strings.ToLower(nfo.Coin) chainIDs, isToken := dex.TokenChains[tokenSymbol] if !isToken { @@ -649,7 +643,7 @@ func (bnc *binance) ConfirmDeposit(ctx context.Context, txID string, onConfirm f dexSymbol := binanceCoinNetworkToDexSymbol(status.Coin, status.Network) assetID, found := dex.BipSymbolID(dexSymbol) if !found { - bnc.log.Errorf("Failed to find DEX asset ID for Coin: %s, Network %s", status.Coin, status.Network) + bnc.log.Errorf("Failed to find DEX asset ID for Coin: %s, Network: %s", status.Coin, status.Network) return false, true, 0 } ui, err := asset.UnitInfo(assetID) diff --git a/client/mm/libxc/binance_live_test.go b/client/mm/libxc/binance_live_test.go index e5266a43ba..a12f2e59ce 100644 --- a/client/mm/libxc/binance_live_test.go +++ b/client/mm/libxc/binance_live_test.go @@ -4,6 +4,7 @@ package libxc import ( "context" + "encoding/json" "fmt" "os" "os/user" @@ -13,6 +14,19 @@ import ( "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" + + _ "decred.org/dcrdex/client/asset/bch" // register bch asset + _ "decred.org/dcrdex/client/asset/btc" // register btc asset + _ "decred.org/dcrdex/client/asset/dash" // register dash asset + _ "decred.org/dcrdex/client/asset/dcr" // register dcr asset + _ "decred.org/dcrdex/client/asset/dgb" // register dgb asset + _ "decred.org/dcrdex/client/asset/doge" // register doge asset + _ "decred.org/dcrdex/client/asset/firo" // register firo asset + _ "decred.org/dcrdex/client/asset/ltc" // register ltc asset + _ "decred.org/dcrdex/client/asset/zcl" // register zcl asset + _ "decred.org/dcrdex/client/asset/zec" // register zec asse + _ "decred.org/dcrdex/server/asset/eth" // register eth asset + _ "decred.org/dcrdex/server/asset/polygon" // register polygon asset ) var ( @@ -59,31 +73,6 @@ func (drv *spoofDriver) Info() *asset.WalletInfo { } } -func init() { - asset.Register(42, &spoofDriver{cFactor: 1e9}) // dcr - asset.Register(60, &spoofDriver{cFactor: 1e9}) // eth - asset.Register(966, &spoofDriver{cFactor: 1e9}) // matic - asset.Register(0, &spoofDriver{cFactor: 1e8}) // btc - asset.RegisterToken(60001, &dex.Token{ - ParentID: 60, - Name: "USDC", - UnitInfo: dex.UnitInfo{ - Conventional: dex.Denomination{ - ConversionFactor: 1e6, - }, - }, - }, &asset.WalletDefinition{}, dex.Mainnet, dex.Testnet, dex.Simnet) - asset.RegisterToken(966002, &dex.Token{ - ParentID: 966, - Name: "WETH", - UnitInfo: dex.UnitInfo{ - Conventional: dex.Denomination{ - ConversionFactor: 1e9, - }, - }, - }, &asset.WalletDefinition{}, dex.Mainnet, dex.Testnet, dex.Simnet) -} - func TestConnect(t *testing.T) { bnc := tNewBinance(t, dex.Simnet) ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) @@ -326,3 +315,31 @@ func TestBalances(t *testing.T) { t.Logf("%+v", balance) } + +func TestGetCoinInfo(t *testing.T) { + bnc := tNewBinance(t, dex.Mainnet) + ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) + defer cancel() + + coins := make([]*binanceCoinInfo, 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) + } + + for _, c := range coins { + if c.Coin == "USDC" { + b, _ := json.MarshalIndent(c, "", " ") + fmt.Println(string(b)) + } + networks := make([]string, 0) + for _, n := range c.NetworkList { + if !n.DepositEnable || !n.WithdrawEnable { + fmt.Printf("%s on network %s not withdrawing and/or depositing. withdraw = %t, deposit = %t\n", + c.Coin, n.Network, n.WithdrawEnable, n.DepositEnable) + } + networks = append(networks, n.Network) + } + fmt.Printf("%q networks: %+v \n", c.Coin, networks) + } +} diff --git a/client/mm/libxc/interface.go b/client/mm/libxc/interface.go index fe05063800..0faebfa215 100644 --- a/client/mm/libxc/interface.go +++ b/client/mm/libxc/interface.go @@ -38,7 +38,7 @@ type CEX interface { // Balance returns the balance of an asset at the CEX. Balance(assetID uint32) (*ExchangeBalance, error) // CancelTrade cancels a trade on the CEX. - CancelTrade(ctx context.Context, base, quote uint32, tradeID string) error + CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error // Markets returns the list of markets at the CEX. Markets() ([]*Market, error) // SubscribeCEXUpdates returns a channel which sends an empty struct when @@ -46,7 +46,7 @@ type CEX interface { SubscribeCEXUpdates() (updates <-chan interface{}, unsubscribe func()) // SubscribeMarket subscribes to order book updates on a market. This must // be called before calling VWAP. - SubscribeMarket(ctx context.Context, base, quote uint32) error + SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error // SubscribeTradeUpdates returns a channel that the caller can use to // listen for updates to a trade's status. When the subscription ID // returned from this function is passed as the updaterID argument to @@ -55,12 +55,12 @@ type CEX interface { SubscribeTradeUpdates() (updates <-chan *TradeUpdate, unsubscribe func(), subscriptionID int) // Trade executes a trade on the CEX. updaterID takes a subscriptionID // returned from SubscribeTradeUpdates. - Trade(ctx context.Context, base, quote uint32, sell bool, rate, qty uint64, subscriptionID int) (string, error) + Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64, subscriptionID int) (string, error) // UnsubscribeMarket unsubscribes from order book updates on a market. - UnsubscribeMarket(base, quote uint32) error - // VWAP returns the volume weighted average price for a certain quantity + UnsubscribeMarket(baseID, quoteID uint32) error + // VWAP returns the volume weighted average price for a certainWithdraw(address string, value, feeRate uint64) quantity // of the base asset on a market. - VWAP(base, quote uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) + VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) // GetDepositAddress returns a deposit address for an asset. GetDepositAddress(ctx context.Context, assetID uint32) (string, error) // ConfirmDeposit is an async function that calls onConfirm when the status diff --git a/client/mm/mm.go b/client/mm/mm.go index 0422ca3f28..02d5dceac2 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -43,7 +43,7 @@ type clientCore interface { OpenWallet(assetID uint32, appPW []byte) error Broadcast(core.Notification) FiatConversionRates() map[uint32]float64 - Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) + Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) NewDepositAddress(assetID uint32) (string, error) TransactionConfirmations(assetID uint32, txid string) (uint32, error) } @@ -501,8 +501,14 @@ func (m *MarketMaker) setupBalances(cfgs []*BotConfig, cexes map[string]libxc.CE } quoteAssetSymbol := dex.TokenSymbol(quoteSymbol) - trackAssetOnCEX(baseAssetSymbol, cfg.BaseAsset, cfg.CEXCfg.Name) - trackAssetOnCEX(quoteAssetSymbol, cfg.QuoteAsset, cfg.CEXCfg.Name) + err = trackAssetOnCEX(baseAssetSymbol, cfg.BaseAsset, cfg.CEXCfg.Name) + if err != nil { + return err + } + err = trackAssetOnCEX(quoteAssetSymbol, cfg.QuoteAsset, cfg.CEXCfg.Name) + if err != nil { + return err + } baseCEXBalance := cexBalanceTracker[cfg.CEXCfg.Name][baseAssetSymbol] quoteCEXBalance := cexBalanceTracker[cfg.CEXCfg.Name][quoteAssetSymbol] cexBaseRequired := calcBalance(cfg.CEXCfg.BaseBalanceType, cfg.CEXCfg.BaseBalance, baseCEXBalance.available) @@ -1140,7 +1146,7 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s defer func() { m.markBotAsRunning(mkt, false) }() - RunArbMarketMaker(m.ctx, cfg, m.core, cex, logger) + RunArbMarketMaker(m.ctx, cfg, m.core, m.wrappedCEXForBot(mktID, cex), logger) }(cfg) default: m.log.Errorf("No bot config provided. Skipping %s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index d771ba1c4d..a6cab2a0b9 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -6,6 +6,7 @@ package mm import ( "context" "fmt" + "sort" "sync" "sync/atomic" @@ -79,25 +80,71 @@ type ArbMarketMakerConfig struct { NumEpochsLeaveOpen uint64 `json:"numEpochsLeaveOpen"` BaseOptions map[string]string `json:"baseOptions"` QuoteOptions map[string]string `json:"quoteOptions"` + // AutoRebalance determines how the bot will handle rebalancing of the + // assets between the dex and the cex. If nil, no rebalancing will take + // place. + AutoRebalance *AutoRebalanceConfig `json:"autoRebalance"` +} + +// autoRebalanceReserves keeps track of the amount of the balances that are +// reserved for an upcoming rebalance. These will be deducted from the +// available balance when placing new orders. +type autoRebalanceReserves struct { + baseDexReserves uint64 + baseCexReserves uint64 + quoteDexReserves uint64 + quoteCexReserves uint64 +} + +func (r *autoRebalanceReserves) get(base, cex bool) uint64 { + if base { + if cex { + return r.baseCexReserves + } + return r.baseDexReserves + } + if cex { + return r.quoteCexReserves + } + return r.quoteDexReserves +} + +func (r *autoRebalanceReserves) set(base, cex bool, amt uint64) { + if base { + if cex { + r.baseCexReserves = amt + } else { + r.baseDexReserves = amt + } + } else { + if cex { + r.quoteCexReserves = amt + } else { + r.quoteDexReserves = amt + } + } +} + +func (r *autoRebalanceReserves) zero() { + r.baseDexReserves = 0 + r.baseCexReserves = 0 + r.quoteDexReserves = 0 + r.quoteCexReserves = 0 } type arbMarketMaker struct { - ctx context.Context - host string - base uint32 - quote uint32 - cex libxc.CEX - // cexTradeUpdatesID is passed to the Trade function of the cex - // so that the cex knows to send update notifications for the - // trade back to this bot. - cexTradeUpdatesID int - core clientCore - log dex.Logger - cfg *ArbMarketMakerConfig - mkt *core.Market - book dexOrderBook - rebalanceRunning atomic.Bool - currEpoch atomic.Uint64 + ctx context.Context + host string + baseID uint32 + quoteID uint32 + cex cex + core clientCore + log dex.Logger + cfg *ArbMarketMakerConfig + mkt *core.Market + book dexOrderBook + rebalanceRunning atomic.Bool + currEpoch atomic.Uint64 ordMtx sync.RWMutex ords map[order.OrderID]*core.Order @@ -112,6 +159,11 @@ type arbMarketMaker struct { feesMtx sync.RWMutex buyFees *orderFees sellFees *orderFees + + reserves autoRebalanceReserves + + pendingBaseRebalance atomic.Bool + pendingQuoteRebalance atomic.Bool } // groupedOrders returns the buy and sell orders grouped by placement index. @@ -157,7 +209,7 @@ func (a *arbMarketMaker) processDEXMatch(o *core.Order, match *core.Match) { a.cexTradesMtx.Lock() defer a.cexTradesMtx.Unlock() - tradeID, err := a.cex.Trade(a.ctx, a.base, a.quote, !o.Sell, cexRate, match.Qty, a.cexTradeUpdatesID) + tradeID, err := a.cex.Trade(a.ctx, a.baseID, a.quoteID, !o.Sell, cexRate, match.Qty) if err != nil { a.log.Errorf("Error sending trade to CEX: %v", err) return @@ -217,7 +269,7 @@ func (a *arbMarketMaker) processDEXOrderNote(note *core.OrderNote) { } func (a *arbMarketMaker) vwap(sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { - return a.cex.VWAP(a.base, a.quote, sell, qty) + return a.cex.VWAP(a.baseID, a.quoteID, sell, qty) } type arbMMRebalancer interface { @@ -244,8 +296,8 @@ func (a *arbMarketMaker) placeMultiTrade(placements []*rateLots, sell bool) { orders, err := a.core.MultiTrade(nil, &core.MultiTradeForm{ Host: a.host, Sell: sell, - Base: a.base, - Quote: a.quote, + Base: a.baseID, + Quote: a.quoteID, Placements: qtyRates, Options: options, }) @@ -264,7 +316,9 @@ func (a *arbMarketMaker) placeMultiTrade(placements []*rateLots, sell bool) { a.ordMtx.Unlock() } -func (a *arbMarketMaker) cancelCEXTrades() { +// cancelExpiredCEXTrades cancels any trades on the CEX that have been open for +// more than the number of epochs specified in the config. +func (a *arbMarketMaker) cancelExpiredCEXTrades() { currEpoch := a.currEpoch.Load() a.cexTradesMtx.RLock() @@ -272,7 +326,7 @@ func (a *arbMarketMaker) cancelCEXTrades() { for tradeID, epoch := range a.cexTrades { if currEpoch-epoch >= a.cfg.NumEpochsLeaveOpen { - err := a.cex.CancelTrade(a.ctx, a.base, a.quote, tradeID) + err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, tradeID) if err != nil { a.log.Errorf("Error canceling CEX trade %s: %v", tradeID, err) } @@ -280,8 +334,8 @@ func (a *arbMarketMaker) cancelCEXTrades() { } } -func arbMarketMakerRebalance(newEpoch uint64, a arbMMRebalancer, c clientCore, cex libxc.CEX, cfg *ArbMarketMakerConfig, mkt *core.Market, buyFees, - sellFees *orderFees, log dex.Logger) (cancels []dex.Bytes, buyOrders, sellOrders []*rateLots) { +func arbMarketMakerRebalance(newEpoch uint64, a arbMMRebalancer, c clientCore, cex cex, cfg *ArbMarketMakerConfig, mkt *core.Market, buyFees, + sellFees *orderFees, reserves *autoRebalanceReserves, log dex.Logger) (cancels []dex.Bytes, buyOrders, sellOrders []*rateLots) { existingBuys, existingSells := a.groupedOrders() @@ -343,6 +397,20 @@ func arbMarketMakerRebalance(newEpoch uint64, a arbMMRebalancer, c clientCore, c fundingFees = buyFees.funding } + cexReserves := reserves.get(!sell, true) + if cexReserves > remainingCEXBalance { + log.Debugf("rebalance: not enough CEX balance to cover reserves") + return nil + } + remainingCEXBalance -= cexReserves + + dexReserves := reserves.get(sell, false) + if dexReserves > remainingDEXBalance { + log.Debugf("rebalance: not enough DEX balance to cover reserves") + return nil + } + remainingDEXBalance -= dexReserves + // Enough balance on the CEX needs to be maintained for counter-trades // for each existing trade on the DEX. Here, we reduce the available // balance on the CEX by the amount required for each order on the @@ -454,6 +522,248 @@ func arbMarketMakerRebalance(newEpoch uint64, a arbMMRebalancer, c clientCore, c return cancels, buys, sells } +// fundsLockedInOrders returns the total amount of the asset that is +// currently locked in a booked order on the DEX. +func (a *arbMarketMaker) fundsLockedInOrders(base bool) uint64 { + buys, sells := a.groupedOrders() + var locked uint64 + + var orders map[int][]*groupedOrder + if base { + orders = sells + } else { + orders = buys + } + + for _, ordersForPlacement := range orders { + for _, o := range ordersForPlacement { + locked += o.lockedAmt + } + } + + return locked +} + +// dexToCexQty returns the amount of backing asset on the CEX that is required +// for a DEX order of the specified quantity and rate. dexSell indicates that +// we are selling on the DEX, and therefore buying on the CEX. +func (a *arbMarketMaker) dexToCexQty(qty, rate uint64, dexSell bool) uint64 { + if dexSell { + cexRate := uint64(float64(rate) * (1 + a.cfg.Profit)) + return calc.BaseToQuote(cexRate, qty) + } + return qty +} + +// cexBalanceBackingDexOrders returns the amount of the asset on the CEX that +// is required so that if all the orders on the DEX were filled, counter +// trades could be made on the CEX. +func (a *arbMarketMaker) cexBalanceBackingDexOrders(base bool) uint64 { + buys, sells := a.groupedOrders() + var orders map[int][]*groupedOrder + if base { + orders = buys + } else { + orders = sells + } + + var locked uint64 + for _, ordersForPlacement := range orders { + for _, o := range ordersForPlacement { + locked += a.dexToCexQty(o.lots*a.mkt.LotSize, o.rate, !base) + } + } + + return locked +} + +// freeUpFunds cancels active orders to free up the specified amount of funds +// for a rebalance between the dex and the cex. The orders are cancelled in +// reverse order of priority. +func (a *arbMarketMaker) freeUpFunds(base, cex bool, amt uint64) { + buys, sells := a.groupedOrders() + var orders map[int][]*groupedOrder + if base && !cex || !base && cex { + orders = sells + } else { + orders = buys + } + + highToLowIndexes := make([]int, 0, len(orders)) + for i := range orders { + highToLowIndexes = append(highToLowIndexes, i) + } + sort.Slice(highToLowIndexes, func(i, j int) bool { + return highToLowIndexes[i] > highToLowIndexes[j] + }) + + currEpoch := a.currEpoch.Load() + + for _, index := range highToLowIndexes { + ordersForPlacement := orders[index] + for _, o := range ordersForPlacement { + // If the order is too recent, just wait for the next epoch to + // cancel. We still count this order towards the freedAmt in + // order to not cancel a higher priority trade. + if currEpoch-o.epoch >= 2 { + err := a.core.Cancel(o.id[:]) + if err != nil { + a.log.Errorf("error cancelling order: %v", err) + continue + } + } + var freedAmt uint64 + if cex { + freedAmt = a.dexToCexQty(o.lots*a.mkt.LotSize, o.rate, !base) + } else { + freedAmt = o.lockedAmt + } + if freedAmt >= amt { + return + } + amt -= freedAmt + } + } +} + +// rebalanceAssets checks if funds on either the CEX or the DEX are below the +// minimum amount, and if so, initiates either withdrawal or deposit to bring +// them to equal. If some funds that need to be transferred are either locked +// in an order on the DEX, or backing a potential order on the CEX, some orders +// are cancelled to free up funds, and the transfer happens in the next epoch. +func (a *arbMarketMaker) rebalanceAssets() { + rebalanceAsset := func(base bool) { + var assetID uint32 + var minAmount uint64 + var minTransferAmount uint64 + if base { + assetID = a.baseID + minAmount = a.cfg.AutoRebalance.MinBaseAmt + minTransferAmount = a.cfg.AutoRebalance.MinBaseTransfer + } else { + assetID = a.quoteID + minAmount = a.cfg.AutoRebalance.MinQuoteAmt + minTransferAmount = a.cfg.AutoRebalance.MinQuoteTransfer + } + symbol := dex.BipIDSymbol(assetID) + + dexAvailableBalance, err := a.core.AssetBalance(assetID) + if err != nil { + a.log.Errorf("Error getting %s balance: %v", symbol, err) + return + } + + totalDexBalance := dexAvailableBalance.Available + a.fundsLockedInOrders(base) + + cexBalance, err := a.cex.Balance(assetID) + if err != nil { + a.log.Errorf("Error getting %s balance on cex: %v", symbol, err) + return + } + + if (totalDexBalance+cexBalance.Available)/2 < minAmount { + a.log.Warnf("Cannot rebalance %s because balance is too low on both DEX and CEX", symbol) + return + } + + var requireDeposit bool + if cexBalance.Available < minAmount { + requireDeposit = true + } else if totalDexBalance >= minAmount { + // No need for withdrawal or deposit. + return + } + + onConfirm := func() { + if base { + a.pendingBaseRebalance.Store(false) + } else { + a.pendingQuoteRebalance.Store(false) + } + } + + if requireDeposit { + amt := (totalDexBalance+cexBalance.Available)/2 - cexBalance.Available + if amt < minTransferAmount { + a.log.Warnf("Amount required to rebalance %s (%d) is less than the min transfer amount %v", + symbol, amt, minTransferAmount) + return + } + + // If we need to cancel some orders to send the required amount to + // the CEX, cancel some orders, and then try again on the next + // epoch. + if amt > dexAvailableBalance.Available { + a.reserves.set(base, false, amt) + a.freeUpFunds(base, false, amt-dexAvailableBalance.Available) + return + } + + err = a.cex.Deposit(a.ctx, assetID, amt, onConfirm) + if err != nil { + a.log.Errorf("Error depositing %d to cex: %v", assetID, err) + return + } + } else { + amt := (totalDexBalance+cexBalance.Available)/2 - totalDexBalance + if amt < minTransferAmount { + a.log.Warnf("Amount required to rebalance %s (%d) is less than the min transfer amount %v", + symbol, amt, minTransferAmount) + return + } + + cexBalanceBackingDexOrders := a.cexBalanceBackingDexOrders(base) + if cexBalance.Available < cexBalanceBackingDexOrders { + a.log.Errorf("cex reported balance %d is less than amount required to back dex orders %d", + cexBalance.Available, cexBalanceBackingDexOrders) + // this is a bug, how to recover? + return + } + + if amt > cexBalance.Available-cexBalanceBackingDexOrders { + a.reserves.set(base, true, amt) + a.freeUpFunds(base, true, amt-(cexBalance.Available-cexBalanceBackingDexOrders)) + return + } + + err = a.cex.Withdraw(a.ctx, assetID, amt, onConfirm) + if err != nil { + a.log.Errorf("Error withdrawing %d from cex: %v", assetID, err) + return + } + } + + if base { + a.pendingBaseRebalance.Store(true) + } else { + a.pendingQuoteRebalance.Store(true) + } + } + + if a.cfg.AutoRebalance == nil { + return + } + + a.cexTradesMtx.Lock() + if len(a.cexTrades) > 0 { + a.cexTradesMtx.Unlock() + return + } + a.cexTradesMtx.Unlock() + + a.reserves.zero() + + if !a.pendingBaseRebalance.Load() { + rebalanceAsset(true) + } + if !a.pendingQuoteRebalance.Load() { + rebalanceAsset(false) + } +} + +// rebalance is called on each new epoch. It determines what orders need to be +// placed, cancelled, and what funds need to be transferred between the DEX and +// the CEX. func (a *arbMarketMaker) rebalance(epoch uint64) { if !a.rebalanceRunning.CompareAndSwap(false, true) { return @@ -466,7 +776,8 @@ func (a *arbMarketMaker) rebalance(epoch uint64) { } a.currEpoch.Store(epoch) - cancels, buyOrders, sellOrders := arbMarketMakerRebalance(epoch, a, a.core, a.cex, a.cfg, a.mkt, a.buyFees, a.sellFees, a.log) + cancels, buyOrders, sellOrders := arbMarketMakerRebalance(epoch, a, a.core, + a.cex, a.cfg, a.mkt, a.buyFees, a.sellFees, &a.reserves, a.log) for _, cancel := range cancels { err := a.core.Cancel(cancel) @@ -482,7 +793,8 @@ func (a *arbMarketMaker) rebalance(epoch uint64) { a.placeMultiTrade(sellOrders, true) } - a.cancelCEXTrades() + a.cancelExpiredCEXTrades() + a.rebalanceAssets() } func (a *arbMarketMaker) handleNotification(note core.Notification) { @@ -511,8 +823,8 @@ func (a *arbMarketMaker) cancelAllOrders() { func (a *arbMarketMaker) updateFeeRates() error { buySwapFees, buyRedeemFees, buyRefundFees, err := a.core.SingleLotFees(&core.SingleLotFeesForm{ Host: a.host, - Base: a.base, - Quote: a.quote, + Base: a.baseID, + Quote: a.quoteID, UseMaxFeeRate: true, UseSafeTxSize: true, }) @@ -522,8 +834,8 @@ func (a *arbMarketMaker) updateFeeRates() error { sellSwapFees, sellRedeemFees, sellRefundFees, err := a.core.SingleLotFees(&core.SingleLotFeesForm{ Host: a.host, - Base: a.base, - Quote: a.quote, + Base: a.baseID, + Quote: a.quoteID, UseMaxFeeRate: true, UseSafeTxSize: true, Sell: true, @@ -532,12 +844,12 @@ func (a *arbMarketMaker) updateFeeRates() error { return fmt.Errorf("failed to get fees: %v", err) } - buyFundingFees, err := a.core.MaxFundingFees(a.quote, a.host, uint32(len(a.cfg.BuyPlacements)), a.cfg.QuoteOptions) + buyFundingFees, err := a.core.MaxFundingFees(a.quoteID, a.host, uint32(len(a.cfg.BuyPlacements)), a.cfg.QuoteOptions) if err != nil { return fmt.Errorf("failed to get funding fees: %v", err) } - sellFundingFees, err := a.core.MaxFundingFees(a.base, a.host, uint32(len(a.cfg.SellPlacements)), a.cfg.BaseOptions) + sellFundingFees, err := a.core.MaxFundingFees(a.baseID, a.host, uint32(len(a.cfg.SellPlacements)), a.cfg.BaseOptions) if err != nil { return fmt.Errorf("failed to get funding fees: %v", err) } @@ -562,7 +874,7 @@ func (a *arbMarketMaker) updateFeeRates() error { } func (a *arbMarketMaker) run() { - book, bookFeed, err := a.core.SyncBook(a.host, a.base, a.quote) + book, bookFeed, err := a.core.SyncBook(a.host, a.baseID, a.quoteID) if err != nil { a.log.Errorf("Failed to sync book: %v", err) return @@ -575,15 +887,14 @@ func (a *arbMarketMaker) run() { return } - err = a.cex.SubscribeMarket(a.ctx, a.base, a.quote) + err = a.cex.SubscribeMarket(a.ctx, a.baseID, a.quoteID) if err != nil { a.log.Errorf("Failed to subscribe to cex market: %v", err) return } - tradeUpdates, unsubscribe, tradeUpdatesID := a.cex.SubscribeTradeUpdates() + tradeUpdates, unsubscribe := a.cex.SubscribeTradeUpdates() defer unsubscribe() - a.cexTradeUpdatesID = tradeUpdatesID wg := &sync.WaitGroup{} @@ -633,7 +944,7 @@ func (a *arbMarketMaker) run() { a.cancelAllOrders() } -func RunArbMarketMaker(ctx context.Context, cfg *BotConfig, c clientCore, cex libxc.CEX, log dex.Logger) { +func RunArbMarketMaker(ctx context.Context, cfg *BotConfig, c clientCore, cex cex, log dex.Logger) { if cfg.ArbMarketMakerConfig == nil { // implies bug in caller log.Errorf("No arb market maker config provided. Exiting.") @@ -649,8 +960,8 @@ func RunArbMarketMaker(ctx context.Context, cfg *BotConfig, c clientCore, cex li (&arbMarketMaker{ ctx: ctx, host: cfg.Host, - base: cfg.BaseAsset, - quote: cfg.QuoteAsset, + baseID: cfg.BaseAsset, + quoteID: cfg.QuoteAsset, cex: cex, core: c, log: log, diff --git a/client/mm/mm_arb_market_maker_test.go b/client/mm/mm_arb_market_maker_test.go index a6e451cf3c..06e6157740 100644 --- a/client/mm/mm_arb_market_maker_test.go +++ b/client/mm/mm_arb_market_maker_test.go @@ -5,6 +5,7 @@ package mm import ( "context" + "encoding/hex" "testing" "decred.org/dcrdex/client/core" @@ -119,6 +120,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { cfg *ArbMarketMakerConfig dexBalances map[uint32]uint64 cexBalances map[uint32]*libxc.ExchangeBalance + reserves autoRebalanceReserves expectedCancels []dex.Bytes expectedBuys []*rateLots @@ -617,15 +619,246 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: {Available: 2*mkt.LotSize - 1}, }, }, + // "no existing orders, two orders each, dex balance edge with reserves, enough" + { + name: "no existing orders, two orders each, dex balance edge with reserves, enough", + rebalancer: &tArbMMRebalancer{ + buyVWAP: map[uint64]*vwapResult{ + lotSizeMultiplier(2): { + avg: 2e6, + extrema: 1.9e6, + }, + lotSizeMultiplier(3.5): { + avg: 1.8e6, + extrema: 1.7e6, + }, + }, + sellVWAP: map[uint64]*vwapResult{ + lotSizeMultiplier(2): { + avg: 2.1e6, + extrema: 2.2e6, + }, + lotSizeMultiplier(3.5): { + avg: 2.3e6, + extrema: 2.4e6, + }, + }, + }, + cfg: cfg2, + reserves: autoRebalanceReserves{ + baseDexReserves: 2 * lotSize, + }, + dexBalances: map[uint32]uint64{ + 42: 2*(lotSize+sellFees.swap) + sellFees.funding + 2*lotSize, + 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, + }, + expectedBuys: []*rateLots{ + { + rate: divideRate(1.9e6, 1+profit), + lots: 1, + }, + { + rate: divideRate(1.7e6, 1+profit), + lots: 1, + placementIndex: 1, + }, + }, + expectedSells: []*rateLots{ + { + rate: multiplyRate(2.2e6, 1+profit), + lots: 1, + }, + { + rate: multiplyRate(2.4e6, 1+profit), + lots: 1, + placementIndex: 1, + }, + }, + }, + // "no existing orders, two orders each, dex balance edge with reserves, not enough" + { + name: "no existing orders, two orders each, dex balance edge with reserves, not enough", + rebalancer: &tArbMMRebalancer{ + buyVWAP: map[uint64]*vwapResult{ + lotSizeMultiplier(2): { + avg: 2e6, + extrema: 1.9e6, + }, + lotSizeMultiplier(3.5): { + avg: 1.8e6, + extrema: 1.7e6, + }, + }, + sellVWAP: map[uint64]*vwapResult{ + lotSizeMultiplier(2): { + avg: 2.1e6, + extrema: 2.2e6, + }, + lotSizeMultiplier(3.5): { + avg: 2.3e6, + extrema: 2.4e6, + }, + }, + }, + cfg: cfg2, + reserves: autoRebalanceReserves{ + baseDexReserves: 2 * lotSize, + }, + dexBalances: map[uint32]uint64{ + 42: 2*(lotSize+sellFees.swap) + sellFees.funding + 2*lotSize - 1, + 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: 1e19}, + 42: {Available: 1e19}, + }, + expectedBuys: []*rateLots{ + { + rate: divideRate(1.9e6, 1+profit), + lots: 1, + }, + { + rate: divideRate(1.7e6, 1+profit), + lots: 1, + placementIndex: 1, + }, + }, + expectedSells: []*rateLots{ + { + rate: multiplyRate(2.2e6, 1+profit), + lots: 1, + }, + }, + }, + // "no existing orders, two orders each, cex balance edge with reserves, enough" + { + name: "no existing orders, two orders each, cex balance edge with reserves, enough", + rebalancer: &tArbMMRebalancer{ + buyVWAP: map[uint64]*vwapResult{ + lotSizeMultiplier(2): { + avg: 2e6, + extrema: 1.9e6, + }, + lotSizeMultiplier(3.5): { + avg: 1.8e6, + extrema: 1.7e6, + }, + }, + sellVWAP: map[uint64]*vwapResult{ + lotSizeMultiplier(2): { + avg: 2.1e6, + extrema: 2.2e6, + }, + lotSizeMultiplier(3.5): { + avg: 2.3e6, + extrema: 2.4e6, + }, + }, + }, + cfg: cfg2, + reserves: autoRebalanceReserves{ + quoteCexReserves: lotSize, + }, + dexBalances: map[uint32]uint64{ + 42: 1e19, + 0: 1e19, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) + lotSize}, + 42: {Available: 2 * mkt.LotSize}, + }, + expectedBuys: []*rateLots{ + { + rate: divideRate(1.9e6, 1+profit), + lots: 1, + }, + { + rate: divideRate(1.7e6, 1+profit), + lots: 1, + placementIndex: 1, + }, + }, + expectedSells: []*rateLots{ + { + rate: multiplyRate(2.2e6, 1+profit), + lots: 1, + }, + { + rate: multiplyRate(2.4e6, 1+profit), + lots: 1, + placementIndex: 1, + }, + }, + }, + // "no existing orders, two orders each, cex balance edge with reserves, enough" + { + name: "no existing orders, two orders each, cex balance edge with reserves, not enough", + rebalancer: &tArbMMRebalancer{ + buyVWAP: map[uint64]*vwapResult{ + lotSizeMultiplier(2): { + avg: 2e6, + extrema: 1.9e6, + }, + lotSizeMultiplier(3.5): { + avg: 1.8e6, + extrema: 1.7e6, + }, + }, + sellVWAP: map[uint64]*vwapResult{ + lotSizeMultiplier(2): { + avg: 2.1e6, + extrema: 2.2e6, + }, + lotSizeMultiplier(3.5): { + avg: 2.3e6, + extrema: 2.4e6, + }, + }, + }, + cfg: cfg2, + reserves: autoRebalanceReserves{ + baseCexReserves: lotSize, + }, + dexBalances: map[uint32]uint64{ + 42: 1e19, + 0: 1e19, + }, + cexBalances: map[uint32]*libxc.ExchangeBalance{ + 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) + lotSize - 1}, + 42: {Available: 2 * mkt.LotSize}, + }, + expectedBuys: []*rateLots{ + { + rate: divideRate(1.9e6, 1+profit), + lots: 1, + }, + }, + expectedSells: []*rateLots{ + { + rate: multiplyRate(2.2e6, 1+profit), + lots: 1, + }, + { + rate: multiplyRate(2.4e6, 1+profit), + lots: 1, + placementIndex: 1, + }, + }, + }, } for _, test := range tests { tCore := newTCore() tCore.setAssetBalances(test.dexBalances) - cex := newTCEX() + cex := newTWrappedCEX() cex.balances = test.cexBalances - cancels, buys, sells := arbMarketMakerRebalance(newEpoch, test.rebalancer, tCore, cex, test.cfg, mkt, buyFees, sellFees, tLogger) + cancels, buys, sells := arbMarketMakerRebalance(newEpoch, test.rebalancer, + tCore, cex, test.cfg, mkt, buyFees, sellFees, &test.reserves, tLogger) if len(cancels) != len(test.expectedCancels) { t.Fatalf("%s: expected %d cancels, got %d", test.name, len(test.expectedCancels), len(cancels)) @@ -876,7 +1109,7 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { } runTest := func(test *test) { - cex := newTCEX() + cex := newTWrappedCEX() tCore := newTCore() ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -894,8 +1127,8 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { core: tCore, ctx: ctx, ords: ords, - base: 42, - quote: 0, + baseID: 42, + quoteID: 0, oidToPlacement: make(map[order.OrderID]int), matchesSeen: make(map[order.MatchID]bool), cexTrades: make(map[string]uint64), @@ -931,3 +1164,542 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { runTest(test) } } + +func TestArbMarketMakerAutoRebalance(t *testing.T) { + orderIDs := make([]order.OrderID, 5) + for i := 0; i < 5; i++ { + copy(orderIDs[i][:], encode.RandomBytes(32)) + } + + matchIDs := make([]order.MatchID, 5) + for i := 0; i < 5; i++ { + copy(matchIDs[i][:], encode.RandomBytes(32)) + } + + mkt := &core.Market{ + LotSize: 4e8, + } + + baseID, quoteID := uint32(42), uint32(0) + + profitRate := float64(0.01) + + type test struct { + name string + cfg *AutoRebalanceConfig + orders map[order.OrderID]*core.Order + oidToPlacement map[order.OrderID]int + cexBaseBalance uint64 + cexQuoteBalance uint64 + dexBaseBalance uint64 + dexQuoteBalance uint64 + activeCEXOrders bool + + expectedDeposit *withdrawArgs + expectedWithdraw *withdrawArgs + expectedCancels []dex.Bytes + expectedReserves autoRebalanceReserves + expectedBasePending bool + expectedQuotePending bool + } + + currEpoch := uint64(123) + + tests := []*test{ + // "no orders, no need to rebalance" + { + name: "no orders, no need to rebalance", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 1e16, + MinQuoteAmt: 1e12, + }, + orders: map[order.OrderID]*core.Order{}, + oidToPlacement: map[order.OrderID]int{}, + cexBaseBalance: 1e16, + cexQuoteBalance: 1e12, + dexBaseBalance: 1e16, + dexQuoteBalance: 1e12, + }, + // "no action with active cex orders" + { + name: "no action with active cex orders", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 6 * mkt.LotSize, + MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), + }, + orders: map[order.OrderID]*core.Order{ + orderIDs[0]: { + ID: orderIDs[0][:], + Qty: 4 * mkt.LotSize, + Rate: 5e7, + Sell: true, + LockedAmt: 4 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + orderIDs[1]: { + ID: orderIDs[1][:], + Qty: 4 * mkt.LotSize, + Rate: 6e7, + Sell: true, + LockedAmt: 4 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + }, + oidToPlacement: map[order.OrderID]int{ + orderIDs[0]: 0, + }, + cexBaseBalance: 3 * mkt.LotSize, + dexBaseBalance: 5 * mkt.LotSize, + cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + activeCEXOrders: true, + }, + // "no orders, need to withdraw base" + { + name: "no orders, need to withdraw base", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 1e16, + MinQuoteAmt: 1e12, + }, + orders: map[order.OrderID]*core.Order{}, + oidToPlacement: map[order.OrderID]int{}, + cexBaseBalance: 8e16, + cexQuoteBalance: 1e12, + dexBaseBalance: 9e15, + dexQuoteBalance: 1e12, + expectedWithdraw: &withdrawArgs{ + assetID: 42, + amt: (9e15+8e16)/2 - 9e15, + }, + expectedBasePending: true, + }, + // "need to deposit base, no need to cancel order" + { + name: "need to deposit base, no need to cancel order", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 6 * mkt.LotSize, + MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), + }, + orders: map[order.OrderID]*core.Order{ + orderIDs[0]: { + ID: orderIDs[0][:], + Qty: 4 * mkt.LotSize, + Rate: 5e7, + Sell: true, + LockedAmt: 4 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + orderIDs[1]: { + ID: orderIDs[1][:], + Qty: 4 * mkt.LotSize, + Rate: 6e7, + Sell: true, + LockedAmt: 4 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + }, + oidToPlacement: map[order.OrderID]int{ + orderIDs[0]: 0, + }, + cexBaseBalance: 3 * mkt.LotSize, + dexBaseBalance: 5 * mkt.LotSize, + cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + expectedDeposit: &withdrawArgs{ + assetID: 42, + amt: 5 * mkt.LotSize, + }, + expectedBasePending: true, + }, + // "need to deposit base, need to cancel 1 order" + { + name: "need to deposit base, need to cancel 1 order", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 6 * mkt.LotSize, + MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), + }, + orders: map[order.OrderID]*core.Order{ + orderIDs[0]: { + ID: orderIDs[0][:], + Qty: 4 * mkt.LotSize, + Rate: 5e7, + Sell: true, + LockedAmt: 4 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + orderIDs[1]: { + ID: orderIDs[1][:], + Qty: 4 * mkt.LotSize, + Rate: 6e7, + Sell: true, + LockedAmt: 4 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + }, + oidToPlacement: map[order.OrderID]int{ + orderIDs[0]: 0, + orderIDs[1]: 1, + }, + cexBaseBalance: 3 * mkt.LotSize, + dexBaseBalance: 5*mkt.LotSize - 2, + cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + expectedCancels: []dex.Bytes{ + orderIDs[1][:], + }, + expectedReserves: autoRebalanceReserves{ + baseDexReserves: (16*mkt.LotSize-2)/2 - 3*mkt.LotSize, + }, + }, + // "need to deposit base, need to cancel 2 orders" + { + name: "need to deposit base, need to cancel 2 orders", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 3 * mkt.LotSize, + MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), + }, + orders: map[order.OrderID]*core.Order{ + orderIDs[0]: { + ID: orderIDs[0][:], + Qty: 2 * mkt.LotSize, + Rate: 5e7, + Sell: true, + LockedAmt: 2 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + orderIDs[1]: { + ID: orderIDs[1][:], + Qty: 2 * mkt.LotSize, + Rate: 5e7, + Sell: true, + LockedAmt: 2 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + orderIDs[2]: { + ID: orderIDs[2][:], + Qty: 2 * mkt.LotSize, + Rate: 5e7, + Sell: true, + LockedAmt: 2 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + }, + oidToPlacement: map[order.OrderID]int{ + orderIDs[0]: 0, + orderIDs[1]: 1, + orderIDs[2]: 2, + }, + cexBaseBalance: 0, + dexBaseBalance: 1000, + cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + expectedCancels: []dex.Bytes{ + orderIDs[2][:], + orderIDs[1][:], + }, + expectedReserves: autoRebalanceReserves{ + baseDexReserves: (6*mkt.LotSize + 1000) / 2, + }, + }, + // "need to withdraw base, no need to cancel order" + { + name: "need to withdraw base, no need to cancel order", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 3 * mkt.LotSize, + MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), + }, + orders: map[order.OrderID]*core.Order{}, + oidToPlacement: map[order.OrderID]int{ + orderIDs[0]: 0, + orderIDs[1]: 1, + orderIDs[2]: 2, + }, + cexBaseBalance: 6 * mkt.LotSize, + dexBaseBalance: 0, + cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + expectedWithdraw: &withdrawArgs{ + assetID: baseID, + amt: 3 * mkt.LotSize, + }, + expectedBasePending: true, + }, + // "need to withdraw base, need to cancel 1 order" + { + name: "need to withdraw base, need to cancel 1 order", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 3 * mkt.LotSize, + MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), + }, + orders: map[order.OrderID]*core.Order{ + orderIDs[0]: { + ID: orderIDs[0][:], + Qty: 2 * mkt.LotSize, + Rate: 5e7, + Sell: false, + LockedAmt: 2*mkt.LotSize + 1500, + Epoch: currEpoch - 2, + }, + orderIDs[1]: { + ID: orderIDs[1][:], + Qty: 2 * mkt.LotSize, + Rate: 6e7, + Sell: false, + LockedAmt: 2*mkt.LotSize + 1500, + Epoch: currEpoch - 2, + }, + }, + oidToPlacement: map[order.OrderID]int{ + orderIDs[0]: 0, + orderIDs[1]: 1, + }, + cexBaseBalance: 8*mkt.LotSize - 2, + dexBaseBalance: 0, + cexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + dexQuoteBalance: calc.BaseToQuote(5e7, 6*mkt.LotSize), + expectedCancels: []dex.Bytes{ + orderIDs[1][:], + }, + expectedReserves: autoRebalanceReserves{ + baseCexReserves: 4*mkt.LotSize - 1, + }, + }, + // "need to deposit quote, no need to cancel order" + { + name: "need to deposit quote, no need to cancel order", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 6 * mkt.LotSize, + MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), + }, + orders: map[order.OrderID]*core.Order{ + orderIDs[0]: { + ID: orderIDs[0][:], + Qty: 2 * mkt.LotSize, + Rate: 5e7, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 2*mkt.LotSize), + Epoch: currEpoch - 2, + }, + orderIDs[1]: { + ID: orderIDs[1][:], + Qty: 2 * mkt.LotSize, + Rate: 5e7, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 2*mkt.LotSize), + Epoch: currEpoch - 2, + }, + }, + oidToPlacement: map[order.OrderID]int{ + orderIDs[0]: 0, + }, + cexBaseBalance: 10 * mkt.LotSize, + dexBaseBalance: 10 * mkt.LotSize, + cexQuoteBalance: calc.BaseToQuote(5e7, 4*mkt.LotSize), + dexQuoteBalance: calc.BaseToQuote(5e7, 8*mkt.LotSize), + expectedDeposit: &withdrawArgs{ + assetID: 0, + amt: calc.BaseToQuote(5e7, 4*mkt.LotSize), + }, + expectedQuotePending: true, + }, + // "need to deposit quote, need to cancel 1 order" + { + name: "need to deposit quote, no need to cancel order", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 6 * mkt.LotSize, + MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), + }, + orders: map[order.OrderID]*core.Order{ + orderIDs[0]: { + ID: orderIDs[0][:], + Qty: 4 * mkt.LotSize, + Rate: 5e7, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 4*mkt.LotSize), + Epoch: currEpoch - 2, + }, + orderIDs[1]: { + ID: orderIDs[1][:], + Qty: 4 * mkt.LotSize, + Rate: 5e7, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 4*mkt.LotSize), + Epoch: currEpoch - 2, + }, + }, + oidToPlacement: map[order.OrderID]int{ + orderIDs[0]: 0, + orderIDs[1]: 1, + }, + cexBaseBalance: 10 * mkt.LotSize, + dexBaseBalance: 10 * mkt.LotSize, + cexQuoteBalance: calc.BaseToQuote(5e7, 4*mkt.LotSize), + dexQuoteBalance: calc.BaseToQuote(5e7, 2*mkt.LotSize), + expectedCancels: []dex.Bytes{ + orderIDs[1][:], + }, + expectedReserves: autoRebalanceReserves{ + quoteDexReserves: calc.BaseToQuote(5e7, 3*mkt.LotSize), + }, + }, + // "need to withdraw quote, no need to cancel order" + { + name: "need to withdraw quote, no need to cancel order", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 6 * mkt.LotSize, + MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), + }, + orders: map[order.OrderID]*core.Order{ + orderIDs[0]: { + ID: orderIDs[0][:], + Qty: 3 * mkt.LotSize, + Rate: 5e7, + Sell: true, + LockedAmt: 3 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + orderIDs[1]: { + ID: orderIDs[1][:], + Qty: 3 * mkt.LotSize, + Rate: 5e7, + Sell: true, + LockedAmt: 3 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + }, + oidToPlacement: map[order.OrderID]int{ + orderIDs[0]: 0, + orderIDs[1]: 1, + }, + cexBaseBalance: 10 * mkt.LotSize, + dexBaseBalance: 10 * mkt.LotSize, + cexQuoteBalance: calc.BaseToQuote(5e7, 4*mkt.LotSize) + calc.BaseToQuote(uint64(float64(5e7)*(1+profitRate)), 6*mkt.LotSize), + dexQuoteBalance: calc.BaseToQuote(5e7, 2*mkt.LotSize) + 12e6, + expectedWithdraw: &withdrawArgs{ + assetID: quoteID, + amt: calc.BaseToQuote(5e7, 4*mkt.LotSize), + }, + expectedQuotePending: true, + }, + // "need to withdraw quote, no need to cancel 1 order" + { + name: "need to withdraw quote, no need to cancel 1 order", + cfg: &AutoRebalanceConfig{ + MinBaseAmt: 6 * mkt.LotSize, + MinQuoteAmt: calc.BaseToQuote(5e7, 6*mkt.LotSize), + }, + orders: map[order.OrderID]*core.Order{ + orderIDs[0]: { + ID: orderIDs[0][:], + Qty: 3 * mkt.LotSize, + Rate: 5e7, + Sell: true, + LockedAmt: 3 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + orderIDs[1]: { + ID: orderIDs[1][:], + Qty: 3 * mkt.LotSize, + Rate: 5e7, + Sell: true, + LockedAmt: 3 * mkt.LotSize, + Epoch: currEpoch - 2, + }, + }, + oidToPlacement: map[order.OrderID]int{ + orderIDs[0]: 0, + orderIDs[1]: 1, + }, + cexBaseBalance: 10 * mkt.LotSize, + dexBaseBalance: 10 * mkt.LotSize, + cexQuoteBalance: calc.BaseToQuote(5e7, 4*mkt.LotSize) + calc.BaseToQuote(uint64(float64(5e7)*(1+profitRate)), 6*mkt.LotSize), + dexQuoteBalance: calc.BaseToQuote(5e7, 2*mkt.LotSize) + 12e6 - 2, + expectedCancels: []dex.Bytes{ + orderIDs[1][:], + }, + expectedReserves: autoRebalanceReserves{ + quoteCexReserves: calc.BaseToQuote(5e7, 4*mkt.LotSize) + 1, + }, + }, + } + + runTest := func(test *test) { + cex := newTWrappedCEX() + cex.balances = map[uint32]*libxc.ExchangeBalance{ + baseID: {Available: test.cexBaseBalance}, + quoteID: {Available: test.cexQuoteBalance}, + } + tCore := newTCore() + tCore.setAssetBalances(map[uint32]uint64{ + baseID: test.dexBaseBalance, + quoteID: test.dexQuoteBalance, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mm := &arbMarketMaker{ + ctx: ctx, + cex: cex, + core: tCore, + baseID: baseID, + quoteID: quoteID, + oidToPlacement: test.oidToPlacement, + ords: test.orders, + log: tLogger, + cfg: &ArbMarketMakerConfig{ + AutoRebalance: test.cfg, + Profit: profitRate, + }, + mkt: mkt, + } + + if test.activeCEXOrders { + mm.cexTrades = map[string]uint64{"abc": 1234} + } + + mm.rebalanceAssets() + + if (test.expectedDeposit == nil) != (cex.lastDepositArgs == nil) { + t.Fatalf("%s: expected deposit %v but got %v", test.name, (test.expectedDeposit != nil), (cex.lastDepositArgs != nil)) + } + if test.expectedDeposit != nil { + if *cex.lastDepositArgs != *test.expectedDeposit { + t.Fatalf("%s: expected deposit %+v but got %+v", test.name, test.expectedDeposit, cex.lastDepositArgs) + } + } + + if (test.expectedWithdraw == nil) != (cex.lastWithdrawArgs == nil) { + t.Fatalf("%s: expected withdraw %v but got %v", test.name, (test.expectedWithdraw != nil), (cex.lastWithdrawArgs != nil)) + } + if test.expectedWithdraw != nil { + if *cex.lastWithdrawArgs != *test.expectedWithdraw { + t.Fatalf("%s: expected withdraw %+v but got %+v", test.name, test.expectedWithdraw, cex.lastWithdrawArgs) + } + } + + if len(tCore.cancelsPlaced) != len(test.expectedCancels) { + t.Fatalf("%s: expected %d cancels but got %d", test.name, len(test.expectedCancels), len(tCore.cancelsPlaced)) + } + for i := range test.expectedCancels { + if !tCore.cancelsPlaced[i].Equal(test.expectedCancels[i]) { + t.Fatalf("%s: expected cancel %d %s but got %s", test.name, i, hex.EncodeToString(test.expectedCancels[i]), hex.EncodeToString(tCore.cancelsPlaced[i])) + } + } + + if test.expectedReserves != mm.reserves { + t.Fatalf("%s: expected reserves %+v but got %+v", test.name, test.expectedReserves, mm.reserves) + } + + if test.expectedBasePending != mm.pendingBaseRebalance.Load() { + t.Fatalf("%s: expected pending base rebalance %v but got %v", test.name, test.expectedBasePending, mm.pendingBaseRebalance.Load()) + } + if test.expectedQuotePending != mm.pendingQuoteRebalance.Load() { + t.Fatalf("%s: expected pending quote rebalance %v but got %v", test.name, test.expectedQuotePending, mm.pendingQuoteRebalance.Load()) + } + } + + for _, test := range tests { + runTest(test) + } +} diff --git a/client/mm/mm_basic.go b/client/mm/mm_basic.go index 3493286997..b4f92b7b13 100644 --- a/client/mm/mm_basic.go +++ b/client/mm/mm_basic.go @@ -227,10 +227,11 @@ type basicMarketMaker struct { // groupedOrder is a subset of an *core.Order. type groupedOrder struct { - id order.OrderID - rate uint64 - lots uint64 - epoch uint64 + id order.OrderID + rate uint64 + lots uint64 + epoch uint64 + lockedAmt uint64 } func groupOrders(orders map[order.OrderID]*core.Order, oidToPlacement map[order.OrderID]int, lotSize uint64) (buys, sells map[int][]*groupedOrder) { @@ -238,10 +239,11 @@ func groupOrders(orders map[order.OrderID]*core.Order, oidToPlacement map[order. var oid order.OrderID copy(oid[:], o.ID) return &groupedOrder{ - id: oid, - rate: o.Rate, - lots: (o.Qty - o.Filled) / lotSize, - epoch: o.Epoch, + id: oid, + rate: o.Rate, + lots: (o.Qty - o.Filled) / lotSize, + epoch: o.Epoch, + lockedAmt: o.LockedAmt, } } diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index e405e1f109..ceec31d01f 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -36,18 +36,10 @@ type SimpleArbConfig struct { BaseOptions map[string]string `json:"baseOptions"` // QuoteOptions are the multi-order options for the quote asset wallet. QuoteOptions map[string]string `json:"quoteOptions"` - // AutoRebalance set to true means that if the base or quote asset balance - // dips below MinBaseAmt or MinQuoteAmt respectively, the bot will deposit - // or withdraw funds from the CEX to have an equal amount on both the DEX - // and the CEX. If it is not possible to bring both the DEX and CEX balances - // above the minimum amount, no action will be taken. Also, if the amount - // required to bring the balances to equal is less than MinBaseTransfer or - // MinQuoteTransfer, no action will be taken. - AutoRebalance bool `json:"autoRebalance"` - MinBaseAmt uint64 `json:"minBaseAmt"` - MinBaseTransfer uint64 `json:"minBaseTransfer"` - MinQuoteAmt uint64 `json:"minQuoteAmt"` - MinQuoteTransfer uint64 `json:"minQuoteTransfer"` + // AutoRebalance determines how the bot will handle rebalancing of the + // assets between the dex and the cex. If nil, no rebalancing will take + // place. + AutoRebalance *AutoRebalanceConfig `json:"autoRebalance"` } func (c *SimpleArbConfig) Validate() error { @@ -115,12 +107,12 @@ func (a *simpleArbMarketMaker) rebalanceAsset(base bool) { var minTransferAmount uint64 if base { assetID = a.baseID - minAmount = a.cfg.MinBaseAmt - minTransferAmount = a.cfg.MinBaseTransfer + minAmount = a.cfg.AutoRebalance.MinBaseAmt + minTransferAmount = a.cfg.AutoRebalance.MinBaseTransfer } else { assetID = a.quoteID - minAmount = a.cfg.MinQuoteAmt - minTransferAmount = a.cfg.MinQuoteTransfer + minAmount = a.cfg.AutoRebalance.MinQuoteAmt + minTransferAmount = a.cfg.AutoRebalance.MinQuoteTransfer } symbol := dex.BipIDSymbol(assetID) @@ -219,7 +211,7 @@ func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) { } } - if a.cfg.AutoRebalance && len(remainingArbs) == 0 { + if a.cfg.AutoRebalance != nil && len(remainingArbs) == 0 { if !a.pendingBaseRebalance.Load() { a.rebalanceAsset(true) } diff --git a/client/mm/mm_simple_arb_test.go b/client/mm/mm_simple_arb_test.go index d342dbb25c..b70ba2f4b5 100644 --- a/client/mm/mm_simple_arb_test.go +++ b/client/mm/mm_simple_arb_test.go @@ -384,11 +384,8 @@ func TestArbRebalance(t *testing.T) { existingArbs []*arbSequence pendingBaseRebalance bool pendingQuoteRebalance bool - autoRebalance bool - minBaseAmt uint64 - minBaseTransfer uint64 - minQuoteAmt uint64 - minQuoteTransfer uint64 + + autoRebalance *AutoRebalanceConfig expectedDexOrder *dexOrder expectedCexOrder *cexOrder @@ -983,9 +980,10 @@ func TestArbRebalance(t *testing.T) { 42: {Available: 1e19}, 0: {Available: 1e10}, }, - autoRebalance: true, - minBaseAmt: 1e16, - minQuoteAmt: 1e12, + autoRebalance: &AutoRebalanceConfig{ + MinBaseAmt: 1e16, + MinQuoteAmt: 1e12, + }, expectedWithdrawal: &assetAmt{ assetID: 42, amt: 4.99995e18, @@ -1017,11 +1015,12 @@ func TestArbRebalance(t *testing.T) { 42: {Available: 1.1e16}, 0: {Available: 9.5e11}, }, - autoRebalance: true, - minBaseAmt: 1e16, - minQuoteAmt: 1e12, - minBaseTransfer: (1.1e16+9.5e15)/2 - 9.5e15, - minQuoteTransfer: (1.1e12+9.5e11)/2 - 9.5e11, + autoRebalance: &AutoRebalanceConfig{ + MinBaseAmt: 1e16, + MinQuoteAmt: 1e12, + MinBaseTransfer: (1.1e16+9.5e15)/2 - 9.5e15, + MinQuoteTransfer: (1.1e12+9.5e11)/2 - 9.5e11, + }, expectedWithdrawal: &assetAmt{ assetID: 42, amt: (1.1e16+9.5e15)/2 - 9.5e15, @@ -1053,11 +1052,12 @@ func TestArbRebalance(t *testing.T) { 42: {Available: 1.1e16}, 0: {Available: 9.5e11}, }, - autoRebalance: true, - minBaseAmt: 1e16, - minQuoteAmt: 1e12, - minBaseTransfer: (1.1e16+9.5e15)/2 - 9.5e15 + 1, - minQuoteTransfer: (1.1e12+9.5e11)/2 - 9.5e11 + 1, + autoRebalance: &AutoRebalanceConfig{ + MinBaseAmt: 1e16, + MinQuoteAmt: 1e12, + MinBaseTransfer: (1.1e16+9.5e15)/2 - 9.5e15 + 1, + MinQuoteTransfer: (1.1e12+9.5e11)/2 - 9.5e11 + 1, + }, }, // "no arb, quote needs withdrawal, base needs deposit" { @@ -1081,9 +1081,10 @@ func TestArbRebalance(t *testing.T) { 42: {Available: 1e14}, 0: {Available: 1e17}, }, - autoRebalance: true, - minBaseAmt: 1e16, - minQuoteAmt: 1e12, + autoRebalance: &AutoRebalanceConfig{ + MinBaseAmt: 1e16, + MinQuoteAmt: 1e12, + }, expectedWithdrawal: &assetAmt{ assetID: 0, amt: 4.9999995e16, @@ -1115,9 +1116,10 @@ func TestArbRebalance(t *testing.T) { 42: {Available: 1e14}, 0: {Available: 1e17}, }, - autoRebalance: true, - minBaseAmt: 1e16, - minQuoteAmt: 1e12, + autoRebalance: &AutoRebalanceConfig{ + MinBaseAmt: 1e16, + MinQuoteAmt: 1e12, + }, pendingBaseRebalance: true, pendingQuoteRebalance: true, }, @@ -1178,10 +1180,6 @@ func TestArbRebalance(t *testing.T) { MaxActiveArbs: maxActiveArbs, NumEpochsLeaveOpen: numEpochsLeaveOpen, AutoRebalance: test.autoRebalance, - MinBaseAmt: test.minBaseAmt, - MinQuoteAmt: test.minQuoteAmt, - MinBaseTransfer: test.minBaseTransfer, - MinQuoteTransfer: test.minQuoteTransfer, }, } @@ -1226,7 +1224,6 @@ func TestArbRebalance(t *testing.T) { } if !test.expectedDexOrder.sell { - fmt.Printf("multi trades placed: %v\n", tCore.multiTradesPlaced) if len(tCore.multiTradesPlaced[0].Placements) != 1 { t.Fatalf("%s: expected 1 buy order but got %d", test.name, len(tCore.buysPlaced)) } diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go index a86f57b0da..95ba31d116 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -169,7 +169,6 @@ type tCore struct { book *orderbook.OrderBook bookFeed *tBookFeed lastSendArgs *sendArgs - sendTxID string txConfs uint32 txConfsErr error txConfsTxID string @@ -284,14 +283,34 @@ func (c *tCore) Broadcast(core.Notification) {} func (c *tCore) FiatConversionRates() map[uint32]float64 { return nil } -func (c *tCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) { + +type tCoin struct { + txID []byte +} + +var _ asset.Coin = (*tCoin)(nil) + +func (c *tCoin) ID() dex.Bytes { + return c.txID +} +func (c *tCoin) String() string { + return hex.EncodeToString(c.txID) +} +func (c *tCoin) Value() uint64 { + return 0 +} +func (c *tCoin) TxID() string { + return hex.EncodeToString(c.txID) +} + +func (c *tCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) { c.lastSendArgs = &sendArgs{ assetID: assetID, value: value, address: address, subtract: subtract, } - return c.sendTxID, nil, nil + return &tCoin{}, nil } func (c *tCore) NewDepositAddress(assetID uint32) (string, error) { return c.newDepositAddress, nil diff --git a/client/mm/sample-config.json b/client/mm/sample-config.json index f6993f6404..e734577a3e 100644 --- a/client/mm/sample-config.json +++ b/client/mm/sample-config.json @@ -62,6 +62,12 @@ "quoteOptions": { "multisplit": "true", "multisplitbuffer": "5" + }, + "autoRebalance": { + "minBaseAmt": 3000000000, + "minBaseTransfer" : 1000000000, + "minQuoteAmt": 20000000, + "minQuoteTransfer": 10000000 } } } diff --git a/client/mm/wrapped_cex.go b/client/mm/wrapped_cex.go index 438b903140..135df64465 100644 --- a/client/mm/wrapped_cex.go +++ b/client/mm/wrapped_cex.go @@ -26,6 +26,13 @@ type cexTrade struct { quoteFilled uint64 } +// cex is an interface implemented by wrappedCEX. It is an interface used +// to interact with a centralized exchange. It assumes there is only one +// caller. It is generally similar to libxc.CEX, but with some notable +// differences: +// - A Deposit function is added, which takes funds from the bot's dex wallet +// and sends it to the cex. +// - SubscribeTradeUpdates/Trade do not return/take a subscription ID. type cex interface { Balance(assetID uint32) (*libxc.ExchangeBalance, error) CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error @@ -37,9 +44,9 @@ type cex interface { Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error } -// wrappedCEX implements the CEX interface. A separate instance should be +// wrappedCEX implements the cex interface. A separate instance should be // created for each arbitrage bot, and it will behave as if the entire balance -// on the CEX is the amount that was allocated to the bot. +// on the cex is the amount that was allocated to the bot. type wrappedCEX struct { libxc.CEX @@ -76,12 +83,11 @@ func (w *wrappedCEX) Deposit(ctx context.Context, assetID uint32, amount uint64, return err } - txID, _, err := w.mm.core.Send([]byte{}, assetID, amount, addr, w.mm.isWithdrawer(assetID)) + coin, err := w.mm.core.Send([]byte{}, assetID, amount, addr, w.mm.isWithdrawer(assetID)) if err != nil { return err } - // TODO: special handling for wallets that do not support withdrawing. w.mm.modifyBotBalance(w.botID, []*balanceMod{{balanceModDecrease, assetID, balTypeAvailable, amount}}) go func() { @@ -91,7 +97,7 @@ func (w *wrappedCEX) Deposit(ctx context.Context, assetID uint32, amount uint64, } onConfirm() } - w.CEX.ConfirmDeposit(ctx, txID, conf) + w.CEX.ConfirmDeposit(ctx, coin.TxID(), conf) }() return nil @@ -123,6 +129,9 @@ func (w *wrappedCEX) Withdraw(ctx context.Context, assetID uint32, amount uint64 return false } if confs > 0 { + // TODO: get the amount withdrawn from the wallet instead of + // trusting the CEX. TxHistory could be used if it is + // implemented for all wallets. w.mm.modifyBotBalance(w.botID, []*balanceMod{{balanceModIncrease, assetID, balTypeAvailable, withdrawnAmt}}) onConfirm() return true diff --git a/client/mm/wrapped_core.go b/client/mm/wrapped_core.go index fdef07c9c6..725cd81337 100644 --- a/client/mm/wrapped_core.go +++ b/client/mm/wrapped_core.go @@ -441,6 +441,7 @@ func (c *wrappedCore) AssetBalance(assetID uint32) (*core.WalletBalance, error) Balance: &db.Balance{ Balance: asset.Balance{ Available: bal, + // TODO: handle locked funds }, }, }, nil diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index 5cb3f3b782..bdae58c91d 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -634,7 +634,7 @@ func send(s *RPCServer, params *RawParams, route string) *msgjson.ResponsePayloa resErr := msgjson.NewError(msgjson.RPCFundTransferError, "empty pass") return createResponse(route, nil, resErr) } - _, coin, err := s.core.Send(form.appPass, form.assetID, form.value, form.address, subtract) + coin, err := s.core.Send(form.appPass, form.assetID, form.value, form.address, subtract) if err != nil { errMsg := fmt.Sprintf("unable to %s: %v", err, route) resErr := msgjson.NewError(msgjson.RPCFundTransferError, errMsg) diff --git a/client/rpcserver/rpcserver.go b/client/rpcserver/rpcserver.go index 67859d4d86..4991e5a59d 100644 --- a/client/rpcserver/rpcserver.go +++ b/client/rpcserver/rpcserver.go @@ -77,7 +77,7 @@ type clientCore interface { Wallets() (walletsStates []*core.WalletState) WalletState(assetID uint32) *core.WalletState RescanWallet(assetID uint32, force bool) error - Send(appPass []byte, assetID uint32, value uint64, addr string, subtract bool) (string, asset.Coin, error) + Send(appPass []byte, assetID uint32, value uint64, addr string, subtract bool) (asset.Coin, error) ExportSeed(pw []byte) ([]byte, error) DeleteArchivedRecords(olderThan *time.Time, matchesFileStr, ordersFileStr string) (int, error) WalletPeers(assetID uint32) ([]*asset.WalletPeer, error) diff --git a/client/rpcserver/rpcserver_test.go b/client/rpcserver/rpcserver_test.go index a141bbd1ac..c75f63ada6 100644 --- a/client/rpcserver/rpcserver_test.go +++ b/client/rpcserver/rpcserver_test.go @@ -151,8 +151,8 @@ func (c *TCore) Wallets() []*core.WalletState { func (c *TCore) WalletState(assetID uint32) *core.WalletState { return c.walletState } -func (c *TCore) Send(pw []byte, assetID uint32, value uint64, addr string, subtract bool) (string, asset.Coin, error) { - return "", c.coin, c.sendErr +func (c *TCore) Send(pw []byte, assetID uint32, value uint64, addr string, subtract bool) (asset.Coin, error) { + return c.coin, c.sendErr } func (c *TCore) ExportSeed(pw []byte) ([]byte, error) { return c.exportSeed, c.exportSeedErr diff --git a/client/webserver/api.go b/client/webserver/api.go index 7b20ae0e78..84e83792c4 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -1365,7 +1365,7 @@ func (s *WebServer) send(w http.ResponseWriter, r *http.Request, form *sendOrWit s.writeAPIError(w, fmt.Errorf("empty password")) return } - _, coin, err := s.core.Send(form.Pass, form.AssetID, form.Value, form.Address, form.Subtract) + coin, err := s.core.Send(form.Pass, form.AssetID, form.Value, form.Address, form.Subtract) if err != nil { s.writeAPIError(w, fmt.Errorf("send/withdraw error: %w", err)) return diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index c380cd8373..6b32c74d00 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -1769,8 +1769,8 @@ func (c *TCore) SupportedAssets() map[uint32]*core.SupportedAsset { } } -func (c *TCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) { - return "", &tCoin{id: []byte{0xde, 0xc7, 0xed}}, nil +func (c *TCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) { + return &tCoin{id: []byte{0xde, 0xc7, 0xed}}, nil } func (c *TCore) Trade(pw []byte, form *core.TradeForm) (*core.Order, error) { return c.trade(form), nil diff --git a/client/webserver/site/src/js/doc.ts b/client/webserver/site/src/js/doc.ts index d14c19722a..4f26267370 100644 --- a/client/webserver/site/src/js/doc.ts +++ b/client/webserver/site/src/js/doc.ts @@ -37,7 +37,8 @@ const BipIDs: Record = { 966000: 'dextt.polygon', 966001: 'usdc.polygon', 966002: 'weth.polygon', - 966003: 'wbtc.polygon' + 966003: 'wbtc.polygon', + 147: 'zcl' } const BipSymbolIDs: Record = {}; diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index 605eaccdee..2289f207c9 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -119,7 +119,7 @@ type clientCore interface { AddDEX(dexAddr string, certI any) error DiscoverAccount(dexAddr string, pass []byte, certI any) (*core.Exchange, bool, error) SupportedAssets() map[uint32]*core.SupportedAsset - Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) + Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) Trade(pw []byte, form *core.TradeForm) (*core.Order, error) TradeAsync(pw []byte, form *core.TradeForm) (*core.InFlightOrder, error) Cancel(oid dex.Bytes) error diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index c3284f573c..41e85b1a2c 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -175,8 +175,8 @@ func (c *TCore) User() *core.User { return nil } func (c *TCore) SupportedAssets() map[uint32]*core.SupportedAsset { return make(map[uint32]*core.SupportedAsset) } -func (c *TCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (string, asset.Coin, error) { - return "", &tCoin{id: []byte{0xde, 0xc7, 0xed}}, c.sendErr +func (c *TCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) { + return &tCoin{id: []byte{0xde, 0xc7, 0xed}}, c.sendErr } func (c *TCore) ValidateAddress(address string, assetID uint32) (bool, error) { return c.validAddr, nil diff --git a/dex/testing/dcrdex/harness.sh b/dex/testing/dcrdex/harness.sh index 909676afa8..3be2d8121d 100755 --- a/dex/testing/dcrdex/harness.sh +++ b/dex/testing/dcrdex/harness.sh @@ -146,7 +146,7 @@ if [ $ETH_ON -eq 0 ]; then { "base": "ETH_simnet", "quote": "BTC_simnet", - "lotSize": 1000000, + "lotSize": 100000000, "rateStep": 1000, "epochDuration": ${EPOCH_DURATION}, "marketBuyBuffer": 1.2, diff --git a/dex/testing/loadbot/mantle.go b/dex/testing/loadbot/mantle.go index 8f5d9577dc..19d16b84bc 100644 --- a/dex/testing/loadbot/mantle.go +++ b/dex/testing/loadbot/mantle.go @@ -540,7 +540,7 @@ func (m *Mantle) replenishBalance(w *botWallet, minFunds, maxFunds uint64) { // Send some back to the alpha address. amt := bal.Available - wantBal m.log.Debugf("Sending %s back to %s alpha node", valString(amt, w.symbol), w.symbol) - _, _, err := m.Send(pass, w.assetID, amt, returnAddress(w.symbol, alpha), false) + _, err := m.Send(pass, w.assetID, amt, returnAddress(w.symbol, alpha), false) if err != nil { m.fatalError("failed to send funds to alpha: %v", err) }