diff --git a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs
index bc9a7e6d..81ba7ac3 100644
--- a/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs
+++ b/src/ExchangeSharp/API/Exchanges/Coinbase/ExchangeCoinbaseAPI.cs
@@ -10,1171 +10,710 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
namespace ExchangeSharp
{
- using System;
- using System.Collections.Generic;
- using System.Diagnostics;
- using System.Linq;
- using System.Net;
- using System.Threading.Tasks;
- using ExchangeSharp.Coinbase;
- using Newtonsoft.Json;
- using Newtonsoft.Json.Linq;
-
- public sealed partial class ExchangeCoinbaseAPI : ExchangeAPI
- {
- public override string BaseUrl { get; set; } = "https://api.pro.coinbase.com";
- public override string BaseUrlWebSocket { get; set; } = "wss://ws-feed.pro.coinbase.com";
-
- ///
- /// The response will also contain a CB-AFTER header which will return the cursor id to use in your next request for the page after this one. The page after is an older page and not one that happened after this one in chronological time.
- ///
- private string cursorAfter;
-
- ///
- /// The response will contain a CB-BEFORE header which will return the cursor id to use in your next request for the page before the current one. The page before is a newer page and not one that happened before in chronological time.
- ///
- private string cursorBefore;
-
- private ExchangeCoinbaseAPI()
- {
- RequestContentType = "application/json";
- NonceStyle = NonceStyle.UnixSeconds;
- NonceEndPoint = "/time";
- NonceEndPointField = "iso";
- NonceEndPointStyle = NonceStyle.Iso8601;
- WebSocketOrderBookType = WebSocketOrderBookType.FullBookFirstThenDeltas;
- /* Rate limits from Coinbase Pro webpage
- * Public endpoints - We throttle public endpoints by IP: 10 requests per second, up to 15 requests per second in bursts. Some endpoints may have custom rate limits.
- * Private endpoints - We throttle private endpoints by profile ID: 15 requests per second, up to 30 requests per second in bursts. Some endpoints may have custom rate limits.
- * fills endpoint has a custom rate limit of 10 requests per second, up to 20 requests per second in bursts. */
- RateLimit = new RateGate(9, TimeSpan.FromSeconds(1)); // set to 9 to be safe
- }
-
- private ExchangeOrderResult ParseFill(JToken result)
- {
- decimal amount = result["size"].ConvertInvariant();
- decimal price = result["price"].ConvertInvariant();
- string symbol = result["product_id"].ToStringInvariant();
-
- decimal fees = result["fee"].ConvertInvariant();
-
- ExchangeOrderResult order = new ExchangeOrderResult
- {
- TradeId = result["trade_id"].ToStringInvariant(),
- Amount = amount,
- AmountFilled = amount,
- Price = price,
- Fees = fees,
- AveragePrice = price,
- IsBuy = (result["side"].ToStringInvariant() == "buy"),
- // OrderDate - not provided here. ideally would be null but ExchangeOrderResult.OrderDate is not nullable
- CompletedDate = null, // order not necessarily fully filled at this point
- TradeDate = result["created_at"].ToDateTimeInvariant(), // even though it is named "created_at", the documentation says that it is the: timestamp of fill
- MarketSymbol = symbol,
- OrderId = result["order_id"].ToStringInvariant(),
- };
-
- return order;
- }
-
- private ExchangeOrderResult ParseOrder(JToken result)
- {
- decimal executedValue = result["executed_value"].ConvertInvariant();
- decimal amountFilled = result["filled_size"].ConvertInvariant();
- decimal amount = result["size"].ConvertInvariant(amountFilled);
- decimal price = result["price"].ConvertInvariant();
- decimal stop_price = result["stop_price"].ConvertInvariant();
- decimal? averagePrice = (
- amountFilled <= 0m ? null : (decimal?)(executedValue / amountFilled)
- );
- decimal fees = result["fill_fees"].ConvertInvariant();
- string marketSymbol = result["product_id"].ToStringInvariant(
- result["id"].ToStringInvariant()
- );
-
- ExchangeOrderResult order = new ExchangeOrderResult
- {
- Amount = amount,
- AmountFilled = amountFilled,
- Price = price <= 0m ? stop_price : price,
- Fees = fees,
- FeesCurrency = marketSymbol.Substring(0, marketSymbol.IndexOf('-')),
- AveragePrice = averagePrice,
- IsBuy = (result["side"].ToStringInvariant() == "buy"),
- OrderDate = result["created_at"].ToDateTimeInvariant(),
- CompletedDate = result["done_at"].ToDateTimeInvariant(),
- MarketSymbol = marketSymbol,
- OrderId = result["id"].ToStringInvariant()
- };
- switch (result["status"].ToStringInvariant())
- {
- case "pending":
- order.Result = ExchangeAPIOrderResult.PendingOpen;
- break;
- case "active":
- case "open":
- if (order.Amount == order.AmountFilled)
- {
- order.Result = ExchangeAPIOrderResult.Filled;
- }
- else if (order.AmountFilled > 0.0m)
- {
- order.Result = ExchangeAPIOrderResult.FilledPartially;
- }
- else
- {
- order.Result = ExchangeAPIOrderResult.Open;
- }
- break;
- case "done":
- case "settled":
- switch (result["done_reason"].ToStringInvariant())
- {
- case "cancelled":
- case "canceled":
- order.Result = ExchangeAPIOrderResult.Canceled;
- break;
- case "filled":
- order.Result = ExchangeAPIOrderResult.Filled;
- break;
- default:
- order.Result = ExchangeAPIOrderResult.Unknown;
- break;
- }
- break;
- case "rejected":
- order.Result = ExchangeAPIOrderResult.Rejected;
- break;
- case "cancelled":
- case "canceled":
- order.Result = ExchangeAPIOrderResult.Canceled;
- break;
- default:
- throw new NotImplementedException(
- $"Unexpected status type: {result["status"].ToStringInvariant()}"
- );
- }
- return order;
- }
-
- protected override bool CanMakeAuthenticatedRequest(
- IReadOnlyDictionary payload
- )
- {
- return base.CanMakeAuthenticatedRequest(payload) && Passphrase != null;
- }
-
- protected override async Task ProcessRequestAsync(
- IHttpWebRequest request,
- Dictionary payload
- )
- {
- if (CanMakeAuthenticatedRequest(payload))
- {
- // Coinbase is funny and wants a seconds double for the nonce, weird... we convert it to double and back to string invariantly to ensure decimal dot is used and not comma
- string timestamp = payload["nonce"].ToStringInvariant();
- payload.Remove("nonce");
- string form = CryptoUtility.GetJsonForPayload(payload);
- byte[] secret = CryptoUtility.ToBytesBase64Decode(PrivateApiKey);
- string toHash =
- timestamp
- + request.Method.ToUpperInvariant()
- + request.RequestUri.PathAndQuery
- + form;
- string signatureBase64String = CryptoUtility.SHA256SignBase64(toHash, secret);
- secret = null;
- toHash = null;
- request.AddHeader("CB-ACCESS-KEY", PublicApiKey.ToUnsecureString());
- request.AddHeader("CB-ACCESS-SIGN", signatureBase64String);
- request.AddHeader("CB-ACCESS-TIMESTAMP", timestamp);
- request.AddHeader(
- "CB-ACCESS-PASSPHRASE",
- CryptoUtility.ToUnsecureString(Passphrase)
- );
- if (request.Method == "POST")
- {
- await CryptoUtility.WriteToRequestAsync(request, form);
- }
- }
- }
-
- protected override void ProcessResponse(IHttpWebResponse response)
- {
- base.ProcessResponse(response);
- cursorAfter = response.GetHeader("CB-AFTER").FirstOrDefault();
- cursorBefore = response.GetHeader("CB-BEFORE").FirstOrDefault();
- }
-
- protected internal override async Task<
- IEnumerable
- > OnGetMarketSymbolsMetadataAsync()
- {
- var markets = new List();
- JToken products = await MakeJsonRequestAsync("/products");
- foreach (JToken product in products)
- {
- var market = new ExchangeMarket
- {
- MarketSymbol = product["id"].ToStringUpperInvariant(),
- QuoteCurrency = product["quote_currency"].ToStringUpperInvariant(),
- BaseCurrency = product["base_currency"].ToStringUpperInvariant(),
- IsActive = string.Equals(
- product["status"].ToStringInvariant(),
- "online",
- StringComparison.OrdinalIgnoreCase
- ),
- MinTradeSize = product["base_min_size"].ConvertInvariant(),
- MaxTradeSize = product["base_max_size"].ConvertInvariant(),
- PriceStepSize = product["quote_increment"].ConvertInvariant(),
- QuantityStepSize = product["base_increment"].ConvertInvariant(),
- };
- markets.Add(market);
- }
-
- return markets;
- }
-
- protected override async Task> OnGetMarketSymbolsAsync()
- {
- return (await GetMarketSymbolsMetadataAsync())
- .Where(market => market.IsActive ?? true)
- .Select(market => market.MarketSymbol);
- }
-
- protected override async Task<
- IReadOnlyDictionary
- > OnGetCurrenciesAsync()
- {
- var currencies = new Dictionary();
- JToken products = await MakeJsonRequestAsync("/currencies");
- foreach (JToken product in products)
- {
- var currency = new ExchangeCurrency
- {
- Name = product["id"].ToStringUpperInvariant(),
- FullName = product["name"].ToStringInvariant(),
- DepositEnabled = true,
- WithdrawalEnabled = true
- };
-
- currencies[currency.Name] = currency;
- }
-
- return currencies;
- }
-
- protected override async Task OnGetTickerAsync(string marketSymbol)
- {
- JToken ticker = await MakeJsonRequestAsync(
- "/products/" + marketSymbol + "/ticker"
- );
- return await this.ParseTickerAsync(
- ticker,
- marketSymbol,
- "ask",
- "bid",
- "price",
- "volume",
- null,
- "time",
- TimestampType.Iso8601UTC
- );
- }
-
- protected override async Task OnGetDepositAddressAsync(
- string symbol,
- bool forceRegenerate = false
- )
- {
- // Hack found here: https://github.com/coinbase/gdax-node/issues/91#issuecomment-352441654 + using Fiddler
-
- // Get coinbase accounts
- JArray accounts = await this.MakeJsonRequestAsync(
- "/coinbase-accounts",
- null,
- await GetNoncePayloadAsync(),
- "GET"
- );
-
- foreach (JToken token in accounts)
- {
- string currency = token["currency"].ConvertInvariant();
- if (currency.Equals(symbol, StringComparison.InvariantCultureIgnoreCase))
- {
- JToken accountWalletAddress = await this.MakeJsonRequestAsync(
- $"/coinbase-accounts/{token["id"]}/addresses",
- null,
- await GetNoncePayloadAsync(),
- "POST"
- );
-
- return new ExchangeDepositDetails
- {
- Address = accountWalletAddress["address"].ToStringInvariant(),
- Currency = currency
- };
- }
- }
- throw new APIException($"Address not found for {symbol}");
- }
-
- protected override async Task<
- IEnumerable>
- > OnGetTickersAsync()
- {
- Dictionary tickers = new Dictionary(
- StringComparer.OrdinalIgnoreCase
- );
- System.Threading.ManualResetEvent evt = new System.Threading.ManualResetEvent(false);
- List symbols = (await GetMarketSymbolsAsync()).ToList();
-
- // stupid Coinbase does not have a one shot API call for tickers outside of web sockets
- using (
- var socket = await GetTickersWebSocketAsync(
- (t) =>
- {
- lock (tickers)
- {
- if (symbols.Count != 0)
- {
- foreach (var kv in t)
- {
- if (!tickers.ContainsKey(kv.Key))
- {
- tickers[kv.Key] = kv.Value;
- symbols.Remove(kv.Key);
- }
- }
- if (symbols.Count == 0)
- {
- evt.Set();
- }
- }
- }
- }
- )
- )
- {
- evt.WaitOne(10000);
- return tickers;
- }
- }
-
- protected override Task OnGetDeltaOrderBookWebSocketAsync(
- Action callback,
- int maxCount = 20,
- params string[] marketSymbols
- )
- {
- return ConnectPublicWebSocketAsync(
- string.Empty,
- (_socket, msg) =>
- {
- string message = msg.ToStringFromUTF8();
- var book = new ExchangeOrderBook();
-
- // string comparison on the json text for faster deserialization
- // More likely to be an l2update so check for that first
- if (message.Contains(@"""l2update"""))
- {
- // parse delta update
- var delta = JsonConvert.DeserializeObject(
- message,
- SerializerSettings
- );
- book.MarketSymbol = delta.ProductId;
- book.SequenceId = delta.Time.Ticks;
- foreach (string[] change in delta.Changes)
- {
- decimal price = change[1].ConvertInvariant();
- decimal amount = change[2].ConvertInvariant();
- if (change[0] == "buy")
- {
- book.Bids[price] = new ExchangeOrderPrice
- {
- Amount = amount,
- Price = price
- };
- }
- else
- {
- book.Asks[price] = new ExchangeOrderPrice
- {
- Amount = amount,
- Price = price
- };
- }
- }
- }
- else if (message.Contains(@"""snapshot"""))
- {
- // parse snapshot
- var snapshot = JsonConvert.DeserializeObject(
- message,
- SerializerSettings
- );
- book.MarketSymbol = snapshot.ProductId;
- foreach (decimal[] ask in snapshot.Asks)
- {
- decimal price = ask[0];
- decimal amount = ask[1];
- book.Asks[price] = new ExchangeOrderPrice
- {
- Amount = amount,
- Price = price
- };
- }
-
- foreach (decimal[] bid in snapshot.Bids)
- {
- decimal price = bid[0];
- decimal amount = bid[1];
- book.Bids[price] = new ExchangeOrderPrice
- {
- Amount = amount,
- Price = price
- };
- }
- }
- else
- {
- // no other message type handled
- return Task.CompletedTask;
- }
-
- callback(book);
- return Task.CompletedTask;
- },
- async (_socket) =>
- {
- // subscribe to order book channel for each symbol
- if (marketSymbols == null || marketSymbols.Length == 0)
- {
- marketSymbols = (await GetMarketSymbolsAsync()).ToArray();
- }
- var chan = new Channel
- {
- Name = ChannelType.Level2,
- ProductIds = marketSymbols.ToList()
- };
- var channelAction = new ChannelAction
- {
- Type = ActionType.Subscribe,
- Channels = new List { chan }
- };
- await _socket.SendMessageAsync(channelAction);
- }
- );
- }
-
- protected override async Task OnGetTickersWebSocketAsync(
- Action>> callback,
- params string[] marketSymbols
- )
- {
- return await ConnectPublicWebSocketAsync(
- "/",
- async (_socket, msg) =>
- {
- JToken token = JToken.Parse(msg.ToStringFromUTF8());
- if (token["type"].ToStringInvariant() == "ticker")
- {
- ExchangeTicker ticker = await this.ParseTickerAsync(
- token,
- token["product_id"].ToStringInvariant(),
- "best_ask",
- "best_bid",
- "price",
- "volume_24h",
- null,
- "time",
- TimestampType.Iso8601UTC
- );
- callback(
- new List>()
- {
- new KeyValuePair(
- token["product_id"].ToStringInvariant(),
- ticker
- )
- }
- );
- }
- },
- async (_socket) =>
- {
- marketSymbols =
- marketSymbols == null || marketSymbols.Length == 0
- ? (await GetMarketSymbolsAsync()).ToArray()
- : marketSymbols;
- var subscribeRequest = new
- {
- type = "subscribe",
- product_ids = marketSymbols,
- channels = new object[]
- {
- new { name = "ticker", product_ids = marketSymbols.ToArray() }
- }
- };
- await _socket.SendMessageAsync(subscribeRequest);
- }
- );
- }
-
- protected override async Task OnGetTradesWebSocketAsync(
- Func, Task> callback,
- params string[] marketSymbols
- )
+ ///
+ /// Warning: This API now uses Coinbase Advanced Trade V2/V3.
+ /// If you are using legacy API keys from previous Coinbase versions they must be upgraded to Advanced Trade on the Coinbase site.
+ /// These keys must be set before using the Coinbase API (sorry).
+ ///
+ public sealed partial class ExchangeCoinbaseAPI : ExchangeAPI
+ {
+ public override string BaseUrl { get; set; } = "https://api.coinbase.com/api/v3/brokerage";
+ private readonly string BaseUrlV2 = "https://api.coinbase.com/v2"; // For Wallet Support
+ public override string BaseUrlWebSocket { get; set; } = "wss://advanced-trade-ws.coinbase.com";
+
+ private enum PaginationType { None, V2, V3}
+ private PaginationType pagination = PaginationType.None;
+ private string cursorNext;
+
+ private Dictionary Accounts = null; // Cached Account IDs
+
+ private ExchangeCoinbaseAPI()
+ {
+ MarketSymbolIsUppercase = true;
+ MarketSymbolIsReversed = false;
+ RequestContentType = "application/json";
+ NonceStyle = NonceStyle.None;
+ WebSocketOrderBookType = WebSocketOrderBookType.FullBookFirstThenDeltas;
+ RateLimit = new RateGate(30, TimeSpan.FromSeconds(1));
+ base.RequestMaker.RequestStateChanged = ProcessResponse;
+ }
+
+ ///
+ /// This is used to capture Pagination instead of overriding the ProcessResponse
+ /// because the Pagination info is no longer in the Headers and ProcessResponse does not return the required Content
+ ///
+ ///
+ ///
+ ///
+ private void ProcessResponse(IAPIRequestMaker maker, RequestMakerState state, object response)
+ {
+ // We can bypass serialization if we already know the last call isn't paginated
+ if (state == RequestMakerState.Finished && pagination != PaginationType.None)
+ {
+ JToken token = JsonConvert.DeserializeObject((string)response);
+ if (token == null) return;
+ switch(pagination)
+ {
+ case PaginationType.V2: cursorNext = token["pagination"]?["next_starting_after"]?.ToStringInvariant(); break;
+ case PaginationType.V3: cursorNext = token[CURSOR]?.ToStringInvariant(); break;
+ }
+ }
+ }
+
+ #region BaseOverrides
+
+ ///
+ /// Overridden because we no longer need a nonce in the payload and passphrase is no longer used
+ ///
+ ///
+ ///
+ protected override bool CanMakeAuthenticatedRequest(IReadOnlyDictionary payload)
+ {
+ return (PrivateApiKey != null && PublicApiKey != null);
+ }
+
+ protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload)
{
- if (marketSymbols == null || marketSymbols.Length == 0)
+ if (CanMakeAuthenticatedRequest(payload))
{
- marketSymbols = (await GetMarketSymbolsAsync()).ToArray();
- }
- return await ConnectPublicWebSocketAsync(
- "/",
- async (_socket, msg) =>
- {
- JToken token = JToken.Parse(msg.ToStringFromUTF8());
- if (token["type"].ToStringInvariant() == "error")
- { // {{ "type": "error", "message": "Failed to subscribe", "reason": "match is not a valid channel" }}
- Logger.Info(
- token["message"].ToStringInvariant()
- + ": "
- + token["reason"].ToStringInvariant()
- );
- return;
- }
- if (token["type"].ToStringInvariant() != "match")
- return; //the ticker channel provides the trade information as well
- if (token["time"] == null)
- return;
- ExchangeTrade trade = ParseTradeWebSocket(token);
- string marketSymbol = token["product_id"].ToStringInvariant();
- await callback(new KeyValuePair(marketSymbol, trade));
- },
- async (_socket) =>
- {
- var subscribeRequest = new
- {
- type = "subscribe",
- product_ids = marketSymbols,
- channels = new object[]
- {
- new { name = "matches", product_ids = marketSymbols }
- }
- };
- await _socket.SendMessageAsync(subscribeRequest);
- }
- );
- }
-
- private ExchangeTrade ParseTradeWebSocket(JToken token)
- {
- return token.ParseTradeCoinbase(
- "size",
- "price",
- "side",
- "time",
- TimestampType.Iso8601UTC,
- "trade_id"
- );
- }
+ string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); // If you're skittish about the local clock, you may retrieve the timestamp from the Coinbase Site
+ string body = CryptoUtility.GetJsonForPayload(payload);
- protected override async Task OnUserDataWebSocketAsync(Action