using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
namespace Synor.Contract
{
///
/// Synor Contract SDK client for C#/.NET.
/// Smart contract deployment, interaction, and event handling.
///
public sealed class ContractClient : IDisposable
{
public const string Version = "0.1.0";
private readonly ContractConfig _config;
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
private bool _closed;
public ContractClient(ContractConfig config)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_httpClient = new HttpClient
{
BaseAddress = new Uri(config.Endpoint),
Timeout = TimeSpan.FromMilliseconds(config.TimeoutMs)
};
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {config.ApiKey}");
_httpClient.DefaultRequestHeaders.Add("X-SDK-Version", $"csharp/{Version}");
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
#region Contract Deployment
public async Task DeployAsync(DeployContractOptions options, CancellationToken ct = default)
{
return await PostAsync("/contract/deploy", options, ct);
}
public async Task DeployCreate2Async(DeployContractOptions options, string salt, CancellationToken ct = default)
{
var body = new Dictionary
{
["bytecode"] = options.Bytecode,
["salt"] = salt,
["abi"] = options.Abi,
["constructor_args"] = options.ConstructorArgs,
["value"] = options.Value,
["gas_limit"] = options.GasLimit,
["gas_price"] = options.GasPrice
};
return await PostAsync("/contract/deploy/create2", body, ct);
}
public async Task PredictAddressAsync(string bytecode, string salt, string? deployer = null, CancellationToken ct = default)
{
var body = new { bytecode, salt, deployer };
var response = await PostAsync>("/contract/predict-address", body, ct);
return response["address"]?.ToString() ?? throw new ContractException("Missing address");
}
#endregion
#region Contract Interaction
public async Task CallAsync(CallContractOptions options, CancellationToken ct = default)
{
return await PostAsync("/contract/call", options, ct);
}
public async Task SendAsync(SendContractOptions options, CancellationToken ct = default)
{
return await PostAsync("/contract/send", options, ct);
}
#endregion
#region Events
public async Task GetEventsAsync(EventFilter filter, CancellationToken ct = default)
{
return await PostAsync("/contract/events", filter, ct);
}
public async Task GetLogsAsync(string contract, long? fromBlock = null, long? toBlock = null, CancellationToken ct = default)
{
var path = $"/contract/logs?contract={HttpUtility.UrlEncode(contract)}";
if (fromBlock.HasValue) path += $"&from_block={fromBlock}";
if (toBlock.HasValue) path += $"&to_block={toBlock}";
return await GetAsync(path, ct);
}
public async Task DecodeLogsAsync(IEnumerable logs, IEnumerable abi, CancellationToken ct = default)
{
var body = new { logs, abi };
return await PostAsync("/contract/decode-logs", body, ct);
}
#endregion
#region ABI Utilities
public async Task EncodeCallAsync(EncodeCallOptions options, CancellationToken ct = default)
{
var response = await PostAsync>("/contract/encode", options, ct);
return response["data"]?.ToString() ?? throw new ContractException("Missing data");
}
public async Task DecodeResultAsync(DecodeResultOptions options, CancellationToken ct = default)
{
var response = await PostAsync>("/contract/decode", options, ct);
return response["result"];
}
public async Task GetSelectorAsync(string signature, CancellationToken ct = default)
{
var response = await GetAsync>($"/contract/selector?signature={HttpUtility.UrlEncode(signature)}", ct);
return response["selector"]?.ToString() ?? throw new ContractException("Missing selector");
}
#endregion
#region Gas Estimation
public async Task EstimateGasAsync(EstimateGasOptions options, CancellationToken ct = default)
{
return await PostAsync("/contract/estimate-gas", options, ct);
}
#endregion
#region Contract Information
public async Task GetBytecodeAsync(string address, CancellationToken ct = default)
{
return await GetAsync($"/contract/{HttpUtility.UrlEncode(address)}/bytecode", ct);
}
public async Task VerifyAsync(VerifyContractOptions options, CancellationToken ct = default)
{
return await PostAsync("/contract/verify", options, ct);
}
public async Task GetVerificationStatusAsync(string address, CancellationToken ct = default)
{
return await GetAsync($"/contract/{HttpUtility.UrlEncode(address)}/verification", ct);
}
#endregion
#region Multicall
public async Task MulticallAsync(IEnumerable requests, CancellationToken ct = default)
{
var body = new { calls = requests };
return await PostAsync("/contract/multicall", body, ct);
}
#endregion
#region Storage
public async Task ReadStorageAsync(ReadStorageOptions options, CancellationToken ct = default)
{
var path = $"/contract/storage?contract={HttpUtility.UrlEncode(options.Contract)}&slot={HttpUtility.UrlEncode(options.Slot)}";
if (options.BlockNumber.HasValue) path += $"&block={options.BlockNumber}";
var response = await GetAsync>(path, ct);
return response["value"]?.ToString() ?? throw new ContractException("Missing value");
}
#endregion
#region Lifecycle
public async Task HealthCheckAsync(CancellationToken ct = default)
{
if (_closed) return false;
try
{
var response = await GetAsync>("/health", ct);
return response.TryGetValue("status", out var status) &&
status is JsonElement elem && elem.GetString() == "healthy";
}
catch
{
return false;
}
}
public void Dispose()
{
_closed = true;
_httpClient.Dispose();
}
public bool IsClosed => _closed;
#endregion
#region HTTP Helpers
private async Task GetAsync(string path, CancellationToken ct)
{
ThrowIfClosed();
return await ExecuteWithRetryAsync(async () =>
{
var response = await _httpClient.GetAsync(path, ct);
return await HandleResponseAsync(response, ct);
});
}
private async Task PostAsync(string path, object body, CancellationToken ct)
{
ThrowIfClosed();
return await ExecuteWithRetryAsync(async () =>
{
var response = await _httpClient.PostAsJsonAsync(path, body, _jsonOptions, ct);
return await HandleResponseAsync(response, ct);
});
}
private async Task HandleResponseAsync(HttpResponseMessage response, CancellationToken ct)
{
var content = await response.Content.ReadAsStringAsync(ct);
if (response.IsSuccessStatusCode)
{
return JsonSerializer.Deserialize(content, _jsonOptions)
?? throw new ContractException("Failed to deserialize response");
}
try
{
var error = JsonSerializer.Deserialize>(content, _jsonOptions);
var message = error?.GetValueOrDefault("message")?.ToString()
?? error?.GetValueOrDefault("error")?.ToString()
?? "Unknown error";
var code = error?.GetValueOrDefault("code")?.ToString();
throw new ContractException(message, (int)response.StatusCode, code);
}
catch (JsonException)
{
throw new ContractException(content, (int)response.StatusCode);
}
}
private async Task ExecuteWithRetryAsync(Func> action)
{
Exception? lastException = null;
for (var attempt = 0; attempt < _config.Retries; attempt++)
{
try
{
return await action();
}
catch (Exception ex)
{
lastException = ex;
if (attempt < _config.Retries - 1)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
}
}
}
throw lastException ?? new ContractException("Request failed");
}
private void ThrowIfClosed()
{
if (_closed) throw new ContractException("Client has been closed");
}
#endregion
}
public class ContractConfig
{
public required string ApiKey { get; init; }
public string Endpoint { get; init; } = "https://contract.synor.io";
public int TimeoutMs { get; init; } = 30000;
public int Retries { get; init; } = 3;
}
public class ContractException : Exception
{
public int? StatusCode { get; }
public string? Code { get; }
public ContractException(string message, int? statusCode = null, string? code = null)
: base(message)
{
StatusCode = statusCode;
Code = code;
}
}
}