diff --git a/Bitfinex.Net.UnitTests/Endpoints/Spot/Account/WithdrawV2.txt b/Bitfinex.Net.UnitTests/Endpoints/Spot/Account/WithdrawV2.txt new file mode 100644 index 0000000..f41bc34 --- /dev/null +++ b/Bitfinex.Net.UnitTests/Endpoints/Spot/Account/WithdrawV2.txt @@ -0,0 +1,23 @@ +POST +/v2/auth/w/withdraw +true +[ + 1568742390999, //MTS + "acc_wd-req", //TYPE + null, //MESSAGE_ID + null, //PLACEHOLDER + [ + 13080092, //WITHDRAWAL_ID + null, //PLACEHOLDER + "ethereum", //METHOD + null, //PAYMENT_ID + "exchange", //WALLET + 0.01, //AMOUNT + null, //PLACEHOLDER + null, //PLACEHOLDER + 0.00135 //WITHDRAWAL_FEE + ], //WITHDRAWAL_ARRAY + null, //CODE + "SUCCESS", //STATUS + "Your withdrawal request has been successfully submitted." //TEXT +] \ No newline at end of file diff --git a/Bitfinex.Net.UnitTests/RestRequestTests.cs b/Bitfinex.Net.UnitTests/RestRequestTests.cs index 2a24588..caa9d4b 100644 --- a/Bitfinex.Net.UnitTests/RestRequestTests.cs +++ b/Bitfinex.Net.UnitTests/RestRequestTests.cs @@ -37,6 +37,7 @@ public async Task ValidateSpotAccountCalls() await tester.ValidateAsync(client => client.SpotApi.Account.GetDepositAddressAsync("123", Enums.WithdrawWallet.Exchange), "GetDepositAddress"); await tester.ValidateAsync(client => client.SpotApi.Account.WalletTransferAsync("ETH", 1, Enums.WithdrawWallet.Exchange, Enums.WithdrawWallet.Exchange), "WalletTransfer"); await tester.ValidateAsync(client => client.SpotApi.Account.WithdrawAsync("ETH", Enums.WithdrawWallet.Exchange, 1), "Withdraw", useSingleArrayItem: true, ignoreProperties: new System.Collections.Generic.List { "status" }); + await tester.ValidateAsync(client => client.SpotApi.Account.WithdrawV2Async("BITCOIN", Enums.WithdrawWallet.Exchange, 1, "123"), "WithdrawV2"); await tester.ValidateAsync(client => client.SpotApi.Account.GetLoginHistoryAsync(), "GetLoginHistory"); await tester.ValidateAsync(client => client.SpotApi.Account.GetApiKeyPermissionsAsync(), "GetApiKeyPermissions"); await tester.ValidateAsync(client => client.SpotApi.Account.GetAccountChangeLogAsync(), "GetAccountChangeLog"); diff --git a/Bitfinex.Net.UnitTests/TestImplementations/TestSocket.cs b/Bitfinex.Net.UnitTests/TestImplementations/TestSocket.cs index 09a9272..398f28f 100644 --- a/Bitfinex.Net.UnitTests/TestImplementations/TestSocket.cs +++ b/Bitfinex.Net.UnitTests/TestImplementations/TestSocket.cs @@ -20,6 +20,7 @@ public class TestSocket: IWebsocket public event Func OnReconnected; public event Func OnReconnecting; public event Func OnRequestRateLimited; + public event Func OnConnectRateLimited; #pragma warning restore 0067 public event Func OnRequestSent; public event Func, Task> OnStreamMessage; diff --git a/Bitfinex.Net/Bitfinex.Net.csproj b/Bitfinex.Net/Bitfinex.Net.csproj index 11a96da..c74943d 100644 --- a/Bitfinex.Net/Bitfinex.Net.csproj +++ b/Bitfinex.Net/Bitfinex.Net.csproj @@ -34,7 +34,7 @@ true - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -55,6 +55,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/Bitfinex.Net/Bitfinex.Net.xml b/Bitfinex.Net/Bitfinex.Net.xml index 6d1acaa..f452940 100644 --- a/Bitfinex.Net/Bitfinex.Net.xml +++ b/Bitfinex.Net/Bitfinex.Net.xml @@ -131,7 +131,7 @@ - + @@ -222,7 +222,7 @@ - + @@ -293,6 +293,9 @@ + + + @@ -500,7 +503,7 @@ - + @@ -1627,9 +1630,13 @@ - Get the ISpotClient for this client. This is a common interface which allows for some basic operations without knowing any details of the exchange. + DEPRECATED; use instead for common/shared functionality. See for more info. + + + + + Get the shared rest requests client. This interface is shared with other exhanges to allow for a common implementation for different exchanges. - @@ -1666,7 +1673,7 @@ Get the withdrawal/deposit history - Symbol to get history for, for example `tETHUSD` + Asset to get history for, for example `ETH` Filter by ids Filter by deposit address Start time of the data to return @@ -1811,6 +1818,22 @@ Cancellation token + + + Withdraw funds + + + Method of withdrawal, methods can be retrieved with ExchangeData.GetAssetDepositWithdrawalMethodsAsync + Wallet type + Quantity to withdraw + Withdrawal address + Invoice (for lightning withdrawals) + Payment id (tag/memo) + When true the fee will be deducted from the withdrawal quantity + Note + Cancellation token + + Get login history @@ -2312,6 +2335,11 @@ Cancellation token + + + Shared interface for Spot rest API usage + + Bitfinex trading endpoints, placing and mananging orders. @@ -2484,6 +2512,11 @@ Bitfinex spot streams + + + Get the shared socket subscription client. This interface is shared with other exhanges to allow for a common implementation for different exchanges. + + Subscribes to ticker updates for a symbol. Use SubscribeToFundingTickerUpdatesAsync for funding symbol ticker updates @@ -2719,6 +2752,11 @@ Id of the offer to cancel + + + Shared interface for Spot socket API usage + + Bitfinex order book factory @@ -4928,6 +4966,11 @@ Amount of time the funding transaction was for + + + Quantity as a positive number + + Transfer info @@ -5148,6 +5191,71 @@ The available balance + + + Withdrawal result + + + + + Timestamp + + + + + Notification type + + + + + Withdrawal info + + + + + Request status + + + + + Message + + + + + Withdrawal info + + + + + Withdrawal id + + + + + Withdrawal method + + + + + Payment id + + + + + Wallet type + + + + + Quantity + + + + + Withdrawal fee + + Result V2. diff --git a/Bitfinex.Net/Clients/GeneralApi/BitfinexRestClientGeneralApi.cs b/Bitfinex.Net/Clients/GeneralApi/BitfinexRestClientGeneralApi.cs index 93659eb..c0ee4c2 100644 --- a/Bitfinex.Net/Clients/GeneralApi/BitfinexRestClientGeneralApi.cs +++ b/Bitfinex.Net/Clients/GeneralApi/BitfinexRestClientGeneralApi.cs @@ -7,6 +7,7 @@ using CryptoExchange.Net.Converters.MessageParsing; using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Objects; +using CryptoExchange.Net.SharedApis; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; using System; @@ -62,7 +63,7 @@ internal Uri GetUrl(string endpoint, string version) } /// - public override string FormatSymbol(string baseAsset, string quoteAsset) => $"t{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; + public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverTime = null) => $"t{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; /// protected override Error ParseErrorResponse(int httpStatusCode, IEnumerable>> responseHeaders, IMessageAccessor accessor) diff --git a/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApi.cs b/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApi.cs index 65f2e94..5aac55e 100644 --- a/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApi.cs +++ b/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApi.cs @@ -10,6 +10,7 @@ using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Interfaces.CommonClients; using CryptoExchange.Net.Objects; +using CryptoExchange.Net.SharedApis; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; using System; @@ -23,7 +24,7 @@ namespace Bitfinex.Net.Clients.SpotApi { /// - internal class BitfinexRestClientSpotApi : RestApiClient, IBitfinexRestClientSpotApi, ISpotClient + internal partial class BitfinexRestClientSpotApi : RestApiClient, IBitfinexRestClientSpotApi, ISpotClient { #region fields internal string? AffiliateCode { get; set; } @@ -72,7 +73,16 @@ protected override AuthenticationProvider CreateAuthenticationProvider(ApiCreden => new BitfinexAuthenticationProvider(credentials, ClientOptions.NonceProvider ?? new BitfinexNonceProvider()); /// - public override string FormatSymbol(string baseAsset, string quoteAsset) => $"t{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; + public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverTime = null) + { + if (baseAsset == "USDT") + baseAsset = "UST"; + + if (quoteAsset == "USDT") + quoteAsset = "UST"; + + return $"t{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; + } #region common interface @@ -150,6 +160,7 @@ internal Uri GetUrl(string endpoint, string version) /// public ISpotClient CommonSpotClient => this; + public IBitfinexRestClientSpotApiShared SharedClient => this; async Task> ISpotClient.PlaceOrderAsync(string symbol, CommonOrderSide side, CommonOrderType type, decimal quantity, decimal? price, string? accountId, string? clientOrderId, CancellationToken ct) { diff --git a/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApiAccount.cs b/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApiAccount.cs index fc47b8e..068d0d2 100644 --- a/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApiAccount.cs +++ b/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApiAccount.cs @@ -49,7 +49,7 @@ public async Task> GetSymbolMarginInfoAsync( } /// - public async Task>> GetMovementsAsync(string? symbol = null, IEnumerable? ids = null, string? address = null, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, CancellationToken ct = default) + public async Task>> GetMovementsAsync(string? asset = null, IEnumerable? ids = null, string? address = null, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, CancellationToken ct = default) { var parameters = new Dictionary(); parameters.AddOptionalParameter("id", ids); @@ -58,7 +58,7 @@ public async Task>> GetMovementsAsyn parameters.AddOptionalParameter("start", DateTimeConverter.ConvertToMilliseconds(startTime)); parameters.AddOptionalParameter("end", DateTimeConverter.ConvertToMilliseconds(endTime)); - var url = _baseClient.GetUrl(symbol == null ? "auth/r/movements/hist" : $"auth/r/movements/{symbol}/hist", "2"); + var url = _baseClient.GetUrl(asset == null ? "auth/r/movements/hist" : $"auth/r/movements/{asset}/hist", "2"); return await _baseClient.SendRequestAsync>(url, HttpMethod.Post, ct, parameters, true).ConfigureAwait(false); } @@ -155,7 +155,7 @@ public async Task>> Ge { "method", method }, { "wallet", JsonConvert.SerializeObject(toWallet, new WithdrawWalletConverter(false)) } }; - parameters.AddOptionalParameter("op_renew", forceNew.HasValue ? JsonConvert.SerializeObject(toWallet, new BoolToIntConverter(false)) : null); + parameters.AddOptionalParameter("op_renew", forceNew == null ? null : forceNew == true ? 1 : 0); return await _baseClient.SendRequestAsync>(_baseClient.GetUrl("auth/w/deposit/address", "2"), HttpMethod.Post, ct, parameters, true).ConfigureAwait(false); } @@ -236,6 +236,33 @@ public async Task> WithdrawAsync(string return result.As(data); } + /// + public async Task> WithdrawV2Async(string method, + WithdrawWallet wallet, + decimal quantity, + string? address = null, + string? invoice = null, + string? paymentId = null, + bool? feeFromWithdrawalAmount = null, + string? note = null, + CancellationToken ct = default) + { + method.ValidateNotNull(nameof(method)); + var parameters = new Dictionary + { + { "method", method }, + { "wallet", JsonConvert.SerializeObject(wallet, new WithdrawWalletConverter(false)) }, + { "amount", quantity.ToString(CultureInfo.InvariantCulture) } + }; + parameters.AddOptionalParameter("address", address); + parameters.AddOptionalParameter("payment_id", paymentId); + parameters.AddOptionalParameter("invoice", invoice); + parameters.AddOptionalParameter("note", note); + parameters.AddOptionalParameter("fee_deduct", feeFromWithdrawalAmount == null ? null : feeFromWithdrawalAmount == true ? 1: 0); + + return await _baseClient.SendRequestAsync(_baseClient.GetUrl("auth/w/withdraw", "2"), HttpMethod.Post, ct, parameters, true).ConfigureAwait(false); + } + /// public async Task>> GetLoginHistoryAsync(DateTime? startTime = null, DateTime? endTime = null, int? limit = null, CancellationToken ct = default) { diff --git a/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApiExchangeData.cs b/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApiExchangeData.cs index c8f5101..0d3c0e5 100644 --- a/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApiExchangeData.cs +++ b/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApiExchangeData.cs @@ -290,7 +290,7 @@ public async Task>> GetTickerHi /// public async Task>> GetTradeHistoryAsync(string symbol, int? limit = null, DateTime? startTime = null, DateTime? endTime = null, Sorting? sorting = null, CancellationToken ct = default) { - limit?.ValidateIntBetween(nameof(limit), 1, 5000); + limit?.ValidateIntBetween(nameof(limit), 1, 10000); var parameters = new Dictionary(); parameters.AddOptionalParameter("limit", limit?.ToString(CultureInfo.InvariantCulture)); parameters.AddOptionalParameter("start", DateTimeConverter.ConvertToMilliseconds(startTime)); diff --git a/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApiShared.cs b/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApiShared.cs new file mode 100644 index 0000000..965fd4f --- /dev/null +++ b/Bitfinex.Net/Clients/SpotApi/BitfinexRestClientSpotApiShared.cs @@ -0,0 +1,774 @@ +using Bitfinex.Net.Enums; +using Bitfinex.Net.Interfaces.Clients.SpotApi; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.SharedApis; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Bitfinex.Net.Clients.SpotApi +{ + internal partial class BitfinexRestClientSpotApi : IBitfinexRestClientSpotApiShared + { + public string Exchange => BitfinexExchange.ExchangeName; + public TradingMode[] SupportedTradingModes { get; } = new[] { TradingMode.Spot }; + public void SetDefaultExchangeParameter(string key, object value) => ExchangeParameters.SetStaticParameter(Exchange, key, value); + public void ResetDefaultExchangeParameters() => ExchangeParameters.ResetStaticParameters(); + + #region Kline client + + GetKlinesOptions IKlineRestClient.GetKlinesOptions { get; } = new GetKlinesOptions(SharedPaginationSupport.Descending, false) + { + MaxRequestDataPoints = 10000 + }; + + async Task>> IKlineRestClient.GetKlinesAsync(GetKlinesRequest request, INextPageToken? pageToken, CancellationToken ct) + { + var validationError = ((IKlineRestClient)this).GetKlinesOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + var interval = (Enums.KlineInterval)request.Interval; + if (!Enum.IsDefined(typeof(Enums.KlineInterval), interval)) + return new ExchangeWebResult>(Exchange, new ArgumentError("Interval not supported")); + + // Determine page token + DateTime? fromTimestamp = null; + if (pageToken is DateTimeToken dateTimeToken) + fromTimestamp = dateTimeToken.LastTime; + + // Get data + var baseAsset = request.Symbol.BaseAsset == "USDT" ? "UST" : request.Symbol.BaseAsset; + var quoteAsset = request.Symbol.QuoteAsset == "USDT" ? "UST" : request.Symbol.QuoteAsset; + + var limit = request.Limit ?? 10000; + var result = await ExchangeData.GetKlinesAsync( + request.Symbol.GetSymbol(FormatSymbol), + interval, + startTime: request.StartTime, + endTime: fromTimestamp ?? request.EndTime?.AddSeconds(-1), + limit: limit, + sorting: Sorting.NewFirst, + ct: ct + ).ConfigureAwait(false); + + if (!result) + return result.AsExchangeResult>(Exchange, null, default); + + // Get next token + DateTimeToken? nextToken = null; + if (result.Data.Count() == limit) + { + var minOpenTime = result.Data.Min(x => x.OpenTime); + nextToken = new DateTimeToken(minOpenTime.AddSeconds(-(int)interval)); + } + + return result.AsExchangeResult>(Exchange, request.Symbol.TradingMode, result.Data.Select(x => new SharedKline(x.OpenTime, x.ClosePrice, x.HighPrice, x.LowPrice, x.OpenPrice, x.Volume)).ToArray(), nextToken); + } + + #endregion + + #region Asset client + EndpointOptions IAssetsRestClient.GetAssetOptions { get; } = new EndpointOptions(false); + async Task> IAssetsRestClient.GetAssetAsync(GetAssetRequest request, CancellationToken ct) + { + var validationError = ((IAssetsRestClient)this).GetAssetOptions.ValidateRequest(Exchange, request, TradingMode.Spot, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult(Exchange, validationError); + + // Execute needed config requests in parallel + var assetSymbols = ExchangeData.GetAssetSymbolsAsync(ct: ct); + var assetList = ExchangeData.GetAssetsListAsync(ct: ct); + var assetMethods = ExchangeData.GetAssetDepositWithdrawalMethodsAsync(ct: ct); + var assetFees = ExchangeData.GetAssetWithdrawalFeesAsync(ct: ct); + var assetTxStatus = ExchangeData.GetDepositWithdrawalStatusAsync(ct: ct); + await Task.WhenAll(assetList, assetMethods).ConfigureAwait(false); + if (!assetSymbols.Result) + return assetSymbols.Result.AsExchangeResult(Exchange, TradingMode.Spot, default); + if (!assetList.Result) + return assetList.Result.AsExchangeResult(Exchange, TradingMode.Spot, default); + if (!assetMethods.Result) + return assetMethods.Result.AsExchangeResult(Exchange, TradingMode.Spot, default); + if (!assetFees.Result) + return assetFees.Result.AsExchangeResult(Exchange, TradingMode.Spot, default); + if (!assetTxStatus.Result) + return assetTxStatus.Result.AsExchangeResult(Exchange, TradingMode.Spot, default); + + var asset = assetList.Result.Data.SingleOrDefault(x => x.Name == request.Asset); + var symbol = assetSymbols.Result.Data.SingleOrDefault(y => y.Key == asset.FullName).Value ?? asset.Name; + var fees = assetFees.Result.Data.SingleOrDefault(y => y.Key.Equals(symbol, StringComparison.OrdinalIgnoreCase)); + + var assetResult = new SharedAsset(symbol) + { + FullName = asset.FullName, + Networks = assetMethods.Result.Data.Where(y => y.Value.Contains(symbol))?.Select(x => + { + var status = assetTxStatus.Result.Data.Single(s => s.Method.Equals(x.Key, StringComparison.OrdinalIgnoreCase)); + return new SharedAssetNetwork(x.Key) + { + WithdrawFee = fees.Value?.Skip(1).First(), + DepositEnabled = status.DepositStatus, + WithdrawEnabled = status.WithdrawalStatus, + MinConfirmations = status.DepositConfirmations + }; + }).ToList() + }; + + + return assetList.Result.AsExchangeResult(Exchange, TradingMode.Spot, assetResult); + } + + EndpointOptions IAssetsRestClient.GetAssetsOptions { get; } = new EndpointOptions(false); + + async Task>> IAssetsRestClient.GetAssetsAsync(GetAssetsRequest request, CancellationToken ct) + { + var validationError = ((IAssetsRestClient)this).GetAssetsOptions.ValidateRequest(Exchange, request, TradingMode.Spot, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + // Execute needed config requests in parallel + var assetSymbols = ExchangeData.GetAssetSymbolsAsync(ct: ct); + var assetList = ExchangeData.GetAssetsListAsync(ct: ct); + var assetMethods = ExchangeData.GetAssetDepositWithdrawalMethodsAsync(ct: ct); + var assetFees = ExchangeData.GetAssetWithdrawalFeesAsync(ct: ct); + var assetTxStatus = ExchangeData.GetDepositWithdrawalStatusAsync(ct: ct); + await Task.WhenAll(assetList, assetMethods).ConfigureAwait(false); + if (!assetSymbols.Result) + return assetSymbols.Result.AsExchangeResult>(Exchange, null, default); + if (!assetList.Result) + return assetList.Result.AsExchangeResult>(Exchange, null, default); + if (!assetMethods.Result) + return assetMethods.Result.AsExchangeResult>(Exchange, null, default); + if (!assetFees.Result) + return assetFees.Result.AsExchangeResult>(Exchange, null, default); + if (!assetTxStatus.Result) + return assetTxStatus.Result.AsExchangeResult>(Exchange, null, default); + + return assetList.Result.AsExchangeResult>(Exchange, TradingMode.Spot, + assetList.Result.Data.Select(x => + { + var symbol = assetSymbols.Result.Data.SingleOrDefault(y => y.Key == x.FullName).Value ?? x.Name; + var fees = assetFees.Result.Data.SingleOrDefault(y => y.Key.Equals(symbol, StringComparison.OrdinalIgnoreCase)); + if (fees.Key == null) + return null; + + return new SharedAsset(symbol) + { + FullName = x.FullName, + Networks = assetMethods.Result.Data.Where(y => y.Value.Contains(symbol))?.Select(x => + { + var status = assetTxStatus.Result.Data.Single(s => s.Method.Equals(x.Key, StringComparison.OrdinalIgnoreCase)); + return new SharedAssetNetwork(x.Key) + { + WithdrawFee = fees.Value.Skip(1).First(), + DepositEnabled = status.DepositStatus, + WithdrawEnabled = status.WithdrawalStatus, + MinConfirmations = status.DepositConfirmations + }; + }).ToArray() + }; + }).Where(x => x != null).ToArray()! + ); + } + + #endregion + + #region Spot Symbol client + + EndpointOptions ISpotSymbolRestClient.GetSpotSymbolsOptions { get; } = new EndpointOptions(false); + async Task>> ISpotSymbolRestClient.GetSpotSymbolsAsync(GetSymbolsRequest request, CancellationToken ct) + { + var validationError = ((ISpotSymbolRestClient)this).GetSpotSymbolsOptions.ValidateRequest(Exchange, request, TradingMode.Spot, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + var result = await ExchangeData.GetSymbolsAsync(ct: ct).ConfigureAwait(false); + if (!result) + return result.AsExchangeResult>(Exchange, null, default); + + return result.AsExchangeResult>(Exchange, TradingMode.Spot, result.Data.Select(s => + { + var assets = GetAssets(s.Key); + return new SharedSpotSymbol(assets.BaseAsset, assets.QuoteAsset, "t" + s.Key, true) + { + MinTradeQuantity = s.Value.MinOrderQuantity, + MaxTradeQuantity = s.Value.MaxOrderQuantity + }; + }).ToArray()); + + } + + private (string BaseAsset, string QuoteAsset) GetAssets(string input) + { + if (input.Contains(":")) + { + var split = input.Split(':'); + return (split[0], split[1]); + } + + return (input.Substring(0, 3), input.Substring(3)); + } + + #endregion + + #region Ticker client + + EndpointOptions ISpotTickerRestClient.GetSpotTickerOptions { get; } = new EndpointOptions(false); + async Task> ISpotTickerRestClient.GetSpotTickerAsync(GetTickerRequest request, CancellationToken ct) + { + var validationError = ((ISpotTickerRestClient)this).GetSpotTickerOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult(Exchange, validationError); + + var baseAsset = request.Symbol.BaseAsset == "USDT" ? "UST" : request.Symbol.BaseAsset; + var quoteAsset = request.Symbol.QuoteAsset == "USDT" ? "UST" : request.Symbol.QuoteAsset; + + var result = await ExchangeData.GetTickerAsync(request.Symbol.GetSymbol(FormatSymbol), ct).ConfigureAwait(false); + if (!result) + return result.AsExchangeResult(Exchange, null, default); + + return result.AsExchangeResult(Exchange, TradingMode.Spot, new SharedSpotTicker(result.Data.Symbol, result.Data.LastPrice, result.Data.HighPrice, result.Data.LowPrice, result.Data.Volume, Math.Round(result.Data.DailyChangePercentage * 100, 2))); + } + + EndpointOptions ISpotTickerRestClient.GetSpotTickersOptions { get; } = new EndpointOptions(false); + async Task>> ISpotTickerRestClient.GetSpotTickersAsync(GetTickersRequest request, CancellationToken ct) + { + var validationError = ((ISpotTickerRestClient)this).GetSpotTickersOptions.ValidateRequest(Exchange, request, request.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + var result = await ExchangeData.GetTickersAsync(ct: ct).ConfigureAwait(false); + if (!result) + return result.AsExchangeResult>(Exchange, null, default); + + return result.AsExchangeResult>(Exchange, TradingMode.Spot, result.Data.Select(x => new SharedSpotTicker(x.Symbol, x.LastPrice, x.HighPrice, x.LowPrice, x.Volume, Math.Round(x.DailyChangePercentage * 100, 2))).ToArray()); + } + + #endregion + + #region Recent Trade client + + GetRecentTradesOptions IRecentTradeRestClient.GetRecentTradesOptions { get; } = new GetRecentTradesOptions(10000, false); + + async Task>> IRecentTradeRestClient.GetRecentTradesAsync(GetRecentTradesRequest request, CancellationToken ct) + { + var validationError = ((IRecentTradeRestClient)this).GetRecentTradesOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + var result = await ExchangeData.GetTradeHistoryAsync( + request.Symbol.GetSymbol(FormatSymbol), + limit: request.Limit, + ct: ct).ConfigureAwait(false); + if (!result) + return result.AsExchangeResult>(Exchange, null, default); + + return result.AsExchangeResult>(Exchange, request.Symbol.TradingMode, result.Data.Select(x => new SharedTrade(Math.Abs(x.Quantity), x.Price, x.Timestamp)).ToArray()); + } + + #endregion + + #region Balance client + EndpointOptions IBalanceRestClient.GetBalancesOptions { get; } = new EndpointOptions(true); + + async Task>> IBalanceRestClient.GetBalancesAsync(GetBalancesRequest request, CancellationToken ct) + { + var validationError = ((IBalanceRestClient)this).GetBalancesOptions.ValidateRequest(Exchange, request, request.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + var result = await Account.GetBalancesAsync(ct: ct).ConfigureAwait(false); + if (!result) + return result.AsExchangeResult>(Exchange, null, default); + + return result.AsExchangeResult>(Exchange, SupportedTradingModes, result.Data.Select(x => new SharedBalance(x.Asset, x.Available ?? 0, x.Total)).ToArray()); + } + + #endregion + + #region Spot Order client + + SharedFeeDeductionType ISpotOrderRestClient.SpotFeeDeductionType => SharedFeeDeductionType.DeductFromOutput; + SharedFeeAssetType ISpotOrderRestClient.SpotFeeAssetType => SharedFeeAssetType.OutputAsset; + IEnumerable ISpotOrderRestClient.SpotSupportedOrderTypes { get; } = new[] { SharedOrderType.Limit, SharedOrderType.Market }; + IEnumerable ISpotOrderRestClient.SpotSupportedTimeInForce { get; } = new[] { SharedTimeInForce.GoodTillCanceled, SharedTimeInForce.ImmediateOrCancel, SharedTimeInForce.FillOrKill }; + SharedQuantitySupport ISpotOrderRestClient.SpotSupportedOrderQuantity { get; } = new SharedQuantitySupport( + SharedQuantityType.BaseAsset, + SharedQuantityType.BaseAsset, + SharedQuantityType.BaseAsset, + SharedQuantityType.BaseAsset); + + PlaceSpotOrderOptions ISpotOrderRestClient.PlaceSpotOrderOptions { get; } = new PlaceSpotOrderOptions(); + async Task> ISpotOrderRestClient.PlaceSpotOrderAsync(PlaceSpotOrderRequest request, CancellationToken ct) + { + var validationError = ((ISpotOrderRestClient)this).PlaceSpotOrderOptions.ValidateRequest( + Exchange, + request, + request.Symbol.TradingMode, + SupportedTradingModes, + ((ISpotOrderRestClient)this).SpotSupportedOrderTypes, + ((ISpotOrderRestClient)this).SpotSupportedTimeInForce, + ((ISpotOrderRestClient)this).SpotSupportedOrderQuantity); + if (validationError != null) + return new ExchangeWebResult(Exchange, validationError); + + int clientOrderId = 0; + if (request.ClientOrderId != null && !int.TryParse(request.ClientOrderId, out clientOrderId)) + return new ExchangeWebResult(Exchange, new ArgumentError("ClientOrderId needs to be parsable to `int` for `Bitfinex`")); + + var result = await Trading.PlaceOrderAsync( + request.Symbol.GetSymbol(FormatSymbol), + request.Side == SharedOrderSide.Buy ? Enums.OrderSide.Buy : Enums.OrderSide.Sell, + GetPlaceOrderType(request.OrderType, request.TimeInForce), + quantity: request.Quantity ?? 0, + flags: request.OrderType == SharedOrderType.LimitMaker ? Enums.OrderFlags.PostOnly : null, + price: request.Price ?? 0, + clientOrderId: request.ClientOrderId != null ? clientOrderId: null, + ct: ct).ConfigureAwait(false); + + if (!result) + return result.AsExchangeResult(Exchange, null, default); + + return result.AsExchangeResult(Exchange, request.Symbol.TradingMode, new SharedId(result.Data.Data!.Id.ToString())); + } + + EndpointOptions ISpotOrderRestClient.GetSpotOrderOptions { get; } = new EndpointOptions(true); + async Task> ISpotOrderRestClient.GetSpotOrderAsync(GetOrderRequest request, CancellationToken ct) + { + var validationError = ((ISpotOrderRestClient)this).GetSpotOrderOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult(Exchange, validationError); + + if (!long.TryParse(request.OrderId, out var orderId)) + return new ExchangeWebResult(Exchange, new ArgumentError("Invalid order id")); + + var symbol = request.Symbol.GetSymbol(FormatSymbol); + var result = await Trading.GetOpenOrdersAsync(symbol, new[] { orderId }, ct: ct).ConfigureAwait(false); + if (!result) + return result.AsExchangeResult(Exchange, null, null); + + if (!result.Data.Any()) + result = await Trading.GetClosedOrdersAsync(symbol, new[] { orderId }, ct: ct).ConfigureAwait(false); + + if (!result.Data.Any()) + return result.AsExchangeError(Exchange, new ServerError($"Order with id {orderId} not found")); + + var order = result.Data.Single(); + return result.AsExchangeResult(Exchange, TradingMode.Spot, new SharedSpotOrder( + order.Symbol, + order.Id.ToString(), + ParseOrderType(order.Type, order.Flags), + order.Side == OrderSide.Buy ? SharedOrderSide.Buy : SharedOrderSide.Sell, + ParseOrderStatus(order.Status), + order.CreateTime) + { + ClientOrderId = order.ClientOrderId?.ToString(), + AveragePrice = order.PriceAverage == 0 ? null : order.PriceAverage, + OrderPrice = order.Price, + Quantity = order.Quantity, + QuantityFilled = order.Quantity - order.QuantityRemaining, + TimeInForce = ParseTimeInForce(order.Type, order.Flags), + UpdateTime = order.UpdateTime + }); + } + + EndpointOptions ISpotOrderRestClient.GetOpenSpotOrdersOptions { get; } = new EndpointOptions(true); + async Task>> ISpotOrderRestClient.GetOpenSpotOrdersAsync(GetOpenOrdersRequest request, CancellationToken ct) + { + var validationError = ((ISpotOrderRestClient)this).GetOpenSpotOrdersOptions.ValidateRequest(Exchange, request, request.Symbol?.TradingMode ?? request.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + var symbol = request.Symbol?.GetSymbol(FormatSymbol); + var result = await Trading.GetOpenOrdersAsync(symbol, ct: ct).ConfigureAwait(false); + if (!result) + return result.AsExchangeResult>(Exchange, null, null); + + return result.AsExchangeResult>(Exchange, TradingMode.Spot, result.Data.Select(x => new SharedSpotOrder( + x.Symbol, + x.Id.ToString(), + ParseOrderType(x.Type, x.Flags), + x.Side == OrderSide.Buy ? SharedOrderSide.Buy : SharedOrderSide.Sell, + ParseOrderStatus(x.Status), + x.CreateTime) + { + ClientOrderId = x.ClientOrderId?.ToString(), + AveragePrice = x.PriceAverage == 0 ? null : x.PriceAverage, + OrderPrice = x.Price, + Quantity = x.Quantity, + QuantityFilled = x.Quantity - x.QuantityRemaining, + TimeInForce = ParseTimeInForce(x.Type, x.Flags), + UpdateTime = x.UpdateTime + }).ToArray()); + } + + PaginatedEndpointOptions ISpotOrderRestClient.GetClosedSpotOrdersOptions { get; } = new PaginatedEndpointOptions(SharedPaginationSupport.Descending, true); + + async Task>> ISpotOrderRestClient.GetClosedSpotOrdersAsync(GetClosedOrdersRequest request, INextPageToken? pageToken, CancellationToken ct) + { + var validationError = ((ISpotOrderRestClient)this).GetClosedSpotOrdersOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + // Determine page token + DateTime? fromTimestamp = null; + if (pageToken is DateTimeToken dateTimeToken) + fromTimestamp = dateTimeToken.LastTime; + + // Get data + var limit = request.Limit ?? 100; + var result = await Trading.GetClosedOrdersAsync(request.Symbol.GetSymbol(FormatSymbol), + startTime: request.StartTime, + endTime: fromTimestamp ?? request.EndTime, + limit: limit, + ct: ct).ConfigureAwait(false); + if (!result) + return result.AsExchangeResult>(Exchange, null, null); + + // Get next token + DateTimeToken? nextToken = null; + if (result.Data.Count() == limit) + nextToken = new DateTimeToken(result.Data.Min(o => o.CreateTime).AddMilliseconds(-1)); + + return result.AsExchangeResult>(Exchange, TradingMode.Spot, result.Data.Select(x => new SharedSpotOrder( + x.Symbol, + x.Id.ToString(), + ParseOrderType(x.Type, x.Flags), + x.Side == OrderSide.Buy ? SharedOrderSide.Buy : SharedOrderSide.Sell, + ParseOrderStatus(x.Status), + x.CreateTime) + { + ClientOrderId = x.ClientOrderId?.ToString(), + AveragePrice = x.PriceAverage == 0 ? null : x.PriceAverage, + OrderPrice = x.Price, + Quantity = x.Quantity, + QuantityFilled = x.Quantity - x.QuantityRemaining, + TimeInForce = ParseTimeInForce(x.Type, x.Flags), + UpdateTime = x.UpdateTime + }).ToArray(), nextToken); + } + + EndpointOptions ISpotOrderRestClient.GetSpotOrderTradesOptions { get; } = new EndpointOptions(true); + async Task>> ISpotOrderRestClient.GetSpotOrderTradesAsync(GetOrderTradesRequest request, CancellationToken ct) + { + var validationError = ((ISpotOrderRestClient)this).GetSpotOrderTradesOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + if (!long.TryParse(request.OrderId, out var orderId)) + return new ExchangeWebResult>(Exchange, new ArgumentError("Invalid order id")); + + var order = await Trading.GetOrderTradesAsync( + request.Symbol.GetSymbol(FormatSymbol), orderId, ct: ct).ConfigureAwait(false); + if (!order) + return order.AsExchangeResult>(Exchange, null, default); + + return order.AsExchangeResult>(Exchange, TradingMode.Spot, order.Data.Select(x => new SharedUserTrade( + x.Symbol, + x.OrderId.ToString(), + x.Id.ToString(), + x.QuantityRaw > 0 ? SharedOrderSide.Buy : SharedOrderSide.Sell, + x.Quantity, + x.Price, + x.Timestamp) + { + Fee = Math.Abs(x.Fee), + FeeAsset = x.FeeAsset, + Role = x.Maker == true ? SharedRole.Maker : x.Maker == false ? SharedRole.Taker : null + }).ToArray()); + } + + PaginatedEndpointOptions ISpotOrderRestClient.GetSpotUserTradesOptions { get; } = new PaginatedEndpointOptions(SharedPaginationSupport.Descending, true); + async Task>> ISpotOrderRestClient.GetSpotUserTradesAsync(GetUserTradesRequest request, INextPageToken? pageToken, CancellationToken ct) + { + var validationError = ((ISpotOrderRestClient)this).GetSpotUserTradesOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + // Determine page token + DateTime? fromTimestamp = null; + if (pageToken is DateTimeToken dateTimeToken) + fromTimestamp = dateTimeToken.LastTime; + + // Get data + var limit = request.Limit ?? 1000; + var order = await Trading.GetUserTradesAsync( + request.Symbol.GetSymbol(FormatSymbol), + startTime: request.StartTime, + endTime: fromTimestamp ?? request.EndTime, + limit: limit, + ct: ct).ConfigureAwait(false); + if (!order) + return order.AsExchangeResult>(Exchange, null, default); + + // Get next token + DateTimeToken? nextToken = null; + if (order.Data.Count() == limit) + nextToken = new DateTimeToken(order.Data.Min(o => o.Timestamp).AddMilliseconds(-1)); + + return order.AsExchangeResult>(Exchange, TradingMode.Spot, order.Data.Select(x => new SharedUserTrade( + x.Symbol, + x.OrderId.ToString(), + x.Id.ToString(), + x.QuantityRaw > 0 ? SharedOrderSide.Buy : SharedOrderSide.Sell, + x.Quantity, + x.Price, + x.Timestamp) + { + Fee = Math.Abs(x.Fee), + FeeAsset = x.FeeAsset, + Role = x.Maker == true ? SharedRole.Maker : x.Maker == false ? SharedRole.Taker : null + }).ToArray(), nextToken); + } + + EndpointOptions ISpotOrderRestClient.CancelSpotOrderOptions { get; } = new EndpointOptions(true); + async Task> ISpotOrderRestClient.CancelSpotOrderAsync(CancelOrderRequest request, CancellationToken ct) + { + var validationError = ((ISpotOrderRestClient)this).CancelSpotOrderOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult(Exchange, validationError); + + if (!long.TryParse(request.OrderId, out var orderId)) + return new ExchangeWebResult(Exchange, new ArgumentError("Invalid order id")); + + var order = await Trading.CancelOrderAsync(orderId, ct: ct).ConfigureAwait(false); + if (!order) + return order.AsExchangeResult(Exchange, null, default); + + return order.AsExchangeResult(Exchange, request.Symbol.TradingMode, new SharedId(order.Data.ToString())); + } + + private SharedOrderStatus ParseOrderStatus(Enums.OrderStatus status) + { + if (status == Enums.OrderStatus.Active || status == Enums.OrderStatus.PartiallyFilled) return SharedOrderStatus.Open; + if (status == Enums.OrderStatus.Canceled) return SharedOrderStatus.Canceled; + return SharedOrderStatus.Filled; + } + + private SharedOrderType ParseOrderType(Enums.OrderType type, OrderFlags? flags) + { + if (type == Enums.OrderType.ExchangeMarket) return SharedOrderType.Market; + if (type == Enums.OrderType.ExchangeLimit && (flags != null && flags.Value.HasFlag(OrderFlags.PostOnly))) return SharedOrderType.LimitMaker; + if (type == Enums.OrderType.ExchangeLimit) return SharedOrderType.Limit; + if (type == Enums.OrderType.ExchangeFillOrKill) return SharedOrderType.Limit; + if (type == Enums.OrderType.ExchangeImmediateOrCancel) return SharedOrderType.Limit; + + return SharedOrderType.Other; + } + + private SharedTimeInForce? ParseTimeInForce(Enums.OrderType type, OrderFlags? flags) + { + if (type == OrderType.ExchangeFillOrKill) return SharedTimeInForce.FillOrKill; + if (type == OrderType.ExchangeImmediateOrCancel) return SharedTimeInForce.ImmediateOrCancel; + + return null; + } + + private Enums.OrderType GetPlaceOrderType(SharedOrderType type, SharedTimeInForce? tif) + { + if ((type == SharedOrderType.Limit || type == SharedOrderType.LimitMaker) && (tif == null || tif == SharedTimeInForce.GoodTillCanceled)) return Enums.OrderType.ExchangeLimit; + if (type == SharedOrderType.Limit && tif == SharedTimeInForce.ImmediateOrCancel) return Enums.OrderType.ExchangeImmediateOrCancel; + if (type == SharedOrderType.Limit && tif == SharedTimeInForce.FillOrKill) return Enums.OrderType.ExchangeFillOrKill; + if (type == SharedOrderType.Market) return Enums.OrderType.ExchangeMarket; + + throw new ArgumentException($"The combination of order type `{type}` and time in force `{tif}` in invalid"); + } + + #endregion + + #region Deposit client + + EndpointOptions IDepositRestClient.GetDepositAddressesOptions { get; } = new EndpointOptions(true) + { + RequiredOptionalParameters = new List + { + new ParameterDescription(nameof(GetDepositAddressesRequest.Network), typeof(string), "The network the deposit address should be for", "bitcoin") + } + }; + + async Task>> IDepositRestClient.GetDepositAddressesAsync(GetDepositAddressesRequest request, CancellationToken ct) + { + var validationError = ((IDepositRestClient)this).GetDepositAddressesOptions.ValidateRequest(Exchange, request, TradingMode.Spot, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + var depositAddresses = await Account.GetDepositAddressAsync(request.Network!, WithdrawWallet.Deposit, false, ct).ConfigureAwait(false); + if (!depositAddresses) + return depositAddresses.AsExchangeResult>(Exchange, null, default); + + return depositAddresses.AsExchangeResult>(Exchange, TradingMode.Spot, new[] { new SharedDepositAddress(depositAddresses.Data.Data!.Asset, !string.IsNullOrEmpty(depositAddresses.Data.Data.PoolAddress) ? depositAddresses.Data.Data.PoolAddress : depositAddresses.Data.Data.Address) + { + Network = request.Network, + TagOrMemo = !string.IsNullOrEmpty(depositAddresses.Data.Data.PoolAddress) ? depositAddresses.Data.Data.Address : null, + } + }); + } + + GetDepositsOptions IDepositRestClient.GetDepositsOptions { get; } = new GetDepositsOptions(SharedPaginationSupport.Descending, true); + async Task>> IDepositRestClient.GetDepositsAsync(GetDepositsRequest request, INextPageToken? pageToken, CancellationToken ct) + { + var validationError = ((IDepositRestClient)this).GetDepositsOptions.ValidateRequest(Exchange, request, TradingMode.Spot, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + // Determine page token + DateTime? offset = null; + if (pageToken is DateTimeToken timeToken) + offset = timeToken.LastTime; + + // Get data + var limit = request.Limit ?? 1000; + var deposits = await Account.GetMovementsAsync( + request.Asset, + startTime: request.StartTime, + endTime: offset ?? request.EndTime, + limit: limit, + ct: ct).ConfigureAwait(false); + if (!deposits) + return deposits.AsExchangeResult>(Exchange, null, default); + + var data = deposits.Data.Where(x => x.Quantity < 0); + + // Determine next token + DateTimeToken? nextToken = null; + if (deposits.Data.Count() == limit) + nextToken = new DateTimeToken(deposits.Data.Min(x => x.StartTime)); + + return deposits.AsExchangeResult>(Exchange, TradingMode.Spot, data.Where(x => x.Quantity > 0).Select(x => new SharedDeposit(x.Asset, x.Quantity, x.Status == "COMPLETED", x.StartTime) + { + Id = x.Id, + TransactionId = x.TransactionId + }).ToArray(), nextToken); + } + + #endregion + + #region Order Book client + GetOrderBookOptions IOrderBookRestClient.GetOrderBookOptions { get; } = new GetOrderBookOptions(new[] { 1, 25, 100 }, false); + async Task> IOrderBookRestClient.GetOrderBookAsync(GetOrderBookRequest request, CancellationToken ct) + { + var validationError = ((IOrderBookRestClient)this).GetOrderBookOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult(Exchange, validationError); + + var result = await ExchangeData.GetOrderBookAsync( + request.Symbol.GetSymbol(FormatSymbol), + Precision.PrecisionLevel0, + limit: request.Limit, + ct: ct).ConfigureAwait(false); + if (!result) + return result.AsExchangeResult(Exchange, null, default); + + return result.AsExchangeResult(Exchange, request.Symbol.TradingMode, new SharedOrderBook(result.Data.Asks, result.Data.Bids)); + } + #endregion + + #region Trade History client + + GetTradeHistoryOptions ITradeHistoryRestClient.GetTradeHistoryOptions { get; } = new GetTradeHistoryOptions(SharedPaginationSupport.Descending, false); + async Task>> ITradeHistoryRestClient.GetTradeHistoryAsync(GetTradeHistoryRequest request, INextPageToken? pageToken, CancellationToken ct) + { + var validationError = ((ITradeHistoryRestClient)this).GetTradeHistoryOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + // Determine page token + DateTime? fromTimestamp = null; + if (pageToken is DateTimeToken dateTimeToken) + fromTimestamp = dateTimeToken.LastTime; + + // Get data + var limit = request.Limit ?? 10000; + var result = await ExchangeData.GetTradeHistoryAsync( + request.Symbol.GetSymbol(FormatSymbol), + startTime: request.StartTime, + endTime: fromTimestamp ?? request.EndTime, + limit: limit, + sorting: Sorting.NewFirst, + ct: ct).ConfigureAwait(false); + if (!result) + return result.AsExchangeResult>(Exchange, null, default); + + // Get next token + DateTimeToken? nextToken = null; + if (result.Data.Count() == limit) + nextToken = new DateTimeToken(result.Data.Min(o => o.Timestamp.AddMilliseconds(-1))); + + // Return + return result.AsExchangeResult>(Exchange, request.Symbol.TradingMode, result.Data./*Where(x => x. < request.EndTime).*/Select(x => new SharedTrade(Math.Abs(x.Quantity), x.Price, x.Timestamp)).ToArray(), nextToken); + } + #endregion + + #region Withdrawal client + + GetWithdrawalsOptions IWithdrawalRestClient.GetWithdrawalsOptions { get; } = new GetWithdrawalsOptions(SharedPaginationSupport.Descending, true); + async Task>> IWithdrawalRestClient.GetWithdrawalsAsync(GetWithdrawalsRequest request, INextPageToken? pageToken, CancellationToken ct) + { + var validationError = ((IWithdrawalRestClient)this).GetWithdrawalsOptions.ValidateRequest(Exchange, request, TradingMode.Spot, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult>(Exchange, validationError); + + // Determine page token + DateTime? offset = null; + if (pageToken is DateTimeToken timeToken) + offset = timeToken.LastTime; + + // Get data + var limit = request.Limit ?? 1000; + var withdrawals = await Account.GetMovementsAsync( + request.Asset, + startTime: request.StartTime, + endTime: offset ?? request.EndTime, + limit: limit, + ct: ct).ConfigureAwait(false); + if (!withdrawals) + return withdrawals.AsExchangeResult>(Exchange, null, default); + + var data = withdrawals.Data.Where(x => x.Quantity < 0); + + // Determine next token + DateTimeToken? nextToken = null; + if (withdrawals.Data.Count() == limit) + nextToken = new DateTimeToken(withdrawals.Data.Min(x => x.StartTime)); + + return withdrawals.AsExchangeResult>(Exchange, TradingMode.Spot, data.Select(x => new SharedWithdrawal(x.Asset, x.Address, Math.Abs(x.Quantity), x.Status == "COMPLETED", x.StartTime) + { + Id = x.Id, + TransactionId = x.TransactionId + }).ToArray(), nextToken); + } + + #endregion + + #region Withdraw client + + WithdrawOptions IWithdrawRestClient.WithdrawOptions { get; } = new WithdrawOptions(); + + async Task> IWithdrawRestClient.WithdrawAsync(WithdrawRequest request, CancellationToken ct) + { + if (string.IsNullOrEmpty(request.Network)) + return new ExchangeWebResult(Exchange, new ArgumentError("Network is required")); + + var validationError = ((IWithdrawRestClient)this).WithdrawOptions.ValidateRequest(Exchange, request, TradingMode.Spot, SupportedTradingModes); + if (validationError != null) + return new ExchangeWebResult(Exchange, validationError); + + // Get data + var withdrawal = await Account.WithdrawV2Async( + request.Network!, + WithdrawWallet.Deposit, + request.Quantity, + address: request.Address, + paymentId: request.AddressTag, + ct: ct).ConfigureAwait(false); + if (!withdrawal) + return withdrawal.AsExchangeResult(Exchange, null, default); + + return withdrawal.AsExchangeResult(Exchange, TradingMode.Spot, new SharedId(withdrawal.Data.Data.WithdrawalId.ToString())); + } + + #endregion + } +} diff --git a/Bitfinex.Net/Clients/SpotApi/BitfinexSocketClientSpotApi.cs b/Bitfinex.Net/Clients/SpotApi/BitfinexSocketClientSpotApi.cs index 8b79e5c..bcde625 100644 --- a/Bitfinex.Net/Clients/SpotApi/BitfinexSocketClientSpotApi.cs +++ b/Bitfinex.Net/Clients/SpotApi/BitfinexSocketClientSpotApi.cs @@ -11,7 +11,6 @@ using Bitfinex.Net.Objects.Models.Socket; using Bitfinex.Net.Interfaces.Clients.SpotApi; using Bitfinex.Net.Objects.Options; -using CryptoExchange.Net.Converters; using CryptoExchange.Net.Objects.Sockets; using Bitfinex.Net.Objects.Sockets.Subscriptions; using Bitfinex.Net.Objects.Models; @@ -21,15 +20,15 @@ using CryptoExchange.Net.Sockets; using System.Globalization; using Bitfinex.Net.Objects.Sockets.Queries; -using Bitfinex.Net.ExtensionMethods; using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Converters.MessageParsing; using CryptoExchange.Net.Clients; +using CryptoExchange.Net.SharedApis; namespace Bitfinex.Net.Clients.SpotApi { /// - internal class BitfinexSocketClientSpotApi : SocketApiClient, IBitfinexSocketClientSpotApi + internal partial class BitfinexSocketClientSpotApi : SocketApiClient, IBitfinexSocketClientSpotApi { private static readonly MessagePath _0Path = MessagePath.Get().Index(0); private static readonly MessagePath _eventPath = MessagePath.Get().Property("event"); @@ -71,7 +70,18 @@ protected override AuthenticationProvider CreateAuthenticationProvider(ApiCreden => new BitfinexAuthenticationProvider(credentials, ClientOptions.NonceProvider ?? new BitfinexNonceProvider()); /// - public override string FormatSymbol(string baseAsset, string quoteAsset) => $"t{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; + public override string FormatSymbol(string baseAsset, string quoteAsset, TradingMode tradingMode, DateTime? deliverTime = null) + { + if (baseAsset == "USDT") + baseAsset = "UST"; + + if (quoteAsset == "USDT") + quoteAsset = "UST"; + + return $"t{baseAsset.ToUpperInvariant()}{quoteAsset.ToUpperInvariant()}"; + } + + public IBitfinexSocketClientSpotApiShared SharedClient => this; /// protected override Query GetAuthenticationRequest(SocketConnection connection) diff --git a/Bitfinex.Net/Clients/SpotApi/BitfinexSocketClientSpotApiShared.cs b/Bitfinex.Net/Clients/SpotApi/BitfinexSocketClientSpotApiShared.cs new file mode 100644 index 0000000..f6ebff3 --- /dev/null +++ b/Bitfinex.Net/Clients/SpotApi/BitfinexSocketClientSpotApiShared.cs @@ -0,0 +1,185 @@ +using Bitfinex.Net.Interfaces.Clients.SpotApi; +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Objects.Sockets; +using CryptoExchange.Net.SharedApis; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Bitfinex.Net.Clients.SpotApi +{ + internal partial class BitfinexSocketClientSpotApi : IBitfinexSocketClientSpotApiShared + { + public string Exchange => BitfinexExchange.ExchangeName; + public TradingMode[] SupportedTradingModes { get; } = new[] { TradingMode.Spot }; + + public void SetDefaultExchangeParameter(string key, object value) => ExchangeParameters.SetStaticParameter(Exchange, key, value); + public void ResetDefaultExchangeParameters() => ExchangeParameters.ResetStaticParameters(); + + #region Ticker client + EndpointOptions ITickerSocketClient.SubscribeTickerOptions { get; } = new EndpointOptions(false); + async Task> ITickerSocketClient.SubscribeToTickerUpdatesAsync(SubscribeTickerRequest request, Action> handler, CancellationToken ct) + { + var validationError = ((ITickerSocketClient)this).SubscribeTickerOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeResult(Exchange, validationError); + + var symbol = request.Symbol.GetSymbol(FormatSymbol); + var result = await SubscribeToTickerUpdatesAsync(symbol, update => handler(update.AsExchangeEvent(Exchange, new SharedSpotTicker(symbol, update.Data.LastPrice, update.Data.HighPrice, update.Data.LowPrice, update.Data.Volume, Math.Round(update.Data.DailyChangePercentage * 100, 2)))), ct).ConfigureAwait(false); + + return new ExchangeResult(Exchange, result); + } + #endregion + + #region Trade client + + EndpointOptions ITradeSocketClient.SubscribeTradeOptions { get; } = new EndpointOptions(false); + async Task> ITradeSocketClient.SubscribeToTradeUpdatesAsync(SubscribeTradeRequest request, Action>> handler, CancellationToken ct) + { + var validationError = ((ITradeSocketClient)this).SubscribeTradeOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeResult(Exchange, validationError); + + var symbol = request.Symbol.GetSymbol(FormatSymbol); + var result = await SubscribeToTradeUpdatesAsync(symbol, update => + { + if (update.UpdateType == SocketUpdateType.Snapshot) + return; + + handler(update.AsExchangeEvent>(Exchange, update.Data.Select(x => new SharedTrade(x.QuantityAbs, x.Price, x.Timestamp)).ToArray())); + }, ct).ConfigureAwait(false); + + return new ExchangeResult(Exchange, result); + } + #endregion + + #region Book Ticker client + + EndpointOptions IBookTickerSocketClient.SubscribeBookTickerOptions { get; } = new EndpointOptions(false); + async Task> IBookTickerSocketClient.SubscribeToBookTickerUpdatesAsync(SubscribeBookTickerRequest request, Action> handler, CancellationToken ct) + { + var validationError = ((IBookTickerSocketClient)this).SubscribeBookTickerOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeResult(Exchange, validationError); + + var symbol = request.Symbol.GetSymbol(FormatSymbol); + var result = await SubscribeToTickerUpdatesAsync(symbol, update => handler(update.AsExchangeEvent(Exchange, new SharedBookTicker(update.Data.BestAskPrice, update.Data.BestAskQuantity, update.Data.BestBidPrice, update.Data.BestBidQuantity))), ct).ConfigureAwait(false); + + return new ExchangeResult(Exchange, result); + } + #endregion + + #region Balance client + EndpointOptions IBalanceSocketClient.SubscribeBalanceOptions { get; } = new EndpointOptions(false); + async Task> IBalanceSocketClient.SubscribeToBalanceUpdatesAsync(SubscribeBalancesRequest request, Action>> handler, CancellationToken ct) + { + var validationError = ((IBalanceSocketClient)this).SubscribeBalanceOptions.ValidateRequest(Exchange, request, request.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeResult(Exchange, validationError); + + var result = await SubscribeToUserUpdatesAsync( + walletHandler: update => { + if (update.UpdateType == SocketUpdateType.Snapshot) + return; + + handler(update.AsExchangeEvent>(Exchange, update.Data.Select(x => new SharedBalance(x.Asset, x.Available ?? x.Total, x.Total)).ToArray())); + }, + ct: ct).ConfigureAwait(false); + + return new ExchangeResult(Exchange, result); + } + #endregion + + #region Spot Order client + EndpointOptions ISpotOrderSocketClient.SubscribeSpotOrderOptions { get; } = new EndpointOptions(false); + async Task> ISpotOrderSocketClient.SubscribeToSpotOrderUpdatesAsync(SubscribeSpotOrderRequest request, Action>> handler, CancellationToken ct) + { + var validationError = ((ISpotOrderSocketClient)this).SubscribeSpotOrderOptions.ValidateRequest(Exchange, request, TradingMode.Spot, SupportedTradingModes); + if (validationError != null) + return new ExchangeResult(Exchange, validationError); + + var result = await SubscribeToUserUpdatesAsync( + orderHandler: update => + { + if (update.UpdateType == SocketUpdateType.Snapshot) + return; + + handler(update.AsExchangeEvent>(Exchange, update.Data.Select(x => + new SharedSpotOrder( + x.Symbol, + x.Id.ToString(), + x.Type == Enums.OrderType.ExchangeLimit ? SharedOrderType.Limit : x.Type == Enums.OrderType.ExchangeMarket ? SharedOrderType.Market : SharedOrderType.Other, + x.Side == Enums.OrderSide.Buy ? SharedOrderSide.Buy : SharedOrderSide.Sell, + x.Status == Enums.OrderStatus.Canceled ? SharedOrderStatus.Canceled : (x.Status == Enums.OrderStatus.Active || x.Status == Enums.OrderStatus.PartiallyFilled) ? SharedOrderStatus.Open : SharedOrderStatus.Filled, + x.CreateTime) + { + ClientOrderId = x.ClientOrderId.ToString(), + OrderPrice = x.Price, + Quantity = x.Quantity, + QuantityFilled = x.Quantity - x.QuantityRemaining, + AveragePrice = x.PriceAverage == 0 ? null : x.PriceAverage, + UpdateTime = x.UpdateTime + } + ).ToArray())); + }, + ct: ct).ConfigureAwait(false); + + return new ExchangeResult(Exchange, result); + } + #endregion + + #region User Trade client + EndpointOptions IUserTradeSocketClient.SubscribeUserTradeOptions { get; } = new EndpointOptions(false); + async Task> IUserTradeSocketClient.SubscribeToUserTradeUpdatesAsync(SubscribeUserTradeRequest request, Action>> handler, CancellationToken ct) + { + var result = await SubscribeToUserUpdatesAsync( + tradeHandler: update => handler(update.AsExchangeEvent>(Exchange, new[] { + new SharedUserTrade( + update.Data.Symbol, + update.Data.OrderId.ToString(), + update.Data.Id.ToString(), + update.Data.QuantityRaw > 0 ? SharedOrderSide.Buy : SharedOrderSide.Sell, + update.Data.Quantity, + update.Data.Price, + update.Data.Timestamp) + { + Fee = Math.Abs(update.Data.Fee), + FeeAsset = update.Data.FeeAsset, + Role = update.Data.Maker == true ? SharedRole.Maker: SharedRole.Taker + } + })), + ct: ct).ConfigureAwait(false); + + return new ExchangeResult(Exchange, result); + } + #endregion + + #region Kline client + SubscribeKlineOptions IKlineSocketClient.SubscribeKlineOptions { get; } = new SubscribeKlineOptions(false); + async Task> IKlineSocketClient.SubscribeToKlineUpdatesAsync(SubscribeKlineRequest request, Action> handler, CancellationToken ct) + { + var interval = (Enums.KlineInterval)request.Interval; + if (!Enum.IsDefined(typeof(Enums.KlineInterval), interval)) + return new ExchangeResult(Exchange, new ArgumentError("Interval not supported")); + + var validationError = ((IKlineSocketClient)this).SubscribeKlineOptions.ValidateRequest(Exchange, request, request.Symbol.TradingMode, SupportedTradingModes); + if (validationError != null) + return new ExchangeResult(Exchange, validationError); + + var symbol = request.Symbol.GetSymbol(FormatSymbol); + var result = await SubscribeToKlineUpdatesAsync(symbol, interval, update => { + if (update.UpdateType == SocketUpdateType.Snapshot) + return; + + foreach (var item in update.Data) + handler(update.AsExchangeEvent(Exchange, new SharedKline(item.OpenTime, item.ClosePrice, item.HighPrice, item.LowPrice, item.OpenPrice, item.Volume))); + } + , ct).ConfigureAwait(false); + + return new ExchangeResult(Exchange, result); + } + #endregion + } +} diff --git a/Bitfinex.Net/ExtensionMethods/ServiceCollectionExtensions.cs b/Bitfinex.Net/ExtensionMethods/ServiceCollectionExtensions.cs index 7973a23..02f5a2c 100644 --- a/Bitfinex.Net/ExtensionMethods/ServiceCollectionExtensions.cs +++ b/Bitfinex.Net/ExtensionMethods/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Bitfinex.Net.Interfaces.Clients; using Bitfinex.Net.Objects.Options; using Bitfinex.Net.SymbolOrderBooks; +using CryptoExchange.Net; using System; using System.Net; using System.Net.Http; @@ -58,6 +59,10 @@ public static IServiceCollection AddBitfinex( services.AddTransient(); services.AddTransient(); services.AddTransient(x => x.GetRequiredService().SpotApi.CommonSpotClient); + + services.RegisterSharedRestInterfaces(x => x.GetRequiredService().SpotApi.SharedClient); + services.RegisterSharedSocketInterfaces(x => x.GetRequiredService().SpotApi.SharedClient); + if (socketClientLifeTime == null) services.AddSingleton(); else diff --git a/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexRestClientSpotApi.cs b/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexRestClientSpotApi.cs index 2dbe310..e084e96 100644 --- a/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexRestClientSpotApi.cs +++ b/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexRestClientSpotApi.cs @@ -25,9 +25,13 @@ public interface IBitfinexRestClientSpotApi : IRestApiClient, IDisposable public IBitfinexRestClientSpotApiTrading Trading { get; } /// - /// Get the ISpotClient for this client. This is a common interface which allows for some basic operations without knowing any details of the exchange. + /// DEPRECATED; use instead for common/shared functionality. See for more info. /// - /// public ISpotClient CommonSpotClient { get; } + + /// + /// Get the shared rest requests client. This interface is shared with other exhanges to allow for a common implementation for different exchanges. + /// + public IBitfinexRestClientSpotApiShared SharedClient { get; } } } diff --git a/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexRestClientSpotApiAccount.cs b/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexRestClientSpotApiAccount.cs index de7cac8..a6d8aea 100644 --- a/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexRestClientSpotApiAccount.cs +++ b/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexRestClientSpotApiAccount.cs @@ -43,7 +43,7 @@ public interface IBitfinexRestClientSpotApiAccount /// Get the withdrawal/deposit history /// /// - /// Symbol to get history for, for example `tETHUSD` + /// Asset to get history for, for example `ETH` /// Filter by ids /// Filter by deposit address /// Start time of the data to return @@ -51,7 +51,7 @@ public interface IBitfinexRestClientSpotApiAccount /// Max amount of results /// Cancellation token /// - Task>> GetMovementsAsync(string? symbol = null, IEnumerable? ids = null, string? address = null, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, CancellationToken ct = default); + Task>> GetMovementsAsync(string? asset = null, IEnumerable? ids = null, string? address = null, DateTime? startTime = null, DateTime? endTime = null, int? limit = null, CancellationToken ct = default); /// /// Get detailed information about a deposit/withdrawal @@ -210,6 +210,29 @@ Task> WithdrawAsync(string withdrawType, CancellationToken ct = default); /// + /// Withdraw funds + /// + /// + /// Method of withdrawal, methods can be retrieved with ExchangeData.GetAssetDepositWithdrawalMethodsAsync + /// Wallet type + /// Quantity to withdraw + /// Withdrawal address + /// Invoice (for lightning withdrawals) + /// Payment id (tag/memo) + /// When true the fee will be deducted from the withdrawal quantity + /// Note + /// Cancellation token + /// + Task> WithdrawV2Async(string method, + WithdrawWallet wallet, + decimal quantity, + string? address = null, + string? invoice = null, + string? paymentId = null, + bool? feeFromWithdrawalAmount = null, + string? note = null, + CancellationToken ct = default); + /// /// Get login history /// /// diff --git a/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexRestClientSpotApiShared.cs b/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexRestClientSpotApiShared.cs new file mode 100644 index 0000000..bcdcd70 --- /dev/null +++ b/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexRestClientSpotApiShared.cs @@ -0,0 +1,23 @@ +using CryptoExchange.Net.SharedApis; + +namespace Bitfinex.Net.Interfaces.Clients.SpotApi +{ + /// + /// Shared interface for Spot rest API usage + /// + public interface IBitfinexRestClientSpotApiShared : + IAssetsRestClient, + IBalanceRestClient, + IDepositRestClient, + IKlineRestClient, + IOrderBookRestClient, + IRecentTradeRestClient, + ISpotOrderRestClient, + ISpotSymbolRestClient, + ISpotTickerRestClient, + ITradeHistoryRestClient, + IWithdrawalRestClient, + IWithdrawRestClient + { + } +} diff --git a/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexSocketClientSpotApi.cs b/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexSocketClientSpotApi.cs index ad72563..cc39f4d 100644 --- a/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexSocketClientSpotApi.cs +++ b/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexSocketClientSpotApi.cs @@ -16,6 +16,11 @@ namespace Bitfinex.Net.Interfaces.Clients.SpotApi /// public interface IBitfinexSocketClientSpotApi : ISocketApiClient, IDisposable { + /// + /// Get the shared socket subscription client. This interface is shared with other exhanges to allow for a common implementation for different exchanges. + /// + public IBitfinexSocketClientSpotApiShared SharedClient { get; } + /// /// Subscribes to ticker updates for a symbol. Use SubscribeToFundingTickerUpdatesAsync for funding symbol ticker updates /// diff --git a/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexSocketClientSpotApiShared.cs b/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexSocketClientSpotApiShared.cs new file mode 100644 index 0000000..c405083 --- /dev/null +++ b/Bitfinex.Net/Interfaces/Clients/SpotApi/IBitfinexSocketClientSpotApiShared.cs @@ -0,0 +1,18 @@ +using CryptoExchange.Net.SharedApis; + +namespace Bitfinex.Net.Interfaces.Clients.SpotApi +{ + /// + /// Shared interface for Spot socket API usage + /// + public interface IBitfinexSocketClientSpotApiShared : + ITickerSocketClient, + ITradeSocketClient, + IBookTickerSocketClient, + IBalanceSocketClient, + ISpotOrderSocketClient, + IKlineSocketClient, + IUserTradeSocketClient + { + } +} diff --git a/Bitfinex.Net/Objects/Models/BitfinexTradeSimple.cs b/Bitfinex.Net/Objects/Models/BitfinexTradeSimple.cs index 9c7595e..ec67d19 100644 --- a/Bitfinex.Net/Objects/Models/BitfinexTradeSimple.cs +++ b/Bitfinex.Net/Objects/Models/BitfinexTradeSimple.cs @@ -35,5 +35,9 @@ public record BitfinexTradeSimple /// [ArrayProperty(4)] public int? Period { get; set; } + /// + /// Quantity as a positive number + /// + public decimal QuantityAbs => Math.Abs(Quantity); } } diff --git a/Bitfinex.Net/Objects/Models/BitfinexWithdrawalResultV2.cs b/Bitfinex.Net/Objects/Models/BitfinexWithdrawalResultV2.cs new file mode 100644 index 0000000..45ad2ee --- /dev/null +++ b/Bitfinex.Net/Objects/Models/BitfinexWithdrawalResultV2.cs @@ -0,0 +1,81 @@ +using Bitfinex.Net.Converters; +using Bitfinex.Net.Enums; +using CryptoExchange.Net.Converters; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Bitfinex.Net.Objects.Models +{ + /// + /// Withdrawal result + /// + [JsonConverter(typeof(ArrayConverter))] + public record BitfinexWithdrawalResultV2 + { + /// + /// Timestamp + /// + [ArrayProperty(0), JsonConverter(typeof(DateTimeConverter))] + public DateTime Timestamp { get; set; } + /// + /// Notification type + /// + [ArrayProperty(1)] + public string NotificationType { get; set; } = string.Empty; + /// + /// Withdrawal info + /// + [ArrayProperty(4)] + public BitfinexWithdrawalInfo Data { get; set; } = null!; + /// + /// Request status + /// + [ArrayProperty(6)] + public string Status { get; set; } = string.Empty; + /// + /// Message + /// + [ArrayProperty(7)] + public string Info { get; set; } = string.Empty; + } + + /// + /// Withdrawal info + /// + [JsonConverter(typeof(ArrayConverter))] + public record BitfinexWithdrawalInfo + { + /// + /// Withdrawal id + /// + [ArrayProperty(0)] + public long WithdrawalId { get; set; } + /// + /// Withdrawal method + /// + [ArrayProperty(2)] + public string Method { get; set; } = string.Empty; + /// + /// Payment id + /// + [ArrayProperty(3)] + public string? PaymentId { get; set; } + /// + /// Wallet type + /// + [ArrayProperty(4), JsonConverter(typeof(WithdrawWalletConverter))] + public WithdrawWallet Wallet { get; set; } + /// + /// Quantity + /// + [ArrayProperty(5)] + public decimal Quantity { get; set; } + /// + /// Withdrawal fee + /// + [ArrayProperty(8)] + public decimal Fee { get; set; } + } +}