exchanges/engine: Add multichain deposit/withdrawal support (#794)

* Add exchange multichain support

* Start tidying up

* Add multichain transfer support for Bitfinex and fix poloniex bug

* Add Coinbene multichain support

* Start adjusting the deposit address manager

* Fix deposit tests and further enhancements

* Cleanup

* Add bypass flag, expand tests plus error coverage for Huobi

Adjust helpers

* Address nitterinos

* BFX wd changes

* Address nitterinos

* Minor fixes rebasing on master

* Fix BFX acceptableMethods test

* Add some TO-DOs for 2 tests WRT races

* Fix acceptableMethods test round 2

* Address nitterinos
This commit is contained in:
Adrian Gallagher
2021-10-15 15:55:38 +11:00
committed by GitHub
parent b093a7df19
commit 0c00b7e1df
145 changed files with 46329 additions and 5507 deletions

View File

@@ -30,7 +30,6 @@ const (
bitfinexAccountInfo = "account_infos"
bitfinexAccountFees = "account_fees"
bitfinexAccountSummary = "summary"
bitfinexDeposit = "deposit/new"
bitfinexBalances = "balances"
bitfinexTransfer = "transfer"
bitfinexWithdrawal = "withdraw"
@@ -77,7 +76,8 @@ const (
bitfinexCandles = "candles/trade"
bitfinexKeyPermissions = "key_info"
bitfinexMarginInfo = "margin_infos"
bitfinexDepositMethod = "conf/pub:map:currency:label"
bitfinexDepositMethod = "conf/pub:map:tx:method"
bitfinexDepositAddress = "auth/w/deposit/address"
bitfinexMarginPairs = "conf/pub:list:pair:margin"
// Bitfinex platform status values
@@ -1065,27 +1065,76 @@ func (b *Bitfinex) GetAccountSummary(ctx context.Context) (AccountSummary, error
// NewDeposit returns a new deposit address
// Method - Example methods accepted: “bitcoin”, “litecoin”, “ethereum”,
// “tethers", "ethereumc", "zcash", "monero", "iota", "bcash"
// WalletName - accepted: “trading”, “exchange, “deposit”
// WalletName - accepted: "exchange", "margin", "funding" (can also use the old labels
// which are "exchange", "trading" and "deposit" respectively). If none is set,
// "funding" will be used by default
// renew - Default is 0. If set to 1, will return a new unused deposit address
func (b *Bitfinex) NewDeposit(ctx context.Context, method, walletName string, renew int) (DepositResponse, error) {
if !common.StringDataCompare(AcceptedWalletNames, walletName) {
return DepositResponse{},
func (b *Bitfinex) NewDeposit(ctx context.Context, method, walletName string, renew uint8) (*Deposit, error) {
if walletName == "" {
walletName = "funding"
} else if !common.StringDataCompare(AcceptedWalletNames, walletName) {
return nil,
fmt.Errorf("walletname: [%s] is not allowed, supported: %s",
walletName,
AcceptedWalletNames)
}
response := DepositResponse{}
req := make(map[string]interface{})
req["method"] = method
req["wallet_name"] = walletName
req["renew"] = renew
req := make(map[string]interface{}, 3)
req["wallet"] = walletName
req["method"] = strings.ToLower(method)
req["op_renew"] = renew
var result []interface{}
return response, b.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost,
bitfinexDeposit,
err := b.SendAuthenticatedHTTPRequestV2(ctx,
exchange.RestSpot,
http.MethodPost,
bitfinexDepositAddress,
req,
&response,
&result,
newDepositAddress)
if err != nil {
return nil, err
}
if len(result) != 8 {
return nil, errors.New("expected result to have a len of 8")
}
depositInfo, ok := result[4].([]interface{})
if !ok || len(depositInfo) != 6 {
return nil, errors.New("unable to get deposit data")
}
depositMethod, ok := depositInfo[1].(string)
if !ok {
return nil, errors.New("unable to type assert depositMethod to string")
}
coin, ok := depositInfo[2].(string)
if !ok {
return nil, errors.New("unable to type assert coin to string")
}
var address, poolAddress string
if depositInfo[5] == nil {
address, ok = depositInfo[4].(string)
if !ok {
return nil, errors.New("unable to type assert address to string")
}
} else {
poolAddress, ok = depositInfo[4].(string)
if !ok {
return nil, errors.New("unable to type assert poolAddress to string")
}
address, ok = depositInfo[5].(string)
if !ok {
return nil, errors.New("unable to type assert address to string")
}
}
return &Deposit{
Method: depositMethod,
CurrencyCode: coin,
Address: address,
PoolAddress: poolAddress,
}, nil
}
// GetKeyPermissions checks the permissions of the key being used to generate
@@ -1149,10 +1198,10 @@ func (b *Bitfinex) WalletTransfer(ctx context.Context, amount float64, currency,
// WithdrawCryptocurrency requests a withdrawal from one of your wallets.
// For FIAT, use WithdrawFIAT
func (b *Bitfinex) WithdrawCryptocurrency(ctx context.Context, wallet, address, paymentID string, amount float64, c currency.Code) (Withdrawal, error) {
func (b *Bitfinex) WithdrawCryptocurrency(ctx context.Context, wallet, address, paymentID, curr string, amount float64) (Withdrawal, error) {
var response []Withdrawal
req := make(map[string]interface{})
req["withdraw_type"] = b.ConvertSymbolToWithdrawalType(c)
req["withdraw_type"] = strings.ToLower(curr)
req["walletselected"] = wallet
req["amount"] = strconv.FormatFloat(amount, 'f', -1, 64)
req["address"] = address
@@ -1666,18 +1715,16 @@ func (b *Bitfinex) SendAuthenticatedHTTPRequestV2(ctx context.Context, ep exchan
body = bytes.NewBuffer(payload)
}
// This is done in a weird way because bitfinex doesn't accept unixnano
n := strconv.FormatInt(int64(b.Requester.GetNonce(false))*1e9, 10)
n := strconv.FormatInt(time.Now().Unix()*1e9, 10)
headers := make(map[string]string)
headers["Content-Type"] = "application/json"
headers["Accept"] = "application/json"
headers["bfx-apikey"] = b.API.Credentials.Key
headers["bfx-nonce"] = n
strPath := "/api" + bitfinexAPIVersion2 + path + string(payload)
signStr := strPath + n
sig := "/api" + bitfinexAPIVersion2 + path + n + string(payload)
hmac, err := crypto.GetHMAC(
crypto.HashSHA512_384,
[]byte(signStr),
[]byte(sig),
[]byte(b.API.Credentials.Secret),
)
if err != nil {
@@ -1792,89 +1839,52 @@ func (b *Bitfinex) CalculateTradingFee(i []AccountInfo, purchasePrice, amount fl
return (fee / 100) * purchasePrice * amount, err
}
// ConvertSymbolToWithdrawalType You need to have specific withdrawal types to withdraw from Bitfinex
func (b *Bitfinex) ConvertSymbolToWithdrawalType(c currency.Code) string {
switch c {
case currency.BTC:
return "bitcoin"
case currency.LTC:
return "litecoin"
case currency.ETH:
return "ethereum"
case currency.ETC:
return "ethereumc"
case currency.USDT:
return "tetheruso"
case currency.ZEC:
return "zcash"
case currency.XMR:
return "monero"
case currency.DSH:
return "dash"
case currency.XRP:
return "ripple"
case currency.SAN:
return "santiment"
case currency.OMG:
return "omisego"
case currency.BCH:
return "bcash"
case currency.ETP:
return "metaverse"
case currency.AVT:
return "aventus"
case currency.EDO:
return "eidoo"
case currency.BTG:
return "bgold"
case currency.DATA:
return "datacoin"
case currency.GNT:
return "golem"
case currency.SNT:
return "status"
default:
return c.Lower().String()
}
}
// ConvertSymbolToDepositMethod returns a converted currency deposit method
func (b *Bitfinex) ConvertSymbolToDepositMethod(ctx context.Context, c currency.Code) (string, error) {
if err := b.PopulateAcceptableMethods(ctx); err != nil {
return "", err
}
method, ok := AcceptableMethods[c.String()]
if !ok {
return "", fmt.Errorf("currency %s not supported in method list",
c)
}
return strings.ToLower(method), nil
}
// PopulateAcceptableMethods retrieves all accepted currency strings and
// populates a map to check
func (b *Bitfinex) PopulateAcceptableMethods(ctx context.Context) error {
if len(AcceptableMethods) == 0 {
var response [][][2]string
err := b.SendHTTPRequest(ctx, exchange.RestSpot,
bitfinexAPIVersion2+bitfinexDepositMethod,
&response,
configs)
if err != nil {
return err
}
if len(response) == 0 {
return errors.New("response contains no data cannot populate acceptable method map")
}
for i := range response[0] {
if len(response[0][i]) != 2 {
return errors.New("response contains no data cannot populate acceptable method map")
}
AcceptableMethods[response[0][i][0]] = response[0][i][1]
}
if acceptableMethods.loaded() {
return nil
}
var response [][][]interface{}
err := b.SendHTTPRequest(ctx,
exchange.RestSpot,
bitfinexAPIVersion2+bitfinexDepositMethod,
&response,
configs)
if err != nil {
return err
}
if len(response) == 0 {
return errors.New("response contains no data cannot populate acceptable method map")
}
data := response[0]
storeData := make(map[string][]string)
for x := range data {
if len(data[x]) == 0 {
return fmt.Errorf("data should not be empty")
}
name, ok := data[x][0].(string)
if !ok {
return fmt.Errorf("unable to type assert name")
}
var availOptions []string
options, ok := data[x][1].([]interface{})
if !ok {
return fmt.Errorf("unable to type assert options")
}
for x := range options {
o, ok := options[x].(string)
if !ok {
return fmt.Errorf("unable to type assert option to string")
}
availOptions = append(availOptions, o)
}
storeData[name] = availOptions
}
acceptableMethods.load(storeData)
return nil
}

View File

@@ -350,7 +350,7 @@ func TestNewDeposit(t *testing.T) {
t.Error("NewDeposit() Expected error")
}
_, err = b.NewDeposit(context.Background(), "bitcoin", "exchange", 0)
_, err = b.NewDeposit(context.Background(), "ripple", "", 0)
if err != nil {
t.Error(err)
}
@@ -956,11 +956,13 @@ func TestWithdraw(t *testing.T) {
}
withdrawCryptoRequest := withdraw.Request{
Exchange: b.Name,
Amount: -1,
Currency: currency.BTC,
Currency: currency.USDT,
Description: "WITHDRAW IT ALL",
Crypto: withdraw.CryptoRequest{
Address: core.BitcoinDonationAddress,
Address: "0x1nv4l1d",
Chain: "tetheruse",
},
}
@@ -1034,14 +1036,12 @@ func TestWithdrawInternationalBank(t *testing.T) {
func TestGetDepositAddress(t *testing.T) {
t.Parallel()
if areTestAPIKeysSet() {
_, err := b.GetDepositAddress(context.Background(),
currency.BTC, "deposit")
_, err := b.GetDepositAddress(context.Background(), currency.USDT, "", "TETHERUSE")
if err != nil {
t.Error("GetDepositAddress() error", err)
}
} else {
_, err := b.GetDepositAddress(context.Background(),
currency.BTC, "deposit")
_, err := b.GetDepositAddress(context.Background(), currency.BTC, "deposit", "")
if err == nil {
t.Error("GetDepositAddress() error cannot be nil")
}
@@ -1201,22 +1201,6 @@ func TestWsCancelOffer(t *testing.T) {
}
}
func TestConvertSymbolToDepositMethod(t *testing.T) {
s, err := b.ConvertSymbolToDepositMethod(context.Background(), currency.BTC)
if err != nil {
log.Fatal(err)
}
if s != "bitcoin" {
t.Errorf("expected bitcoin but received %s", s)
}
_, err = b.ConvertSymbolToDepositMethod(context.Background(),
currency.NewCode("CATS!"))
if err == nil {
log.Fatal("error cannot be nil")
}
}
func TestUpdateTradablePairs(t *testing.T) {
err := b.UpdateTradablePairs(context.Background(), false)
if err != nil {
@@ -1650,3 +1634,70 @@ func TestReOrderbyID(t *testing.T) {
}
}
}
func TestPopulateAcceptableMethods(t *testing.T) {
t.Parallel()
if acceptableMethods.loaded() {
// we may have have been loaded from another test, so reset
acceptableMethods.m.Lock()
acceptableMethods.a = make(map[string][]string)
acceptableMethods.m.Unlock()
if acceptableMethods.loaded() {
t.Error("expected false")
}
}
if err := b.PopulateAcceptableMethods(context.Background()); err != nil {
t.Fatal(err)
}
if !acceptableMethods.loaded() {
t.Error("acceptable method store should be loaded")
}
if methods := acceptableMethods.lookup(currency.NewCode("UST")); len(methods) == 0 {
t.Error("USDT should have many available methods")
}
if methods := acceptableMethods.lookup(currency.NewCode("ASdasdasdasd")); len(methods) != 0 {
t.Error("non-existent code should return no methods")
}
// since we're already loaded, this will return nil
if err := b.PopulateAcceptableMethods(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestGetAvailableTransferChains(t *testing.T) {
t.Parallel()
r, err := b.GetAvailableTransferChains(context.Background(), currency.USDT)
if err != nil {
t.Fatal(err)
}
if len(r) < 2 {
t.Error("there should be many available USDT transfer chains")
}
}
func TestAccetableMethodStore(t *testing.T) {
t.Parallel()
var a acceptableMethodStore
if a.loaded() {
t.Error("should be empty")
}
data := map[string][]string{
"BITCOIN": {"BTC"},
"TETHER1": {"UST"},
"TETHER2": {"UST"},
}
a.load(data)
if !a.loaded() {
t.Error("data should be loaded")
}
if name := a.lookup(currency.NewCode("BTC")); len(name) != 1 && name[1] != "BITCOIN" {
t.Error("incorrect values")
}
if name := a.lookup(currency.NewCode("UST")); (name[0] != "TETHER1" && name[1] != "TETHER2") &&
(name[0] != "TETHER2" && name[1] != "TETHER1") {
t.Errorf("incorrect values")
}
if name := a.lookup(currency.NewCode("PANDA_HORSE")); len(name) != 0 {
t.Error("incorrect values")
}
}

View File

@@ -2,8 +2,11 @@ package bitfinex
import (
"errors"
"sync"
"time"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
)
@@ -50,8 +53,37 @@ var AcceptedOrderType = []string{"market", "limit", "stop", "trailing-stop",
var AcceptedWalletNames = []string{"trading", "exchange", "deposit", "margin",
"funding"}
// AcceptableMethods defines a map of currency codes to methods
var AcceptableMethods = make(map[string]string)
type acceptableMethodStore struct {
a map[string][]string
m sync.RWMutex
}
// acceptableMethods holds the available acceptable deposit and withdraw methods
var acceptableMethods acceptableMethodStore
func (a *acceptableMethodStore) lookup(curr currency.Code) []string {
a.m.RLock()
defer a.m.RUnlock()
var methods []string
for k, v := range a.a {
if common.StringDataCompareInsensitive(v, curr.Upper().String()) {
methods = append(methods, k)
}
}
return methods
}
func (a *acceptableMethodStore) load(data map[string][]string) {
a.m.Lock()
defer a.m.Unlock()
a.a = data
}
func (a *acceptableMethodStore) loaded() bool {
a.m.RLock()
defer a.m.RUnlock()
return len(a.a) > 0
}
// MarginV2FundingData stores margin funding data
type MarginV2FundingData struct {
@@ -238,12 +270,12 @@ type Currency struct {
Amount float64 `json:"amount,string"`
}
// DepositResponse holds deposit address information
type DepositResponse struct {
Result string `json:"string"`
Method string `json:"method"`
Currency string `json:"currency"`
Address string `json:"address"`
// Deposit holds the deposit address info
type Deposit struct {
Method string
CurrencyCode string
Address string // Deposit address (instead of the address, this field will show Tag/Memo/Payment_ID for currencies that require it)
PoolAddress string // Pool address (for currencies that require a Tag/Memo/Payment_ID)
}
// KeyPermissions holds the key permissions for the API key set

View File

@@ -17,6 +17,7 @@ import (
exchange "github.com/thrasher-corp/gocryptotrader/exchanges"
"github.com/thrasher-corp/gocryptotrader/exchanges/account"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/deposit"
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
@@ -89,29 +90,32 @@ func (b *Bitfinex) SetDefaults() {
REST: true,
Websocket: true,
RESTCapabilities: protocol.Features{
TickerBatching: true,
TickerFetching: true,
OrderbookFetching: true,
AutoPairUpdates: true,
AccountInfo: true,
CryptoDeposit: true,
CryptoWithdrawal: true,
FiatWithdraw: true,
GetOrder: true,
GetOrders: true,
CancelOrders: true,
CancelOrder: true,
SubmitOrder: true,
SubmitOrders: true,
DepositHistory: true,
WithdrawalHistory: true,
TradeFetching: true,
UserTradeHistory: true,
TradeFee: true,
FiatDepositFee: true,
FiatWithdrawalFee: true,
CryptoDepositFee: true,
CryptoWithdrawalFee: true,
TickerBatching: true,
TickerFetching: true,
OrderbookFetching: true,
AutoPairUpdates: true,
AccountInfo: true,
CryptoDeposit: true,
CryptoWithdrawal: true,
FiatWithdraw: true,
GetOrder: true,
GetOrders: true,
CancelOrders: true,
CancelOrder: true,
SubmitOrder: true,
SubmitOrders: true,
DepositHistory: true,
WithdrawalHistory: true,
TradeFetching: true,
UserTradeHistory: true,
TradeFee: true,
FiatDepositFee: true,
FiatWithdrawalFee: true,
CryptoDepositFee: true,
CryptoWithdrawalFee: true,
MultiChainDeposits: true,
MultiChainWithdrawals: true,
MultiChainDepositRequiresChainSet: true,
},
WebsocketCapabilities: protocol.Features{
AccountBalance: true,
@@ -731,18 +735,39 @@ func (b *Bitfinex) GetOrderInfo(ctx context.Context, orderID string, pair curren
}
// GetDepositAddress returns a deposit address for a specified currency
func (b *Bitfinex) GetDepositAddress(ctx context.Context, c currency.Code, accountID string) (string, error) {
func (b *Bitfinex) GetDepositAddress(ctx context.Context, c currency.Code, accountID, chain string) (*deposit.Address, error) {
if accountID == "" {
accountID = "deposit"
accountID = "funding"
}
method, err := b.ConvertSymbolToDepositMethod(ctx, c)
if err != nil {
return "", err
if c == currency.USDT {
// USDT is UST on Bitfinex
c = currency.NewCode("UST")
}
if err := b.PopulateAcceptableMethods(ctx); err != nil {
return nil, err
}
methods := acceptableMethods.lookup(c)
if len(methods) == 0 {
return nil, errors.New("unsupported currency")
}
method := methods[0]
if len(methods) > 1 && chain != "" {
method = chain
} else if len(methods) > 1 && chain == "" {
return nil, fmt.Errorf("a chain must be specified, %s available", methods)
}
resp, err := b.NewDeposit(ctx, method, accountID, 0)
return resp.Address, err
if err != nil {
return nil, err
}
return &deposit.Address{
Address: resp.Address,
Tag: resp.PoolAddress,
}, err
}
// WithdrawCryptocurrencyFunds returns a withdrawal ID when a withdrawal is submitted
@@ -750,6 +775,31 @@ func (b *Bitfinex) WithdrawCryptocurrencyFunds(ctx context.Context, withdrawRequ
if err := withdrawRequest.Validate(); err != nil {
return nil, err
}
if err := b.PopulateAcceptableMethods(ctx); err != nil {
return nil, err
}
tmpCurr := withdrawRequest.Currency
if tmpCurr == currency.USDT {
// USDT is UST on Bitfinex
tmpCurr = currency.NewCode("UST")
}
methods := acceptableMethods.lookup(tmpCurr)
if len(methods) == 0 {
return nil, errors.New("no transfer methods returned for currency")
}
method := methods[0]
if len(methods) > 1 && withdrawRequest.Crypto.Chain != "" {
if !common.StringDataCompareInsensitive(methods, withdrawRequest.Crypto.Chain) {
return nil, fmt.Errorf("invalid chain %s supplied, %v available", withdrawRequest.Crypto.Chain, methods)
}
method = withdrawRequest.Crypto.Chain
} else if len(methods) > 1 && withdrawRequest.Crypto.Chain == "" {
return nil, fmt.Errorf("a chain must be specified, %s available", methods)
}
// Bitfinex has support for three types, exchange, margin and deposit
// As this is for trading, I've made the wrapper default 'exchange'
// TODO: Discover an automated way to make the decision for wallet type to withdraw from
@@ -757,9 +807,9 @@ func (b *Bitfinex) WithdrawCryptocurrencyFunds(ctx context.Context, withdrawRequ
resp, err := b.WithdrawCryptocurrency(ctx,
walletType,
withdrawRequest.Crypto.Address,
withdrawRequest.Description,
withdrawRequest.Amount,
withdrawRequest.Currency)
withdrawRequest.Crypto.AddressTag,
method,
withdrawRequest.Amount)
if err != nil {
return nil, err
}
@@ -1119,3 +1169,22 @@ func (b *Bitfinex) fixCasing(in currency.Pair, a asset.Item) (string, error) {
runes[0] = unicode.ToLower(runes[0])
return string(runes), nil
}
// GetAvailableTransferChains returns the available transfer blockchains for the specific
// cryptocurrency
func (b *Bitfinex) GetAvailableTransferChains(ctx context.Context, cryptocurrency currency.Code) ([]string, error) {
if err := b.PopulateAcceptableMethods(ctx); err != nil {
return nil, err
}
if cryptocurrency == currency.USDT {
// USDT is UST on Bitfinex
cryptocurrency = currency.NewCode("UST")
}
availChains := acceptableMethods.lookup(cryptocurrency)
if len(availChains) == 0 {
return nil, fmt.Errorf("unable to find any available chains")
}
return availChains, nil
}