using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
namespace Synor.Sdk.Rpc;
///
/// Synor RPC SDK client for C#.
///
/// Provides blockchain data queries, transaction submission,
/// and real-time subscriptions via WebSocket.
///
///
///
/// 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();
///
///
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> _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
};
}
///
/// Get the latest block.
///
public async Task GetLatestBlockAsync(CancellationToken cancellationToken = default)
{
return await GetAsync("/blocks/latest", cancellationToken);
}
///
/// Get a block by hash or height.
///
public async Task GetBlockAsync(
string hashOrHeight,
CancellationToken cancellationToken = default)
{
return await GetAsync($"/blocks/{hashOrHeight}", cancellationToken);
}
///
/// Get a block header by hash or height.
///
public async Task GetBlockHeaderAsync(
string hashOrHeight,
CancellationToken cancellationToken = default)
{
return await GetAsync($"/blocks/{hashOrHeight}/header", cancellationToken);
}
///
/// Get a range of blocks.
///
public async Task> GetBlocksAsync(
long startHeight,
long endHeight,
CancellationToken cancellationToken = default)
{
return await GetAsync>(
$"/blocks?start={startHeight}&end={endHeight}",
cancellationToken);
}
///
/// Get a transaction by ID.
///
public async Task GetTransactionAsync(
string txid,
CancellationToken cancellationToken = default)
{
return await GetAsync($"/transactions/{txid}", cancellationToken);
}
///
/// Get raw transaction hex.
///
public async Task GetRawTransactionAsync(
string txid,
CancellationToken cancellationToken = default)
{
var response = await GetAsync($"/transactions/{txid}/raw", cancellationToken);
return response.GetProperty("hex").GetString() ?? throw new RpcException("Invalid response");
}
///
/// Send a raw transaction.
///
public async Task SendRawTransactionAsync(
string hex,
CancellationToken cancellationToken = default)
{
var request = new { hex };
return await PostAsync("/transactions/send", request, cancellationToken);
}
///
/// Decode a raw transaction.
///
public async Task DecodeRawTransactionAsync(
string hex,
CancellationToken cancellationToken = default)
{
var request = new { hex };
return await PostAsync("/transactions/decode", request, cancellationToken);
}
///
/// Get transactions for an address.
///
public async Task> GetAddressTransactionsAsync(
string address,
int limit = 20,
int offset = 0,
CancellationToken cancellationToken = default)
{
return await GetAsync>(
$"/addresses/{address}/transactions?limit={limit}&offset={offset}",
cancellationToken);
}
///
/// Estimate transaction fee.
///
public async Task EstimateFeeAsync(
RpcPriority priority = RpcPriority.Medium,
CancellationToken cancellationToken = default)
{
return await GetAsync(
$"/fees/estimate?priority={priority.ToString().ToLower()}",
cancellationToken);
}
///
/// Get all fee estimates.
///
public async Task> GetAllFeeEstimatesAsync(
CancellationToken cancellationToken = default)
{
var estimates = await GetAsync>("/fees/estimates", cancellationToken);
return estimates.ToDictionary(e => e.Priority, e => e);
}
///
/// Get chain information.
///
public async Task GetChainInfoAsync(CancellationToken cancellationToken = default)
{
return await GetAsync("/chain/info", cancellationToken);
}
///
/// Get mempool information.
///
public async Task GetMempoolInfoAsync(CancellationToken cancellationToken = default)
{
return await GetAsync("/mempool/info", cancellationToken);
}
///
/// Get mempool transaction IDs.
///
public async Task> GetMempoolTransactionsAsync(
int limit = 100,
CancellationToken cancellationToken = default)
{
return await GetAsync>($"/mempool/transactions?limit={limit}", cancellationToken);
}
///
/// Subscribe to new blocks.
///
public async Task SubscribeBlocksAsync(
Action callback,
CancellationToken cancellationToken = default)
{
await EnsureWebSocketConnectedAsync(cancellationToken);
var subscriptionId = Guid.NewGuid().ToString();
_subscriptionCallbacks[subscriptionId] = element =>
{
var block = element.Deserialize(_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);
});
}
///
/// Subscribe to address transactions.
///
public async Task SubscribeAddressAsync(
string address,
Action callback,
CancellationToken cancellationToken = default)
{
await EnsureWebSocketConnectedAsync(cancellationToken);
var subscriptionId = Guid.NewGuid().ToString();
_subscriptionCallbacks[subscriptionId] = element =>
{
var tx = element.Deserialize(_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);
});
}
///
/// Subscribe to mempool transactions.
///
public async Task SubscribeMempoolAsync(
Action callback,
CancellationToken cancellationToken = default)
{
await EnsureWebSocketConnectedAsync(cancellationToken);
var subscriptionId = Guid.NewGuid().ToString();
_subscriptionCallbacks[subscriptionId] = element =>
{
var tx = element.Deserialize(_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 GetAsync(string path, CancellationToken cancellationToken)
{
return await ExecuteWithRetryAsync(async () =>
{
var response = await _httpClient.GetAsync(path, cancellationToken);
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken)
?? throw new RpcException("Invalid response");
});
}
private async Task PostAsync(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(_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 ExecuteWithRetryAsync(Func> 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);
}
}