Files
managing-apps/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs

694 lines
27 KiB
C#

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<HttpResponseMessage> _retryPolicy;
private readonly ILogger<Web3ProxyService> _logger;
public Web3ProxyService(IOptions<Web3ProxySettings> options, ILogger<Web3ProxyService> 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<HttpRequestException>()
.Or<TaskCanceledException>()
.Or<TimeoutException>()
.OrResult<HttpResponseMessage>(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;
}
/// <summary>
/// Checks if an error message indicates insufficient funds or allowance that should not be retried
/// </summary>
/// <param name="errorMessage">The error message to check</param>
/// <returns>True if this is a non-retryable insufficient funds error</returns>
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");
}
/// <summary>
/// Determines the type of insufficient funds error based on the error message
/// </summary>
/// <param name="errorMessage">The error message to analyze</param>
/// <returns>The type of insufficient funds error</returns>
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<T> ExecuteWithRetryAsync<T>(Func<Task<HttpResponseMessage>> httpCall, string operationName)
{
try
{
var response = await _retryPolicy.ExecuteAsync(httpCall);
if (!response.IsSuccessStatusCode)
{
await HandleErrorResponse(response);
}
var result = await response.Content.ReadFromJsonAsync<T>(_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<T> ExecuteWithRetryAsync<T>(Func<Task<HttpResponseMessage>> httpCall, string operationName,
string idempotencyKey)
{
try
{
var response = await _retryPolicy.ExecuteAsync(httpCall);
if (!response.IsSuccessStatusCode)
{
await HandleErrorResponse(response);
}
var result = await response.Content.ReadFromJsonAsync<T>(_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<T> CallPrivyServiceAsync<T>(string endpoint, object payload)
{
if (!endpoint.StartsWith("/"))
{
endpoint = $"/{endpoint}";
}
var url = $"{_settings.BaseUrl}/api/privy{endpoint}";
var idempotencyKey = Guid.NewGuid().ToString();
return await ExecuteWithRetryAsync<T>(
() =>
{
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<T> GetPrivyServiceAsync<T>(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<T>(
() => _httpClient.GetAsync(url),
$"GetPrivyServiceAsync({endpoint})");
}
public async Task<T> CallGmxServiceAsync<T>(string endpoint, object payload)
{
if (!endpoint.StartsWith("/"))
{
endpoint = $"/{endpoint}";
}
var url = $"{_settings.BaseUrl}/api/gmx{endpoint}";
var idempotencyKey = Guid.NewGuid().ToString();
return await ExecuteWithRetryAsync<T>(
() =>
{
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<T> GetGmxServiceAsync<T>(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<T>(
() => _httpClient.GetAsync(url),
$"GetGmxServiceAsync({endpoint})");
}
public async Task<GmxClaimableSummary> GetGmxClaimableSummaryAsync(string account)
{
var payload = new { account };
var response = await GetGmxServiceAsync<ClaimingFeesResponse>("/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<SwapInfos> 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<GmxSwapResponse>("/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<SwapInfos> 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<Web3ProxyTokenSendResponse>("/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<List<Balance>> GetWalletBalanceAsync(string address, Ticker[] assets, string[] chains)
{
var payload = new
{
address,
asset = assets,
chain = chains
};
var response = await GetPrivyServiceAsync<Web3ProxyBalanceResponse>("/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<Balance>();
}
public async Task<decimal> GetEstimatedGasFeeUsdAsync()
{
var response = await GetGmxServiceAsync<GasFeeResponse>("/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<GasFeeData> GetGasFeeDataAsync()
{
var response = await GetGmxServiceAsync<GasFeeResponse>("/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<Web3ProxyResponse>(_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<Web3ProxyErrorResponse>(_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<List<Position>> GetGmxPositionHistoryAsync(string account, int pageIndex = 0,
int pageSize = 20, string? ticker = null)
{
var payload = new
{
account,
pageIndex,
pageSize,
ticker
};
var response = await GetGmxServiceAsync<GetGmxPositionHistoryResponse>("/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<Position>();
if (response.Positions != null)
{
foreach (var g in response.Positions)
{
try
{
var tickerEnum = MiscExtensions.ParseEnum<Ticker>(g.Ticker);
var directionEnum = MiscExtensions.ParseEnum<TradeDirection>(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<PositionStatus>(g.Status);
// Open trade represents the closing event from GMX history
if (g.Open != null)
{
position.Open = new Trade(
g.Open.Date,
MiscExtensions.ParseEnum<TradeDirection>(g.Open.Direction),
MiscExtensions.ParseEnum<TradeStatus>(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;
}
}
}