using System.Collections; using System.Net; using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Web; using Managing.Application.Abstractions.Services; using Managing.Core; using Managing.Core.Exceptions; using Managing.Domain.Accounts; using Managing.Domain.Evm; using Managing.Domain.Trades; using Managing.Infrastructure.Evm.Models.Proxy; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Polly; using static Managing.Common.Enums; namespace Managing.Infrastructure.Evm.Services { public class Web3ProxySettings { public string BaseUrl { get; set; } = "http://localhost:3000"; public int MaxRetryAttempts { get; set; } = 3; public int RetryDelayMs { get; set; } = 1000; public int TimeoutSeconds { get; set; } = 30; } public class Web3ProxyService : IWeb3ProxyService { private readonly HttpClient _httpClient; private readonly Web3ProxySettings _settings; private readonly JsonSerializerOptions _jsonOptions; private readonly IAsyncPolicy _retryPolicy; private readonly ILogger _logger; public Web3ProxyService(IOptions options, ILogger logger) { _settings = options.Value; _logger = logger; _httpClient = new HttpClient(); _httpClient.Timeout = TimeSpan.FromSeconds(_settings.TimeoutSeconds); _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; // Configure retry policy _retryPolicy = Policy .Handle() .Or() .Or() .OrResult(r => !r.IsSuccessStatusCode && IsRetryableStatusCode(r.StatusCode)) .WaitAndRetryAsync( retryCount: _settings.MaxRetryAttempts, sleepDurationProvider: retryAttempt => TimeSpan.FromMilliseconds( _settings.RetryDelayMs * Math.Pow(2, retryAttempt - 1) + // Exponential backoff new Random().Next(0, _settings.RetryDelayMs / 4) // Add jitter ), onRetry: (outcome, timespan, retryCount, context) => { var exception = outcome.Exception; var response = outcome.Result; var errorMessage = exception?.Message ?? $"HTTP {response?.StatusCode}"; _logger.LogWarning( "Web3Proxy request failed (attempt {RetryCount}/{MaxRetries}): {Error}. Retrying in {Delay}ms", retryCount, _settings.MaxRetryAttempts + 1, errorMessage, timespan.TotalMilliseconds); }); } private static bool IsRetryableStatusCode(HttpStatusCode statusCode) { return statusCode == HttpStatusCode.RequestTimeout || statusCode == HttpStatusCode.TooManyRequests || statusCode == HttpStatusCode.InternalServerError || statusCode == HttpStatusCode.BadGateway || statusCode == HttpStatusCode.ServiceUnavailable || statusCode == HttpStatusCode.GatewayTimeout; } /// /// Checks if an error message indicates insufficient funds or allowance that should not be retried /// /// The error message to check /// True if this is a non-retryable insufficient funds error private static bool IsInsufficientFundsError(string errorMessage) { if (string.IsNullOrEmpty(errorMessage)) return false; var lowerError = errorMessage.ToLowerInvariant(); // Check for common insufficient funds/allowance error patterns return lowerError.Contains("erc20: transfer amount exceeds allowance") || lowerError.Contains("insufficient funds") || lowerError.Contains("insufficient balance") || lowerError.Contains("execution reverted") && (lowerError.Contains("allowance") || lowerError.Contains("balance")) || lowerError.Contains("gas required exceeds allowance") || lowerError.Contains("out of gas") || lowerError.Contains("insufficient eth") || lowerError.Contains("insufficient token"); } /// /// Determines the type of insufficient funds error based on the error message /// /// The error message to analyze /// The type of insufficient funds error private static InsufficientFundsType GetInsufficientFundsType(string errorMessage) { if (string.IsNullOrEmpty(errorMessage)) return InsufficientFundsType.General; var lowerError = errorMessage.ToLowerInvariant(); if (lowerError.Contains("erc20: transfer amount exceeds allowance") || lowerError.Contains("allowance")) { return InsufficientFundsType.InsufficientAllowance; } if (lowerError.Contains("gas required exceeds allowance") || lowerError.Contains("out of gas") || lowerError.Contains("insufficient eth")) { return InsufficientFundsType.InsufficientEth; } if (lowerError.Contains("insufficient balance") || lowerError.Contains("insufficient token")) { return InsufficientFundsType.InsufficientBalance; } return InsufficientFundsType.General; } private async Task ExecuteWithRetryAsync(Func> httpCall, string operationName) { try { var response = await _retryPolicy.ExecuteAsync(httpCall); if (!response.IsSuccessStatusCode) { await HandleErrorResponse(response); } var result = await response.Content.ReadFromJsonAsync(_jsonOptions); return result ?? throw new Web3ProxyException($"Failed to deserialize response for {operationName}"); } catch (InsufficientFundsException) { // Re-throw insufficient funds exceptions immediately without retrying throw; } catch (Exception ex) when (!(ex is Web3ProxyException)) { _logger.LogError(ex, "Operation {OperationName} failed after all retry attempts", operationName); SentrySdk.CaptureException(ex); throw new Web3ProxyException($"Failed to execute {operationName}: {ex.Message}"); } } private async Task ExecuteWithRetryAsync(Func> httpCall, string operationName, string idempotencyKey) { try { var response = await _retryPolicy.ExecuteAsync(httpCall); if (!response.IsSuccessStatusCode) { await HandleErrorResponse(response); } var result = await response.Content.ReadFromJsonAsync(_jsonOptions); return result ?? throw new Web3ProxyException($"Failed to deserialize response for {operationName}"); } catch (InsufficientFundsException) { // Re-throw insufficient funds exceptions immediately without retrying throw; } catch (Exception ex) when (!(ex is Web3ProxyException)) { _logger.LogError(ex, "Operation {OperationName} failed after all retry attempts (IdempotencyKey: {IdempotencyKey})", operationName, idempotencyKey); SentrySdk.CaptureException(ex); throw new Web3ProxyException($"Failed to execute {operationName}: {ex.Message}"); } } public async Task CallPrivyServiceAsync(string endpoint, object payload) { if (!endpoint.StartsWith("/")) { endpoint = $"/{endpoint}"; } var url = $"{_settings.BaseUrl}/api/privy{endpoint}"; var idempotencyKey = Guid.NewGuid().ToString(); return await ExecuteWithRetryAsync( () => { var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(payload, options: _jsonOptions) }; request.Headers.Add("Idempotency-Key", idempotencyKey); return _httpClient.SendAsync(request); }, $"CallPrivyServiceAsync({endpoint})", idempotencyKey); } public async Task GetPrivyServiceAsync(string endpoint, object? payload = null) { if (!endpoint.StartsWith("/")) { endpoint = $"/{endpoint}"; } var url = $"{_settings.BaseUrl}/api/privy{endpoint}"; if (payload != null) { url += BuildQueryString(payload); } return await ExecuteWithRetryAsync( () => _httpClient.GetAsync(url), $"GetPrivyServiceAsync({endpoint})"); } public async Task CallGmxServiceAsync(string endpoint, object payload) { if (!endpoint.StartsWith("/")) { endpoint = $"/{endpoint}"; } var url = $"{_settings.BaseUrl}/api/gmx{endpoint}"; var idempotencyKey = Guid.NewGuid().ToString(); return await ExecuteWithRetryAsync( () => { var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(payload, options: _jsonOptions) }; request.Headers.Add("Idempotency-Key", idempotencyKey); return _httpClient.SendAsync(request); }, $"CallGmxServiceAsync({endpoint})", idempotencyKey); } public async Task GetGmxServiceAsync(string endpoint, object? payload = null) { if (!endpoint.StartsWith("/")) { endpoint = $"/{endpoint}"; } var url = $"{_settings.BaseUrl}/api/gmx{endpoint}"; if (payload != null) { url += BuildQueryString(payload); } return await ExecuteWithRetryAsync( () => _httpClient.GetAsync(url), $"GetGmxServiceAsync({endpoint})"); } public async Task GetGmxClaimableSummaryAsync(string account) { var payload = new { account }; var response = await GetGmxServiceAsync("/claimable-summary", payload); if (response.Data == null) { throw new Web3ProxyException("GMX claimable summary data is null"); } // Map from Web3Proxy response model to domain model return new GmxClaimableSummary { ClaimableFundingFees = new FundingFeesData { TotalUsdc = response.Data.ClaimableFundingFees.TotalUsdc }, ClaimableUiFees = new UiFeesData { TotalUsdc = response.Data.ClaimableUiFees.TotalUsdc }, RebateStats = new RebateStatsData { TotalRebateUsdc = response.Data.RebateStats.TotalRebateUsdc, DiscountUsdc = response.Data.RebateStats.DiscountUsdc, RebateFactor = response.Data.RebateStats.RebateFactor, DiscountFactor = response.Data.RebateStats.DiscountFactor } }; } public async Task SwapGmxTokensAsync(string account, Ticker fromTicker, Ticker toTicker, double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5) { var payload = new { account, fromTicker = fromTicker.ToString(), toTicker = toTicker.ToString(), amount, orderType, triggerRatio, allowedSlippage }; var response = await CallGmxServiceAsync("/swap-tokens", payload); if (response == null) { throw new Web3ProxyException("GMX swap response is null"); } // Map from infrastructure model to domain model return new SwapInfos { Success = response.Success, Hash = response.Hash, Message = response.Message, Error = null, // GmxSwapResponse doesn't have Error property ErrorType = response.ErrorType, Suggestion = response.Suggestion }; } public async Task SendTokenAsync(string senderAddress, string recipientAddress, Ticker ticker, decimal amount, int? chainId = null) { var payload = new { senderAddress, recipientAddress, ticker = ticker.ToString(), amount = amount.ToString(), // Convert decimal to string for bigint compatibility chainId }; var response = await CallPrivyServiceAsync("/send-token", payload); if (response == null) { throw new Web3ProxyException("Token send response is null"); } // Map from infrastructure model to domain model return new SwapInfos { Success = response.Success, Hash = response.Hash, Message = null, // Web3ProxyTokenSendResponse doesn't have Message property Error = response.Error, ErrorType = null, // Web3ProxyTokenSendResponse doesn't have ErrorType property Suggestion = null // Web3ProxyTokenSendResponse doesn't have Suggestion property }; } public async Task> GetWalletBalanceAsync(string address, Ticker[] assets, string[] chains) { var payload = new { address, asset = assets, chain = chains }; var response = await GetPrivyServiceAsync("/wallet-balance", payload); if (response == null) { throw new Web3ProxyException("Wallet balance response is null"); } if (!response.Success) { throw new Web3ProxyException($"Wallet balance request failed: {response.Error}"); } return response.Balances ?? new List(); } public async Task GetEstimatedGasFeeUsdAsync() { var response = await GetGmxServiceAsync("/gas-fee", null); if (response == null) { throw new Web3ProxyException("Gas fee response is null"); } if (!response.Success) { throw new Web3ProxyException($"Gas fee request failed: {response.Error}"); } if (response.Data is null) { throw new Web3ProxyException("Gas fee data is null"); } return (decimal)(response.Data.EstimatedGasFeeUsd ?? 0); } public async Task GetGasFeeDataAsync() { var response = await GetGmxServiceAsync("/gas-fee", null); if (response == null) { throw new Web3ProxyException("Gas fee response is null"); } if (!response.Success) { throw new Web3ProxyException($"Gas fee request failed: {response.Error}"); } if (response.Data is null) { throw new Web3ProxyException("Gas fee data is null"); } return response.Data; } private async Task HandleErrorResponse(HttpResponseMessage response) { var statusCode = (int)response.StatusCode; try { // Try to parse as the Web3Proxy error format (success: false, error: string) var content = await response.Content.ReadAsStringAsync(); var errorResponse = await response.Content.ReadFromJsonAsync(_jsonOptions); if (errorResponse != null && !errorResponse.Success && !string.IsNullOrEmpty(errorResponse.Error)) { // Check if this is an insufficient funds error that should not be retried if (IsInsufficientFundsError(errorResponse.Error)) { var errorType = GetInsufficientFundsType(errorResponse.Error); throw new InsufficientFundsException(errorResponse.Error, errorType); } // Handle the standard Web3Proxy error format throw new Web3ProxyException(errorResponse.Error); } // Fallback for other error formats try { // Try to parse as structured error if it doesn't match the simple format var structuredErrorResponse = await response.Content.ReadFromJsonAsync(_jsonOptions); if (structuredErrorResponse?.ErrorDetails != null) { structuredErrorResponse.ErrorDetails.StatusCode = statusCode; // Check if this is an insufficient funds error that should not be retried if (IsInsufficientFundsError(structuredErrorResponse.ErrorDetails.Message)) { var errorType = GetInsufficientFundsType(structuredErrorResponse.ErrorDetails.Message); throw new InsufficientFundsException(structuredErrorResponse.ErrorDetails.Message, errorType); } throw new Web3ProxyException(structuredErrorResponse.ErrorDetails); } } catch (Exception ex) when (ex is InsufficientFundsException) { // Re-throw insufficient funds exceptions as-is throw; } catch { // Check if the raw content contains insufficient funds errors if (IsInsufficientFundsError(content)) { var errorType = GetInsufficientFundsType(content); throw new InsufficientFundsException(content, errorType); } // If we couldn't parse as structured error, use the simple error or fallback throw new Web3ProxyException($"HTTP error {statusCode}: {content}"); } } catch (Exception ex) when (ex is InsufficientFundsException) { // Re-throw insufficient funds exceptions as-is throw; } catch (Exception ex) when (!(ex is Web3ProxyException)) { SentrySdk.CaptureException(ex); // If we couldn't parse the error as JSON or another issue occurred var content = await response.Content.ReadAsStringAsync(); // Check if the raw content contains insufficient funds errors if (IsInsufficientFundsError(content)) { var errorType = GetInsufficientFundsType(content); throw new InsufficientFundsException(content, errorType); } throw new Web3ProxyException($"HTTP error {statusCode}: {content}"); } } private string BuildQueryString(object payload) { var properties = payload.GetType().GetProperties(); if (properties.Length == 0) { return string.Empty; } var queryString = new StringBuilder("?"); bool isFirst = true; foreach (var prop in properties) { var value = prop.GetValue(payload); if (value != null) { var paramName = prop.Name; // Apply camelCase to match JSON property naming paramName = char.ToLowerInvariant(paramName[0]) + paramName.Substring(1); // Check if the value is an array or enumerable (but not string) if (value is IEnumerable enumerable && !(value is string)) { // Handle arrays by creating multiple query parameters with the same name foreach (var item in enumerable) { if (!isFirst) { queryString.Append("&"); } queryString.Append(HttpUtility.UrlEncode(paramName)); queryString.Append("="); queryString.Append(HttpUtility.UrlEncode(item?.ToString() ?? "")); isFirst = false; } } else { // Handle single values if (!isFirst) { queryString.Append("&"); } queryString.Append(HttpUtility.UrlEncode(paramName)); queryString.Append("="); queryString.Append(HttpUtility.UrlEncode(value.ToString())); isFirst = false; } } } return queryString.ToString(); } public async Task> GetGmxPositionHistoryAsync(string account, int pageIndex = 0, int pageSize = 20, string? ticker = null) { var payload = new { account, pageIndex, pageSize, ticker }; var response = await GetGmxServiceAsync("/position-history", payload); if (response == null) { throw new Web3ProxyException("Position history response is null"); } if (!response.Success) { throw new Web3ProxyException($"Position history request failed: {response.Error}"); } // Map to domain Positions manually var positions = new List(); if (response.Positions != null) { foreach (var g in response.Positions) { try { var tickerEnum = MiscExtensions.ParseEnum(g.Ticker); var directionEnum = MiscExtensions.ParseEnum(g.Direction); var position = new Position( Guid.NewGuid(), 0, // AccountId unknown at this layer directionEnum, tickerEnum, null, // MoneyManagement not available here PositionInitiator.Bot, g.Date, null // User not available here ); position.Status = MiscExtensions.ParseEnum(g.Status); // Open trade represents the closing event from GMX history if (g.Open != null) { position.Open = new Trade( g.Open.Date, MiscExtensions.ParseEnum(g.Open.Direction), MiscExtensions.ParseEnum(g.Open.Status), TradeType.Market, tickerEnum, (decimal)g.Open.Quantity, (decimal)g.Open.Price, (decimal)g.Open.Leverage, g.Open.ExchangeOrderId, string.Empty ); } else { position.Open = new Trade( g.Date, directionEnum, TradeStatus.Filled, TradeType.Market, tickerEnum, (decimal)g.Quantity, (decimal)g.Price, (decimal)g.Leverage, g.ExchangeOrderId, "GMX Position Close" ); } position.ProfitAndLoss = new ProfitAndLoss { Realized = g.ProfitAndLoss?.Realized != null ? (decimal)g.ProfitAndLoss.Realized : (decimal)g.Pnl, Net = g.ProfitAndLoss?.Net != null ? (decimal)g.ProfitAndLoss.Net : (decimal)g.Pnl }; position.UiFees = g.UiFees.HasValue ? (decimal)g.UiFees.Value : 0; position.GasFees = g.GasFees.HasValue ? (decimal)g.GasFees.Value : 0; positions.Add(position); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to map GMX position history entry"); } } } return positions; } } }