diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 3396665e85..bb0fa8f254 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -4895,23 +4895,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. @@ -5006,6 +5006,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 71b8ec0d76..bafb0a1d29 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -3725,11 +3725,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) } } @@ -3894,7 +3894,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 495430aea6..fbcab9d47f 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 e0eff66703..058a49f282 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -4301,31 +4301,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. @@ -4383,6 +4383,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 2e44f3b695..e4dd312a9e 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -3361,14 +3361,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) } } @@ -3387,13 +3387,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) } @@ -3401,7 +3401,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) } @@ -3409,7 +3409,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 d3cc2dc1be..8853a32ff5 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -3136,14 +3136,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 { @@ -3152,37 +3152,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. @@ -3338,6 +3338,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 f9cb01154e..058ced7fa4 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -515,9 +515,10 @@ 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(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 @@ -629,7 +630,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/polygon/polygon.go b/client/asset/polygon/polygon.go index 809e0c3506..a108492e3a 100644 --- a/client/asset/polygon/polygon.go +++ b/client/asset/polygon/polygon.go @@ -45,8 +45,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/core/core.go b/client/core/core.go index 515d93f2a5..48a456ac8c 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -4295,7 +4295,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) } @@ -5341,54 +5341,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()) @@ -5396,7 +5397,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 6ef61f3d84..42b78ac325 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -729,6 +729,7 @@ type TXCWallet struct { } var _ asset.Accelerator = (*TXCWallet)(nil) +var _ asset.Withdrawer = (*TXCWallet)(nil) func newTWallet(assetID uint32) (*xcWallet, *TXCWallet) { w := &TXCWallet{ @@ -929,19 +930,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 { @@ -1111,6 +1112,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 @@ -2729,7 +2734,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) } @@ -2738,13 +2743,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") } @@ -2752,7 +2757,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") } @@ -2760,7 +2765,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") } @@ -2768,7 +2773,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) } @@ -2794,7 +2799,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) } @@ -2808,7 +2813,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") } @@ -10378,12 +10383,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 1c0a14249b..82390e0c24 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/mm/config.go b/client/mm/config.go index 3bbe8b94c1..c556c7550d 100644 --- a/client/mm/config.go +++ b/client/mm/config.go @@ -34,20 +34,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"` @@ -63,6 +74,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/libxc/binance.go b/client/mm/libxc/binance.go index 19c0596eea..fc4664362c 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..ccce2c46f8 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" @@ -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,7 +131,7 @@ func TestTrade(t *testing.T) { } } }() - tradeID, err := bnc.Trade(ctx, "eth", "btc", false, 6000e2, 1e7, updaterID) + tradeID, err := bnc.Trade(ctx, "eth", "btc", false, 6000e2, 1e7 if err != nil { t.Fatalf("trade error: %v", err) } @@ -232,3 +233,65 @@ func TestVWAP(t *testing.T) { t.Fatalf("error unsubscribing market") } } + +func TestWithdrawal(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(amt uint64, txID string) { + t.Logf("withdrawal complete: %v, %v", amt, txID) + wg.Done() + } + + err = bnc.Withdraw(ctx, "polygon", 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, "polygon") + 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("polygon") + 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/client/mm/mm.go b/client/mm/mm.go index ed625a05ae..d310036acd 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,24 +1102,32 @@ 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: wg.Add(1) go func(cfg *BotConfig) { defer wg.Done() + if cfg.CEXCfg == nil { + // Should be caught earlier, but just in case. + m.log.Errorf("No CEX config provided. Skipping %s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + return + } + mkt := MarketWithHost{cfg.Host, cfg.BaseAsset, cfg.QuoteAsset} + m.markBotAsRunning(mkt, true) + defer func() { + m.markBotAsRunning(mkt, false) + }() logger := m.log.SubLogger(fmt.Sprintf("Arbitrage-%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 } - RunSimpleArbBot(m.ctx, cfg, m.core, cex, logger) + RunSimpleArbBot(m.ctx, cfg, m.wrappedCoreForBot(mktID), 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_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()) 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), + } +} 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 4e04159fba..1ce2d06e1a 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -1341,7 +1341,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 ae1c0c7536..70d987fb5f 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -1751,8 +1751,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/site/src/js/doc.ts b/client/webserver/site/src/js/doc.ts index 50fa30ee08..d099452cb8 100644 --- a/client/webserver/site/src/js/doc.ts +++ b/client/webserver/site/src/js/doc.ts @@ -30,8 +30,8 @@ const BipIDs: Record = { 60001: 'usdc.eth', 966000: 'dextt.polygon', 966001: 'usdc.polygon', - 966002: 'weth.polygon', - 966003: 'wbtc.polygon' + 966002: 'eth.polygon', + 966003: 'btc.polygon' } const BipSymbolIDs: Record = {}; diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index c5214f0cde..23e3b26804 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -120,7 +120,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 4f18d19689..969b15811d 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -170,8 +170,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/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/dex/testing/loadbot/mantle.go b/dex/testing/loadbot/mantle.go index 0960a0db1a..255bd742b6 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) } 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: