1
0
mirror of https://github.com/JKorf/CryptoExchange.Net synced 2025-06-08 00:16:27 +00:00

Updated some interfaces, made time syncing methods nullable for apis not using it, added optional retry checking, removed private key from api credentials, added better support for api credentials subclasses

This commit is contained in:
JKorf 2023-02-13 21:18:45 +01:00
parent a222bb3f02
commit 11650f7c1a
14 changed files with 201 additions and 194 deletions

View File

@ -37,8 +37,8 @@ namespace CryptoExchange.Net.UnitTests
public CallResult<T> Deserialize<T>(string data) => Deserialize<T>(data, null, null); public CallResult<T> Deserialize<T>(string data) => Deserialize<T>(data, null, null);
public override TimeSpan GetTimeOffset() => throw new NotImplementedException(); public override TimeSpan? GetTimeOffset() => null;
public override TimeSyncInfo GetTimeSyncInfo() => throw new NotImplementedException(); public override TimeSyncInfo GetTimeSyncInfo() => null;
protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials) => throw new NotImplementedException(); protected override AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials) => throw new NotImplementedException();
protected override Task<WebCallResult<DateTime>> GetServerTimestampAsync() => throw new NotImplementedException(); protected override Task<WebCallResult<DateTime>> GetServerTimestampAsync() => throw new NotImplementedException();
} }

View File

@ -142,7 +142,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
ParameterPositions[method] = position; ParameterPositions[method] = position;
} }
public override TimeSpan GetTimeOffset() public override TimeSpan? GetTimeOffset()
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
@ -178,7 +178,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations
return new ServerError((int)error["errorCode"], (string)error["errorMessage"]); return new ServerError((int)error["errorCode"], (string)error["errorMessage"]);
} }
public override TimeSpan GetTimeOffset() public override TimeSpan? GetTimeOffset()
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }

View File

@ -21,20 +21,6 @@ namespace CryptoExchange.Net.Authentication
/// </summary> /// </summary>
public SecureString? Secret { get; } public SecureString? Secret { get; }
/// <summary>
/// The private key to authenticate requests
/// </summary>
public PrivateKey? PrivateKey { get; }
/// <summary>
/// Create Api credentials providing a private key for authentication
/// </summary>
/// <param name="privateKey">The private key used for signing</param>
public ApiCredentials(PrivateKey privateKey)
{
PrivateKey = privateKey;
}
/// <summary> /// <summary>
/// Create Api credentials providing an api key and secret for authentication /// Create Api credentials providing an api key and secret for authentication
/// </summary> /// </summary>
@ -69,11 +55,8 @@ namespace CryptoExchange.Net.Authentication
/// <returns></returns> /// <returns></returns>
public virtual ApiCredentials Copy() public virtual ApiCredentials Copy()
{ {
if (PrivateKey == null) // Use .GetString() to create a copy of the SecureString
// Use .GetString() to create a copy of the SecureString return new ApiCredentials(Key!.GetString(), Secret!.GetString());
return new ApiCredentials(Key!.GetString(), Secret!.GetString());
else
return new ApiCredentials(PrivateKey!.Copy());
} }
/// <summary> /// <summary>
@ -123,7 +106,6 @@ namespace CryptoExchange.Net.Authentication
{ {
Key?.Dispose(); Key?.Dispose();
Secret?.Dispose(); Secret?.Dispose();
PrivateKey?.Dispose();
} }
} }
} }

View File

@ -223,7 +223,7 @@ namespace CryptoExchange.Net.Authentication
/// <returns></returns> /// <returns></returns>
protected static DateTime GetTimestamp(RestApiClient apiClient) protected static DateTime GetTimestamp(RestApiClient apiClient)
{ {
return DateTime.UtcNow.Add(apiClient?.GetTimeOffset() ?? TimeSpan.Zero)!; return DateTime.UtcNow.Add(apiClient.GetTimeOffset() ?? TimeSpan.Zero)!;
} }
/// <summary> /// <summary>

View File

@ -1,110 +0,0 @@
using System;
using System.Security;
namespace CryptoExchange.Net.Authentication
{
/// <summary>
/// Private key info
/// </summary>
public class PrivateKey : IDisposable
{
/// <summary>
/// The private key
/// </summary>
public SecureString Key { get; }
/// <summary>
/// The private key's pass phrase
/// </summary>
public SecureString? Passphrase { get; }
/// <summary>
/// Indicates if the private key is encrypted or not
/// </summary>
public bool IsEncrypted { get; }
/// <summary>
/// Create a private key providing an encrypted key information
/// </summary>
/// <param name="key">The private key used for signing</param>
/// <param name="passphrase">The private key's passphrase</param>
public PrivateKey(SecureString key, SecureString passphrase)
{
Key = key;
Passphrase = passphrase;
IsEncrypted = true;
}
/// <summary>
/// Create a private key providing an encrypted key information
/// </summary>
/// <param name="key">The private key used for signing</param>
/// <param name="passphrase">The private key's passphrase</param>
public PrivateKey(string key, string passphrase)
{
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(passphrase))
throw new ArgumentException("Key and passphrase can't be null/empty");
var secureKey = new SecureString();
foreach (var c in key)
secureKey.AppendChar(c);
secureKey.MakeReadOnly();
Key = secureKey;
var securePassphrase = new SecureString();
foreach (var c in passphrase)
securePassphrase.AppendChar(c);
securePassphrase.MakeReadOnly();
Passphrase = securePassphrase;
IsEncrypted = true;
}
/// <summary>
/// Create a private key providing an unencrypted key information
/// </summary>
/// <param name="key">The private key used for signing</param>
public PrivateKey(SecureString key)
{
Key = key;
IsEncrypted = false;
}
/// <summary>
/// Create a private key providing an encrypted key information
/// </summary>
/// <param name="key">The private key used for signing</param>
public PrivateKey(string key)
{
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key can't be null/empty");
Key = key.ToSecureString();
IsEncrypted = false;
}
/// <summary>
/// Copy the private key
/// </summary>
/// <returns></returns>
public PrivateKey Copy()
{
if (Passphrase == null)
return new PrivateKey(Key.GetString());
else
return new PrivateKey(Key.GetString(), Passphrase.GetString());
}
/// <summary>
/// Dispose
/// </summary>
public void Dispose()
{
Key?.Dispose();
Passphrase?.Dispose();
}
}
}

View File

@ -6,6 +6,7 @@ using System.Net.Http;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using CryptoExchange.Net.Authentication; using CryptoExchange.Net.Authentication;
using CryptoExchange.Net.Interfaces;
using CryptoExchange.Net.Logging; using CryptoExchange.Net.Logging;
using CryptoExchange.Net.Objects; using CryptoExchange.Net.Objects;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -17,7 +18,7 @@ namespace CryptoExchange.Net
/// <summary> /// <summary>
/// Base API for all API clients /// Base API for all API clients
/// </summary> /// </summary>
public abstract class BaseApiClient: IDisposable public abstract class BaseApiClient : IDisposable, IBaseApiClient
{ {
private ApiCredentials? _apiCredentials; private ApiCredentials? _apiCredentials;
private AuthenticationProvider? _authenticationProvider; private AuthenticationProvider? _authenticationProvider;
@ -38,7 +39,7 @@ namespace CryptoExchange.Net
/// </summary> /// </summary>
public AuthenticationProvider? AuthenticationProvider public AuthenticationProvider? AuthenticationProvider
{ {
get get
{ {
if (!_created && !_disposing && _apiCredentials != null) if (!_created && !_disposing && _apiCredentials != null)
{ {
@ -98,7 +99,7 @@ namespace CryptoExchange.Net
/// <summary> /// <summary>
/// Lock for id generating /// Lock for id generating
/// </summary> /// </summary>
protected static object idLock = new (); protected static object idLock = new();
/// <summary> /// <summary>
/// A default serializer /// A default serializer
@ -131,7 +132,7 @@ namespace CryptoExchange.Net
protected abstract AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials); protected abstract AuthenticationProvider CreateAuthenticationProvider(ApiCredentials credentials);
/// <inheritdoc /> /// <inheritdoc />
public void SetApiCredentials(ApiCredentials credentials) public void SetApiCredentials<T>(T credentials) where T : ApiCredentials
{ {
_apiCredentials = credentials?.Copy(); _apiCredentials = credentials?.Copy();
_created = false; _created = false;

View File

@ -53,7 +53,7 @@ namespace CryptoExchange.Net
/// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options. /// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options.
/// </summary> /// </summary>
/// <param name="credentials">The credentials to set</param> /// <param name="credentials">The credentials to set</param>
public void SetApiCredentials(ApiCredentials credentials) protected virtual void SetApiCredentials<T>(T credentials) where T : ApiCredentials
{ {
foreach (var apiClient in ApiClients) foreach (var apiClient in ApiClients)
apiClient.SetApiCredentials(credentials); apiClient.SetApiCredentials(credentials);

View File

@ -20,35 +20,24 @@ namespace CryptoExchange.Net
/// <summary> /// <summary>
/// Base rest API client for interacting with a REST API /// Base rest API client for interacting with a REST API
/// </summary> /// </summary>
public abstract class RestApiClient: BaseApiClient public abstract class RestApiClient : BaseApiClient, IRestApiClient
{ {
/// <summary> /// <inheritdoc />
/// The factory for creating requests. Used for unit testing
/// </summary>
public IRequestFactory RequestFactory { get; set; } = new RequestFactory(); public IRequestFactory RequestFactory { get; set; } = new RequestFactory();
/// <inheritdoc />
public abstract TimeSyncInfo? GetTimeSyncInfo();
/// <inheritdoc />
public abstract TimeSpan? GetTimeOffset();
/// <inheritdoc />
public int TotalRequestsMade { get; set; }
/// <summary> /// <summary>
/// Request headers to be sent with each request /// Request headers to be sent with each request
/// </summary> /// </summary>
protected Dictionary<string, string>? StandardRequestHeaders { get; set; } protected Dictionary<string, string>? StandardRequestHeaders { get; set; }
/// <summary>
/// Get time sync info for an API client
/// </summary>
/// <returns></returns>
public abstract TimeSyncInfo GetTimeSyncInfo();
/// <summary>
/// Get time offset for an API client
/// </summary>
/// <returns></returns>
public abstract TimeSpan GetTimeOffset();
/// <summary>
/// Total amount of requests made with this API client
/// </summary>
public int TotalRequestsMade { get; set; }
/// <summary> /// <summary>
/// Options for this client /// Options for this client
/// </summary> /// </summary>
@ -70,7 +59,7 @@ namespace CryptoExchange.Net
/// <param name="log">Logger</param> /// <param name="log">Logger</param>
/// <param name="options">The base client options</param> /// <param name="options">The base client options</param>
/// <param name="apiOptions">The Api client options</param> /// <param name="apiOptions">The Api client options</param>
public RestApiClient(Log log, ClientOptions options, RestApiClientOptions apiOptions): base(log, options, apiOptions) public RestApiClient(Log log, ClientOptions options, RestApiClientOptions apiOptions) : base(log, options, apiOptions)
{ {
var rateLimiters = new List<IRateLimiter>(); var rateLimiters = new List<IRateLimiter>();
foreach (var rateLimiter in apiOptions.RateLimiters) foreach (var rateLimiter in apiOptions.RateLimiters)
@ -110,12 +99,20 @@ namespace CryptoExchange.Net
Dictionary<string, string>? additionalHeaders = null, Dictionary<string, string>? additionalHeaders = null,
bool ignoreRatelimit = false) bool ignoreRatelimit = false)
{ {
var request = await PrepareRequestAsync(uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false); int currentTry = 0;
if (!request) while (true)
return new WebCallResult(request.Error!); {
currentTry++;
var request = await PrepareRequestAsync(uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false);
if (!request)
return new WebCallResult(request.Error!);
var result = await GetResponseAsync<object>(request.Data, deserializer, cancellationToken, true).ConfigureAwait(false); var result = await GetResponseAsync<object>(request.Data, deserializer, cancellationToken, true).ConfigureAwait(false);
return result.AsDataless(); if (await ShouldRetryRequestAsync(result, currentTry).ConfigureAwait(false))
continue;
return result.AsDataless();
}
} }
/// <summary> /// <summary>
@ -149,11 +146,20 @@ namespace CryptoExchange.Net
bool ignoreRatelimit = false bool ignoreRatelimit = false
) where T : class ) where T : class
{ {
var request = await PrepareRequestAsync(uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false); int currentTry = 0;
if (!request) while (true)
return new WebCallResult<T>(request.Error!); {
currentTry++;
var request = await PrepareRequestAsync(uri, method, cancellationToken, parameters, signed, parameterPosition, arraySerialization, requestWeight, deserializer, additionalHeaders, ignoreRatelimit).ConfigureAwait(false);
if (!request)
return new WebCallResult<T>(request.Error!);
return await GetResponseAsync<T>(request.Data, deserializer, cancellationToken, false).ConfigureAwait(false); var result = await GetResponseAsync<T>(request.Data, deserializer, cancellationToken, false).ConfigureAwait(false);
if (await ShouldRetryRequestAsync(result, currentTry).ConfigureAwait(false))
continue;
return result;
}
} }
/// <summary> /// <summary>
@ -190,7 +196,8 @@ namespace CryptoExchange.Net
{ {
var syncTask = SyncTimeAsync(); var syncTask = SyncTimeAsync();
var timeSyncInfo = GetTimeSyncInfo(); var timeSyncInfo = GetTimeSyncInfo();
if (timeSyncInfo.TimeSyncState.LastSyncTime == default)
if (timeSyncInfo != null && timeSyncInfo.TimeSyncState.LastSyncTime == default)
{ {
// Initially with first request we'll need to wait for the time syncing, if it's not the first request we can just continue // Initially with first request we'll need to wait for the time syncing, if it's not the first request we can just continue
var syncTimeResult = await syncTask.ConfigureAwait(false); var syncTimeResult = await syncTask.ConfigureAwait(false);
@ -235,8 +242,6 @@ namespace CryptoExchange.Net
return new CallResult<IRequest>(request); return new CallResult<IRequest>(request);
} }
/// <summary> /// <summary>
/// Executes the request and returns the result deserialized into the type parameter class /// Executes the request and returns the result deserialized into the type parameter class
/// </summary> /// </summary>
@ -376,6 +381,16 @@ namespace CryptoExchange.Net
return Task.FromResult<ServerError?>(null); return Task.FromResult<ServerError?>(null);
} }
/// <summary>
/// Can be used to indicate that a request should be retried. Defaults to false. Make sure to retry a max number of times (based on the the tries parameter) or the request will retry forever.
/// Note that this is always called; even when the request might be successful
/// </summary>
/// <typeparam name="T">WebCallResult type parameter</typeparam>
/// <param name="callResult">The result of the call</param>
/// <param name="tries">The current try number</param>
/// <returns>True if call should retry, false if the call should return</returns>
protected virtual Task<bool> ShouldRetryRequestAsync<T>(WebCallResult<T> callResult, int tries) => Task.FromResult(false);
/// <summary> /// <summary>
/// Creates a request object /// Creates a request object
/// </summary> /// </summary>
@ -521,11 +536,14 @@ namespace CryptoExchange.Net
/// Retrieve the server time for the purpose of syncing time between client and server to prevent authentication issues /// Retrieve the server time for the purpose of syncing time between client and server to prevent authentication issues
/// </summary> /// </summary>
/// <returns>Server time</returns> /// <returns>Server time</returns>
protected abstract Task<WebCallResult<DateTime>> GetServerTimestampAsync(); protected virtual Task<WebCallResult<DateTime>> GetServerTimestampAsync() => throw new NotImplementedException();
internal async Task<WebCallResult<bool>> SyncTimeAsync() internal async Task<WebCallResult<bool>> SyncTimeAsync()
{ {
var timeSyncParams = GetTimeSyncInfo(); var timeSyncParams = GetTimeSyncInfo();
if (timeSyncParams == null)
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, true, null);
if (await timeSyncParams.TimeSyncState.Semaphore.WaitAsync(0).ConfigureAwait(false)) if (await timeSyncParams.TimeSyncState.Semaphore.WaitAsync(0).ConfigureAwait(false))
{ {
if (!timeSyncParams.SyncTime || (DateTime.UtcNow - timeSyncParams.TimeSyncState.LastSyncTime < timeSyncParams.RecalculationInterval)) if (!timeSyncParams.SyncTime || (DateTime.UtcNow - timeSyncParams.TimeSyncState.LastSyncTime < timeSyncParams.RecalculationInterval))
@ -557,7 +575,7 @@ namespace CryptoExchange.Net
// Calculate time offset between local and server // Calculate time offset between local and server
var offset = result.Data - (localTime.AddMilliseconds(result.ResponseTime!.Value.TotalMilliseconds / 2)); var offset = result.Data - (localTime.AddMilliseconds(result.ResponseTime!.Value.TotalMilliseconds / 2));
timeSyncParams.UpdateTimeOffset(offset); timeSyncParams.UpdateTimeOffset(offset);
timeSyncParams.TimeSyncState.Semaphore.Release(); timeSyncParams.TimeSyncState.Semaphore.Release();
} }
return new WebCallResult<bool>(null, null, null, null, null, null, null, null, true, null); return new WebCallResult<bool>(null, null, null, null, null, null, null, null, true, null);

View File

@ -18,13 +18,10 @@ namespace CryptoExchange.Net
/// <summary> /// <summary>
/// Base socket API client for interaction with a websocket API /// Base socket API client for interaction with a websocket API
/// </summary> /// </summary>
public abstract class SocketApiClient : BaseApiClient public abstract class SocketApiClient : BaseApiClient, ISocketApiClient
{ {
#region Fields #region Fields
/// <inheritdoc/>
/// <summary>
/// The factory for creating sockets. Used for unit testing
/// </summary>
public IWebsocketFactory SocketFactory { get; set; } = new WebsocketFactory(); public IWebsocketFactory SocketFactory { get; set; } = new WebsocketFactory();
/// <summary> /// <summary>
@ -117,7 +114,7 @@ namespace CryptoExchange.Net
/// <param name="log">log</param> /// <param name="log">log</param>
/// <param name="options">Client options</param> /// <param name="options">Client options</param>
/// <param name="apiOptions">The Api client options</param> /// <param name="apiOptions">The Api client options</param>
public SocketApiClient(Log log, ClientOptions options, SocketApiClientOptions apiOptions): base(log, options, apiOptions) public SocketApiClient(Log log, ClientOptions options, SocketApiClientOptions apiOptions) : base(log, options, apiOptions)
{ {
ClientOptions = options; ClientOptions = options;
} }

View File

@ -0,0 +1,17 @@
using CryptoExchange.Net.Authentication;
namespace CryptoExchange.Net.Interfaces
{
/// <summary>
/// Base api client
/// </summary>
public interface IBaseApiClient
{
/// <summary>
/// Set the API credentials for this API client
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="credentials"></param>
void SetApiCredentials<T>(T credentials) where T : ApiCredentials;
}
}

View File

@ -0,0 +1,33 @@
using CryptoExchange.Net.Objects;
using System;
namespace CryptoExchange.Net.Interfaces
{
/// <summary>
/// Base rest API client
/// </summary>
public interface IRestApiClient : IBaseApiClient
{
/// <summary>
/// The factory for creating requests. Used for unit testing
/// </summary>
IRequestFactory RequestFactory { get; set; }
/// <summary>
/// Total amount of requests made with this API client
/// </summary>
int TotalRequestsMade { get; set; }
/// <summary>
/// Get time offset for an API client. Return null if time syncing shouldnt/cant be done
/// </summary>
/// <returns></returns>
TimeSpan? GetTimeOffset();
/// <summary>
/// Get time sync info for an API client. Return null if time syncing shouldnt/cant be done
/// </summary>
/// <returns></returns>
TimeSyncInfo? GetTimeSyncInfo();
}
}

View File

@ -18,11 +18,5 @@ namespace CryptoExchange.Net.Interfaces
/// The total amount of requests made with this client /// The total amount of requests made with this client
/// </summary> /// </summary>
int TotalRequestsMade { get; } int TotalRequestsMade { get; }
/// <summary>
/// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options.
/// </summary>
/// <param name="credentials">The credentials to set</param>
void SetApiCredentials(ApiCredentials credentials);
} }
} }

View File

@ -0,0 +1,81 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Sockets;
using System;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Interfaces
{
/// <summary>
/// Socket API client
/// </summary>
public interface ISocketApiClient: IBaseApiClient
{
/// <summary>
/// The current amount of socket connections on the API client
/// </summary>
int CurrentConnections { get; }
/// <summary>
/// The current amount of subscriptions over all connections
/// </summary>
int CurrentSubscriptions { get; }
/// <summary>
/// Incoming data kpbs
/// </summary>
double IncomingKbps { get; }
/// <summary>
/// Client options
/// </summary>
SocketApiClientOptions Options { get; }
/// <summary>
/// The factory for creating sockets. Used for unit testing
/// </summary>
IWebsocketFactory SocketFactory { get; set; }
/// <summary>
/// Get the url to reconnect to after losing a connection
/// </summary>
/// <param name="connection"></param>
/// <returns></returns>
Task<Uri?> GetReconnectUriAsync(SocketConnection connection);
/// <summary>
/// Log the current state of connections and subscriptions
/// </summary>
string GetSubscriptionsState();
/// <summary>
/// Reconnect all connections
/// </summary>
/// <returns></returns>
Task ReconnectAsync();
/// <summary>
/// Update the original request to send when the connection is restored after disconnecting. Can be used to update an authentication token for example.
/// </summary>
/// <param name="request">The original request</param>
/// <returns></returns>
Task<CallResult<object>> RevitalizeRequestAsync(object request);
/// <summary>
/// Periodically sends data over a socket connection
/// </summary>
/// <param name="identifier">Identifier for the periodic send</param>
/// <param name="interval">How often</param>
/// <param name="objGetter">Method returning the object to send</param>
void SendPeriodic(string identifier, TimeSpan interval, Func<SocketConnection, object> objGetter);
/// <summary>
/// Unsubscribe all subscriptions
/// </summary>
/// <returns></returns>
Task UnsubscribeAllAsync();
/// <summary>
/// Unsubscribe an update subscription
/// </summary>
/// <param name="subscriptionId">The id of the subscription to unsubscribe</param>
/// <returns></returns>
Task<bool> UnsubscribeAsync(int subscriptionId);
/// <summary>
/// Unsubscribe an update subscription
/// </summary>
/// <param name="subscription">The subscription to unsubscribe</param>
/// <returns></returns>
Task UnsubscribeAsync(UpdateSubscription subscription);
}
}

View File

@ -16,12 +16,6 @@ namespace CryptoExchange.Net.Interfaces
/// </summary> /// </summary>
ClientOptions ClientOptions { get; } ClientOptions ClientOptions { get; }
/// <summary>
/// Set the API credentials for this client. All Api clients in this client will use the new credentials, regardless of earlier set options.
/// </summary>
/// <param name="credentials">The credentials to set</param>
void SetApiCredentials(ApiCredentials credentials);
/// <summary> /// <summary>
/// Incoming kilobytes per second of data /// Incoming kilobytes per second of data
/// </summary> /// </summary>