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); } }