- Implement SynorStorage class for decentralized storage operations including upload, download, pinning, and CAR file management. - Create supporting types and models for storage operations such as UploadOptions, Pin, and StorageConfig. - Implement SynorWallet class for wallet operations including wallet creation, address generation, transaction signing, and balance queries. - Create supporting types and models for wallet operations such as Wallet, Address, and Transaction. - Introduce error handling for both storage and wallet operations.
477 lines
15 KiB
C#
477 lines
15 KiB
C#
using System.Net.Http.Json;
|
|
using System.Net.WebSockets;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace Synor.Sdk.Rpc;
|
|
|
|
/// <summary>
|
|
/// Synor RPC SDK client for C#.
|
|
///
|
|
/// Provides blockchain data queries, transaction submission,
|
|
/// and real-time subscriptions via WebSocket.
|
|
/// </summary>
|
|
/// <example>
|
|
/// <code>
|
|
/// var rpc = new SynorRpc(new RpcConfig { ApiKey = "your-api-key" });
|
|
///
|
|
/// // Get latest block
|
|
/// var block = await rpc.GetLatestBlockAsync();
|
|
/// Console.WriteLine($"Block height: {block.Height}");
|
|
///
|
|
/// // Subscribe to new blocks
|
|
/// var subscription = await rpc.SubscribeBlocksAsync(block => {
|
|
/// Console.WriteLine($"New block: {block.Height}");
|
|
/// });
|
|
///
|
|
/// // Later: cancel subscription
|
|
/// subscription.Cancel();
|
|
/// rpc.Dispose();
|
|
/// </code>
|
|
/// </example>
|
|
public class SynorRpc : IDisposable
|
|
{
|
|
private readonly RpcConfig _config;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly JsonSerializerOptions _jsonOptions;
|
|
private ClientWebSocket? _webSocket;
|
|
private CancellationTokenSource? _wsCancellation;
|
|
private readonly Dictionary<string, Action<JsonElement>> _subscriptionCallbacks = new();
|
|
private bool _disposed;
|
|
|
|
public SynorRpc(RpcConfig config)
|
|
{
|
|
_config = config;
|
|
_httpClient = new HttpClient
|
|
{
|
|
BaseAddress = new Uri(config.Endpoint),
|
|
Timeout = config.Timeout
|
|
};
|
|
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {config.ApiKey}");
|
|
|
|
_jsonOptions = new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the latest block.
|
|
/// </summary>
|
|
public async Task<Block> GetLatestBlockAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetAsync<Block>("/blocks/latest", cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get a block by hash or height.
|
|
/// </summary>
|
|
public async Task<Block> GetBlockAsync(
|
|
string hashOrHeight,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetAsync<Block>($"/blocks/{hashOrHeight}", cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get a block header by hash or height.
|
|
/// </summary>
|
|
public async Task<BlockHeader> GetBlockHeaderAsync(
|
|
string hashOrHeight,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetAsync<BlockHeader>($"/blocks/{hashOrHeight}/header", cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get a range of blocks.
|
|
/// </summary>
|
|
public async Task<List<Block>> GetBlocksAsync(
|
|
long startHeight,
|
|
long endHeight,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetAsync<List<Block>>(
|
|
$"/blocks?start={startHeight}&end={endHeight}",
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get a transaction by ID.
|
|
/// </summary>
|
|
public async Task<RpcTransaction> GetTransactionAsync(
|
|
string txid,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetAsync<RpcTransaction>($"/transactions/{txid}", cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get raw transaction hex.
|
|
/// </summary>
|
|
public async Task<string> GetRawTransactionAsync(
|
|
string txid,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var response = await GetAsync<JsonElement>($"/transactions/{txid}/raw", cancellationToken);
|
|
return response.GetProperty("hex").GetString() ?? throw new RpcException("Invalid response");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Send a raw transaction.
|
|
/// </summary>
|
|
public async Task<SubmitResult> SendRawTransactionAsync(
|
|
string hex,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var request = new { hex };
|
|
return await PostAsync<SubmitResult>("/transactions/send", request, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decode a raw transaction.
|
|
/// </summary>
|
|
public async Task<RpcTransaction> DecodeRawTransactionAsync(
|
|
string hex,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var request = new { hex };
|
|
return await PostAsync<RpcTransaction>("/transactions/decode", request, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get transactions for an address.
|
|
/// </summary>
|
|
public async Task<List<RpcTransaction>> GetAddressTransactionsAsync(
|
|
string address,
|
|
int limit = 20,
|
|
int offset = 0,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetAsync<List<RpcTransaction>>(
|
|
$"/addresses/{address}/transactions?limit={limit}&offset={offset}",
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Estimate transaction fee.
|
|
/// </summary>
|
|
public async Task<FeeEstimate> EstimateFeeAsync(
|
|
RpcPriority priority = RpcPriority.Medium,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetAsync<FeeEstimate>(
|
|
$"/fees/estimate?priority={priority.ToString().ToLower()}",
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get all fee estimates.
|
|
/// </summary>
|
|
public async Task<Dictionary<RpcPriority, FeeEstimate>> GetAllFeeEstimatesAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var estimates = await GetAsync<List<FeeEstimate>>("/fees/estimates", cancellationToken);
|
|
return estimates.ToDictionary(e => e.Priority, e => e);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get chain information.
|
|
/// </summary>
|
|
public async Task<ChainInfo> GetChainInfoAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetAsync<ChainInfo>("/chain/info", cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get mempool information.
|
|
/// </summary>
|
|
public async Task<MempoolInfo> GetMempoolInfoAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetAsync<MempoolInfo>("/mempool/info", cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get mempool transaction IDs.
|
|
/// </summary>
|
|
public async Task<List<string>> GetMempoolTransactionsAsync(
|
|
int limit = 100,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetAsync<List<string>>($"/mempool/transactions?limit={limit}", cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscribe to new blocks.
|
|
/// </summary>
|
|
public async Task<Subscription> SubscribeBlocksAsync(
|
|
Action<Block> callback,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await EnsureWebSocketConnectedAsync(cancellationToken);
|
|
|
|
var subscriptionId = Guid.NewGuid().ToString();
|
|
_subscriptionCallbacks[subscriptionId] = element =>
|
|
{
|
|
var block = element.Deserialize<Block>(_jsonOptions);
|
|
if (block != null) callback(block);
|
|
};
|
|
|
|
var subscribeMessage = JsonSerializer.Serialize(new
|
|
{
|
|
type = "subscribe",
|
|
channel = "blocks",
|
|
subscription_id = subscriptionId
|
|
});
|
|
|
|
await SendWebSocketMessageAsync(subscribeMessage, cancellationToken);
|
|
|
|
return new Subscription(subscriptionId, "blocks", () =>
|
|
{
|
|
_subscriptionCallbacks.Remove(subscriptionId);
|
|
var unsubscribeMessage = JsonSerializer.Serialize(new
|
|
{
|
|
type = "unsubscribe",
|
|
subscription_id = subscriptionId
|
|
});
|
|
_ = SendWebSocketMessageAsync(unsubscribeMessage, CancellationToken.None);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscribe to address transactions.
|
|
/// </summary>
|
|
public async Task<Subscription> SubscribeAddressAsync(
|
|
string address,
|
|
Action<RpcTransaction> callback,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await EnsureWebSocketConnectedAsync(cancellationToken);
|
|
|
|
var subscriptionId = Guid.NewGuid().ToString();
|
|
_subscriptionCallbacks[subscriptionId] = element =>
|
|
{
|
|
var tx = element.Deserialize<RpcTransaction>(_jsonOptions);
|
|
if (tx != null) callback(tx);
|
|
};
|
|
|
|
var subscribeMessage = JsonSerializer.Serialize(new
|
|
{
|
|
type = "subscribe",
|
|
channel = "address",
|
|
address,
|
|
subscription_id = subscriptionId
|
|
});
|
|
|
|
await SendWebSocketMessageAsync(subscribeMessage, cancellationToken);
|
|
|
|
return new Subscription(subscriptionId, $"address:{address}", () =>
|
|
{
|
|
_subscriptionCallbacks.Remove(subscriptionId);
|
|
var unsubscribeMessage = JsonSerializer.Serialize(new
|
|
{
|
|
type = "unsubscribe",
|
|
subscription_id = subscriptionId
|
|
});
|
|
_ = SendWebSocketMessageAsync(unsubscribeMessage, CancellationToken.None);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscribe to mempool transactions.
|
|
/// </summary>
|
|
public async Task<Subscription> SubscribeMempoolAsync(
|
|
Action<RpcTransaction> callback,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await EnsureWebSocketConnectedAsync(cancellationToken);
|
|
|
|
var subscriptionId = Guid.NewGuid().ToString();
|
|
_subscriptionCallbacks[subscriptionId] = element =>
|
|
{
|
|
var tx = element.Deserialize<RpcTransaction>(_jsonOptions);
|
|
if (tx != null) callback(tx);
|
|
};
|
|
|
|
var subscribeMessage = JsonSerializer.Serialize(new
|
|
{
|
|
type = "subscribe",
|
|
channel = "mempool",
|
|
subscription_id = subscriptionId
|
|
});
|
|
|
|
await SendWebSocketMessageAsync(subscribeMessage, cancellationToken);
|
|
|
|
return new Subscription(subscriptionId, "mempool", () =>
|
|
{
|
|
_subscriptionCallbacks.Remove(subscriptionId);
|
|
var unsubscribeMessage = JsonSerializer.Serialize(new
|
|
{
|
|
type = "unsubscribe",
|
|
subscription_id = subscriptionId
|
|
});
|
|
_ = SendWebSocketMessageAsync(unsubscribeMessage, CancellationToken.None);
|
|
});
|
|
}
|
|
|
|
private async Task EnsureWebSocketConnectedAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (_webSocket?.State == WebSocketState.Open)
|
|
return;
|
|
|
|
_webSocket?.Dispose();
|
|
_wsCancellation?.Cancel();
|
|
_wsCancellation?.Dispose();
|
|
|
|
_webSocket = new ClientWebSocket();
|
|
_webSocket.Options.SetRequestHeader("Authorization", $"Bearer {_config.ApiKey}");
|
|
_wsCancellation = new CancellationTokenSource();
|
|
|
|
await _webSocket.ConnectAsync(new Uri(_config.WsEndpoint), cancellationToken);
|
|
_ = ReceiveWebSocketMessagesAsync(_wsCancellation.Token);
|
|
}
|
|
|
|
private async Task ReceiveWebSocketMessagesAsync(CancellationToken cancellationToken)
|
|
{
|
|
var buffer = new byte[8192];
|
|
|
|
try
|
|
{
|
|
while (_webSocket?.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
|
{
|
|
var result = await _webSocket.ReceiveAsync(buffer, cancellationToken);
|
|
|
|
if (result.MessageType == WebSocketMessageType.Close)
|
|
break;
|
|
|
|
if (result.MessageType == WebSocketMessageType.Text)
|
|
{
|
|
var json = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
|
ProcessWebSocketMessage(json);
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected when cancelling
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (_config.Debug)
|
|
{
|
|
Console.WriteLine($"WebSocket error: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ProcessWebSocketMessage(string json)
|
|
{
|
|
try
|
|
{
|
|
var doc = JsonDocument.Parse(json);
|
|
var root = doc.RootElement;
|
|
|
|
if (root.TryGetProperty("subscription_id", out var subIdElement))
|
|
{
|
|
var subscriptionId = subIdElement.GetString();
|
|
if (subscriptionId != null && _subscriptionCallbacks.TryGetValue(subscriptionId, out var callback))
|
|
{
|
|
if (root.TryGetProperty("data", out var data))
|
|
{
|
|
callback(data);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (_config.Debug)
|
|
{
|
|
Console.WriteLine($"Error processing WebSocket message: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SendWebSocketMessageAsync(string message, CancellationToken cancellationToken)
|
|
{
|
|
if (_webSocket?.State == WebSocketState.Open)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(message);
|
|
await _webSocket.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken);
|
|
}
|
|
}
|
|
|
|
private async Task<T> GetAsync<T>(string path, CancellationToken cancellationToken)
|
|
{
|
|
return await ExecuteWithRetryAsync(async () =>
|
|
{
|
|
var response = await _httpClient.GetAsync(path, cancellationToken);
|
|
await EnsureSuccessAsync(response);
|
|
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken)
|
|
?? throw new RpcException("Invalid response");
|
|
});
|
|
}
|
|
|
|
private async Task<T> PostAsync<T>(string path, object body, CancellationToken cancellationToken)
|
|
{
|
|
return await ExecuteWithRetryAsync(async () =>
|
|
{
|
|
var response = await _httpClient.PostAsJsonAsync(path, body, _jsonOptions, cancellationToken);
|
|
await EnsureSuccessAsync(response);
|
|
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken)
|
|
?? throw new RpcException("Invalid response");
|
|
});
|
|
}
|
|
|
|
private async Task EnsureSuccessAsync(HttpResponseMessage response)
|
|
{
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
throw new RpcException(
|
|
$"HTTP error: {content}",
|
|
statusCode: (int)response.StatusCode);
|
|
}
|
|
}
|
|
|
|
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation)
|
|
{
|
|
Exception? lastException = null;
|
|
|
|
for (int attempt = 0; attempt < _config.Retries; attempt++)
|
|
{
|
|
try
|
|
{
|
|
return await operation();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
lastException = ex;
|
|
if (_config.Debug)
|
|
{
|
|
Console.WriteLine($"Attempt {attempt + 1} failed: {ex.Message}");
|
|
}
|
|
if (attempt < _config.Retries - 1)
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(attempt + 1));
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastException ?? new RpcException("Unknown error after retries");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (!_disposed)
|
|
{
|
|
_wsCancellation?.Cancel();
|
|
_wsCancellation?.Dispose();
|
|
_webSocket?.Dispose();
|
|
_httpClient.Dispose();
|
|
_disposed = true;
|
|
}
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|