From 40f3c66694278c13e52d062346db5170800a2fd4 Mon Sep 17 00:00:00 2001 From: cryptooda Date: Tue, 23 Sep 2025 14:03:46 +0700 Subject: [PATCH] Add ETH and USDC balance check before start/restart bot and autoswap --- src/Managing.Api/appsettings.Production.json | 4 +- src/Managing.Api/appsettings.Sandbox.json | 4 +- src/Managing.Api/appsettings.json | 4 +- .../Services/IExchangeService.cs | 2 +- .../Services/IWeb3ProxyService.cs | 2 + .../Abstractions/IBotService.cs | 23 +- .../Bots/Grains/AgentGrain.cs | 344 +++++++++--------- .../Bots/Models/AgentGrainState.cs | 124 ++++--- .../Bots/TradingBotBase.cs | 12 + .../ManageBot/BotService.cs | 121 +++++- .../ManageBot/StartBotCommandHandler.cs | 35 +- .../Handlers/OpenPositionCommandHandler.cs | 14 +- src/Managing.Common/Constants.cs | 4 +- .../Exceptions/CustomExceptions.cs | 75 ++++ .../Abstractions/IExchangeProcessor.cs | 2 +- .../ExchangeService.cs | 19 +- .../Exchanges/BaseProcessor.cs | 2 +- .../Exchanges/EvmProcessor.cs | 4 +- .../ExchangeServicesTests.cs | 4 +- .../EvmManager.cs | 14 +- .../Models/Proxy/Web3ProxyError.cs | 44 ++- .../Services/Web3ProxyService.cs | 126 +++++++ .../src/plugins/custom/gmx.ts | 148 +++++++- 23 files changed, 847 insertions(+), 284 deletions(-) diff --git a/src/Managing.Api/appsettings.Production.json b/src/Managing.Api/appsettings.Production.json index f20fa772..d19fce92 100644 --- a/src/Managing.Api/appsettings.Production.json +++ b/src/Managing.Api/appsettings.Production.json @@ -14,8 +14,8 @@ }, "Web3Proxy": { "BaseUrl": "http://srv-captain--web3-proxy:4111", - "MaxRetryAttempts": 3, - "RetryDelayMs": 1000, + "MaxRetryAttempts": 2, + "RetryDelayMs": 1500, "TimeoutSeconds": 30 }, "Serilog": { diff --git a/src/Managing.Api/appsettings.Sandbox.json b/src/Managing.Api/appsettings.Sandbox.json index 0652c693..c5e1845e 100644 --- a/src/Managing.Api/appsettings.Sandbox.json +++ b/src/Managing.Api/appsettings.Sandbox.json @@ -19,8 +19,8 @@ }, "Web3Proxy": { "BaseUrl": "http://srv-captain--web3-proxy:4111", - "MaxRetryAttempts": 3, - "RetryDelayMs": 1000, + "MaxRetryAttempts": 2, + "RetryDelayMs": 1500, "TimeoutSeconds": 30 }, "Serilog": { diff --git a/src/Managing.Api/appsettings.json b/src/Managing.Api/appsettings.json index fa0d2cbd..dc55b4c9 100644 --- a/src/Managing.Api/appsettings.json +++ b/src/Managing.Api/appsettings.json @@ -22,8 +22,8 @@ }, "Web3Proxy": { "BaseUrl": "http://localhost:4111", - "MaxRetryAttempts": 3, - "RetryDelayMs": 1000, + "MaxRetryAttempts": 2, + "RetryDelayMs": 1500, "TimeoutSeconds": 30 }, "Kaigen": { diff --git a/src/Managing.Application.Abstractions/Services/IExchangeService.cs b/src/Managing.Application.Abstractions/Services/IExchangeService.cs index 93afadc8..892a6dd0 100644 --- a/src/Managing.Application.Abstractions/Services/IExchangeService.cs +++ b/src/Managing.Application.Abstractions/Services/IExchangeService.cs @@ -45,7 +45,7 @@ public interface IExchangeService decimal GetVolume(Account account, Ticker ticker); Task> GetTrades(Account account, Ticker ticker); Task CancelOrder(Account account, Ticker ticker); - decimal GetFee(Account account, bool isForPaperTrading = false); + Task GetFee(Account account, bool isForPaperTrading = false); Task GetCandle(Account account, Ticker ticker, DateTime date); Task GetQuantityInPosition(Account account, Ticker ticker); diff --git a/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs b/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs index f6fc34d6..9eadc2f1 100644 --- a/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs +++ b/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs @@ -17,5 +17,7 @@ namespace Managing.Application.Abstractions.Services Task SendTokenAsync(string senderAddress, string recipientAddress, Ticker ticker, decimal amount, int? chainId = null); Task> GetWalletBalanceAsync(string address, Ticker[] assets, string[] chains); + + Task GetEstimatedGasFeeUsdAsync(); } } \ No newline at end of file diff --git a/src/Managing.Application/Abstractions/IBotService.cs b/src/Managing.Application/Abstractions/IBotService.cs index b634f559..f78e00e4 100644 --- a/src/Managing.Application/Abstractions/IBotService.cs +++ b/src/Managing.Application/Abstractions/IBotService.cs @@ -1,3 +1,5 @@ +using Managing.Application.Bots.Models; +using Managing.Domain.Accounts; using Managing.Domain.Bots; using Managing.Domain.Trades; using static Managing.Common.Enums; @@ -36,12 +38,19 @@ public interface IBotService /// Sort direction ("Asc" or "Desc") /// Tuple containing the bots for the current page and total count Task<(IEnumerable Bots, int TotalCount)> GetBotsPaginatedAsync( - int pageNumber, - int pageSize, - BotStatus? status = null, - string? name = null, - string? ticker = null, - string? agentName = null, - string sortBy = "CreateDate", + int pageNumber, + int pageSize, + BotStatus? status = null, + string? name = null, + string? ticker = null, + string? agentName = null, + string sortBy = "CreateDate", string sortDirection = "Desc"); + + /// + /// Checks USDC and ETH balances for EVM/GMX V2 accounts + /// + /// The account to check balances for + /// Balance check result + Task CheckAccountBalancesAsync(Account account); } \ No newline at end of file diff --git a/src/Managing.Application/Bots/Grains/AgentGrain.cs b/src/Managing.Application/Bots/Grains/AgentGrain.cs index 038e8657..115cc172 100644 --- a/src/Managing.Application/Bots/Grains/AgentGrain.cs +++ b/src/Managing.Application/Bots/Grains/AgentGrain.cs @@ -5,6 +5,7 @@ using Managing.Application.Abstractions.Services; using Managing.Application.Bots.Models; using Managing.Application.Orleans; using Managing.Common; +using Managing.Core.Exceptions; using Managing.Domain.Statistics; using Microsoft.Extensions.Logging; using static Managing.Common.Enums; @@ -237,184 +238,193 @@ public class AgentGrain : Grain, IAgentGrain public async Task CheckAndEnsureEthBalanceAsync(Guid requestingBotId, string accountName) { - try + // Check if a swap is already in progress + if (_state.State.IsSwapInProgress) { - // Check if a swap is already in progress - if (_state.State.IsSwapInProgress) - { - _logger.LogInformation("Swap already in progress for agent {UserId}, bot {RequestingBotId} will wait", - this.GetPrimaryKeyLong(), requestingBotId); - return new BalanceCheckResult - { - IsSuccessful = false, - FailureReason = BalanceCheckFailureReason.SwapInProgress, - Message = "Swap operation already in progress", - ShouldStopBot = false - }; - } - - // Check cooldown period (5 minutes between swaps) - if (_state.State.LastSwapTime.HasValue && - DateTime.UtcNow - _state.State.LastSwapTime.Value < TimeSpan.FromMinutes(5)) - { - _logger.LogInformation( - "Swap cooldown period active for agent {UserId}, bot {RequestingBotId} will wait", - this.GetPrimaryKeyLong(), requestingBotId); - return new BalanceCheckResult - { - IsSuccessful = false, - FailureReason = BalanceCheckFailureReason.SwapCooldownActive, - Message = "Swap cooldown period active", - ShouldStopBot = false - }; - } - - // Get or refresh cached balance data - var balanceData = await GetOrRefreshBalanceDataAsync(accountName); - if (balanceData == null) - { - _logger.LogError("Failed to get balance data for account {AccountName}, user {UserId}", - accountName, this.GetPrimaryKeyLong()); - return new BalanceCheckResult - { - IsSuccessful = false, - FailureReason = BalanceCheckFailureReason.BalanceFetchError, - Message = "Failed to fetch balance data", - ShouldStopBot = false - }; - } - - _logger.LogInformation( - "Agent {UserId} balance check - ETH: {EthValue:F2} USD, USDC: {UsdcValue:F2} USD (cached: {IsCached})", - this.GetPrimaryKeyLong(), balanceData.EthValueInUsd, balanceData.UsdcValue, - _state.State.CachedBalanceData?.IsValid == true); - - // Check USDC minimum balance first (this will stop the bot if insufficient) - if (balanceData.UsdcValue < Constants.GMX.Config.MinimumPositionAmount) - { - _logger.LogWarning( - "USDC balance is below minimum required amount - ETH: {EthValue:F2} USD, USDC: {UsdcValue:F2} USD (minimum: {Minimum})", - balanceData.EthValueInUsd, balanceData.UsdcValue, Constants.GMX.Config.MinimumPositionAmount); - return new BalanceCheckResult - { - IsSuccessful = false, - FailureReason = BalanceCheckFailureReason.InsufficientUsdcBelowMinimum, - Message = - $"USDC balance below minimum required amount ({Constants.GMX.Config.MinimumPositionAmount} USD)", - ShouldStopBot = true - }; - } - - // If ETH balance is sufficient, return success - if (balanceData.EthValueInUsd >= Constants.GMX.Config.MinimumEthBalance) - { - return new BalanceCheckResult - { - IsSuccessful = true, - FailureReason = BalanceCheckFailureReason.None, - Message = "Balance check successful", - ShouldStopBot = false - }; - } - - // Check if we have enough USDC for swap (need at least 5 USD for swap) - if (balanceData.UsdcValue < - (Constants.GMX.Config.MinimumPositionAmount + (decimal)Constants.GMX.Config.AutoSwapAmount)) - { - _logger.LogWarning( - "Insufficient USDC balance for swap - ETH: {EthValue:F2} USD, USDC: {UsdcValue:F2} USD (need {AutoSwapAmount} USD for swap)", - balanceData.EthValueInUsd, balanceData.UsdcValue, Constants.GMX.Config.AutoSwapAmount); - return new BalanceCheckResult - { - IsSuccessful = false, - FailureReason = BalanceCheckFailureReason.InsufficientUsdcForSwap, - Message = $"Insufficient USDC balance for swap (need {Constants.GMX.Config.AutoSwapAmount} USD)", - ShouldStopBot = true - }; - } - - // Mark swap as in progress - _state.State.IsSwapInProgress = true; - await _state.WriteStateAsync(); - - try - { - _logger.LogInformation("Initiating USDC to ETH swap for agent {UserId} - swapping 5 USDC", - this.GetPrimaryKeyLong()); - - // Get user for the swap - var userId = (int)this.GetPrimaryKeyLong(); - var user = await _userService.GetUserByIdAsync(userId); - if (user == null) - { - _logger.LogError("User {UserId} not found for swap operation", userId); - return new BalanceCheckResult - { - IsSuccessful = false, - FailureReason = BalanceCheckFailureReason.BalanceFetchError, - Message = "User not found for swap operation", - ShouldStopBot = false - }; - } - - // Perform the swap - var swapInfo = await _accountService.SwapGmxTokensAsync(user, accountName, - Ticker.USDC, Ticker.ETH, Constants.GMX.Config.AutoSwapAmount); - - if (swapInfo.Success) - { - _logger.LogInformation( - "Successfully swapped 5 USDC to ETH for agent {UserId}, transaction hash: {Hash}", - userId, swapInfo.Hash); - - // Update last swap time and invalidate cache - _state.State.LastSwapTime = DateTime.UtcNow; - _state.State.CachedBalanceData = null; // Invalidate cache after successful swap - return new BalanceCheckResult - { - IsSuccessful = true, - FailureReason = BalanceCheckFailureReason.None, - Message = "Swap completed successfully", - ShouldStopBot = false - }; - } - else - { - _logger.LogError("Failed to swap USDC to ETH for agent {UserId}: {Error}", - userId, swapInfo.Error ?? swapInfo.Message); - return new BalanceCheckResult - { - IsSuccessful = false, - FailureReason = BalanceCheckFailureReason.SwapExecutionError, - Message = swapInfo.Error ?? swapInfo.Message ?? "Swap execution failed", - ShouldStopBot = false - }; - } - } - finally - { - // Always clear the swap in progress flag - _state.State.IsSwapInProgress = false; - await _state.WriteStateAsync(); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking/ensuring ETH balance for agent {UserId}, bot {RequestingBotId}", + _logger.LogInformation("Swap already in progress for agent {UserId}, bot {RequestingBotId} will wait", this.GetPrimaryKeyLong(), requestingBotId); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.SwapInProgress, + Message = "Swap operation already in progress", + ShouldStopBot = false + }; + } - // Clear swap in progress flag on error - _state.State.IsSwapInProgress = false; - await _state.WriteStateAsync(); + // Check cooldown period (5 minutes between swaps) + if (_state.State.LastSwapTime.HasValue && + DateTime.UtcNow - _state.State.LastSwapTime.Value < TimeSpan.FromMinutes(5)) + { + _logger.LogInformation( + "Swap cooldown period active for agent {UserId}, bot {RequestingBotId} will wait", + this.GetPrimaryKeyLong(), requestingBotId); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.SwapCooldownActive, + Message = "Swap cooldown period active", + ShouldStopBot = false + }; + } + // Get or refresh cached balance data + var balanceData = await GetOrRefreshBalanceDataAsync(accountName); + if (balanceData == null) + { + _logger.LogError("Failed to get balance data for account {AccountName}, user {UserId}", + accountName, this.GetPrimaryKeyLong()); return new BalanceCheckResult { IsSuccessful = false, FailureReason = BalanceCheckFailureReason.BalanceFetchError, - Message = ex.Message, + Message = "Failed to fetch balance data", ShouldStopBot = false }; } + + _logger.LogInformation( + "Agent {UserId} balance check - ETH: {EthValue:F2} USD, USDC: {UsdcValue:F2} USD (cached: {IsCached})", + this.GetPrimaryKeyLong(), balanceData.EthValueInUsd, balanceData.UsdcValue, + _state.State.CachedBalanceData?.IsValid == true); + + // Check USDC minimum balance first (this will stop the bot if insufficient) + if (balanceData.UsdcValue < Constants.GMX.Config.MinimumPositionAmount) + { + _logger.LogWarning( + "USDC balance is below minimum required amount - ETH: {EthValue:F2} USD, USDC: {UsdcValue:F2} USD (minimum: {Minimum})", + balanceData.EthValueInUsd, balanceData.UsdcValue, Constants.GMX.Config.MinimumPositionAmount); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.InsufficientUsdcBelowMinimum, + Message = + $"USDC balance below minimum required amount ({Constants.GMX.Config.MinimumPositionAmount} USD)", + ShouldStopBot = true + }; + } + + // If ETH balance is sufficient, return success + if (balanceData.EthValueInUsd >= Constants.GMX.Config.MinimumTradeEthBalanceUsd) + { + return new BalanceCheckResult + { + IsSuccessful = true, + FailureReason = BalanceCheckFailureReason.None, + Message = "Balance check successful - Enough ETH balance for trading", + ShouldStopBot = false + }; + } + + if (balanceData.EthValueInUsd < Constants.GMX.Config.MinimumSwapEthBalanceUsd) + { + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.InsufficientEthBelowMinimum, + Message = "ETH balance below minimum required amount", + ShouldStopBot = true + }; + } + + // Check if we have enough USDC for swap (need at least 5 USD for swap) + if (balanceData.UsdcValue < + (Constants.GMX.Config.MinimumPositionAmount + (decimal)Constants.GMX.Config.AutoSwapAmount)) + { + _logger.LogWarning( + "Insufficient USDC balance for swap - ETH: {EthValue:F2} USD, USDC: {UsdcValue:F2} USD (need {AutoSwapAmount} USD for swap)", + balanceData.EthValueInUsd, balanceData.UsdcValue, Constants.GMX.Config.AutoSwapAmount); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.InsufficientUsdcForSwap, + Message = $"Insufficient USDC balance for swap (need {Constants.GMX.Config.AutoSwapAmount} USD)", + ShouldStopBot = true + }; + } + + // Mark swap as in progress + _state.State.IsSwapInProgress = true; + await _state.WriteStateAsync(); + + try + { + _logger.LogInformation("Initiating USDC to ETH swap for agent {UserId} - swapping 5 USDC", + this.GetPrimaryKeyLong()); + + // Get user for the swap + var userId = (int)this.GetPrimaryKeyLong(); + var user = await _userService.GetUserByIdAsync(userId); + + // Perform the swap + var swapInfo = await _accountService.SwapGmxTokensAsync(user, accountName, + Ticker.USDC, Ticker.ETH, Constants.GMX.Config.AutoSwapAmount); + + if (swapInfo.Success) + { + _logger.LogInformation( + "Successfully swapped 5 USDC to ETH for agent {UserId}, transaction hash: {Hash}", + userId, swapInfo.Hash); + + // Update last swap time and invalidate cache + _state.State.LastSwapTime = DateTime.UtcNow; + _state.State.CachedBalanceData = null; // Invalidate cache after successful swap + return new BalanceCheckResult + { + IsSuccessful = true, + FailureReason = BalanceCheckFailureReason.None, + Message = "Swap completed successfully", + ShouldStopBot = false + }; + } + else + { + _logger.LogError("Failed to swap USDC to ETH for agent {UserId}: {Error}", + userId, swapInfo.Error ?? swapInfo.Message); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.SwapExecutionError, + Message = swapInfo.Error ?? swapInfo.Message ?? "Swap execution failed", + ShouldStopBot = true + }; + } + } + catch (InvalidOperationException ex) when (ex.InnerException is InsufficientFundsException insufficientFundsEx) + { + // Handle allowance exception (insufficient ETH for gas fees) + _logger.LogError(insufficientFundsEx, + "Insufficient funds during autoswap for agent {UserId}: {ErrorMessage}", + this.GetPrimaryKeyLong(), insufficientFundsEx.Message); + + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.SwapExecutionError, + Message = insufficientFundsEx.UserMessage ?? + "Insufficient ETH for gas fees during autoswap. Bot cannot continue trading.", + ShouldStopBot = true + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during autoswap for agent {UserId}, bot {RequestingBotId}", + this.GetPrimaryKeyLong(), requestingBotId); + + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.SwapExecutionError, + Message = ex.Message, + ShouldStopBot = true + }; + } + finally + { + // Always clear the swap in progress flag + _state.State.IsSwapInProgress = false; + await _state.WriteStateAsync(); + } } /// diff --git a/src/Managing.Application/Bots/Models/AgentGrainState.cs b/src/Managing.Application/Bots/Models/AgentGrainState.cs index 3f3cded8..568499f8 100644 --- a/src/Managing.Application/Bots/Models/AgentGrainState.cs +++ b/src/Managing.Application/Bots/Models/AgentGrainState.cs @@ -1,94 +1,110 @@ +#nullable enable namespace Managing.Application.Bots.Models { + [GenerateSerializer] public class AgentGrainState { - public string AgentName { get; set; } - public HashSet BotIds { get; set; } = new HashSet(); - + [Id(0)] public string AgentName { get; set; } = string.Empty; + [Id(1)] public HashSet BotIds { get; set; } = new HashSet(); + /// /// Tracks if a swap operation is currently in progress to prevent multiple simultaneous swaps /// + [Id(2)] public bool IsSwapInProgress { get; set; } = false; - + /// /// Timestamp of the last swap operation to implement cooldown period /// + [Id(3)] public DateTime? LastSwapTime { get; set; } = null; - + /// /// Cached balance data to reduce external API calls /// + [Id(4)] public CachedBalanceData? CachedBalanceData { get; set; } = null; } - + /// /// Cached balance data to avoid repeated external API calls /// + [GenerateSerializer] public class CachedBalanceData { /// /// When the balance was last fetched /// + [Id(0)] public DateTime LastFetched { get; set; } = DateTime.UtcNow; - + /// /// The account name this balance data is for /// + [Id(1)] public string AccountName { get; set; } = string.Empty; - + /// /// ETH balance in USD /// + [Id(2)] public decimal EthValueInUsd { get; set; } = 0; - + /// /// USDC balance value /// + [Id(3)] public decimal UsdcValue { get; set; } = 0; - - /// - /// Whether the cached data is still valid (less than 1 minute old) - /// - public bool IsValid => DateTime.UtcNow - LastFetched < TimeSpan.FromMinutes(1.5); -} -/// -/// Result of a balance check operation -/// -public class BalanceCheckResult -{ - /// - /// Whether the balance check was successful - /// - public bool IsSuccessful { get; set; } - - /// - /// The reason for failure if not successful - /// - public BalanceCheckFailureReason FailureReason { get; set; } - - /// - /// Additional details about the result - /// - public string Message { get; set; } = string.Empty; - - /// - /// Whether the bot should stop due to this result - /// - public bool ShouldStopBot { get; set; } -} + /// + /// Whether the cached data is still valid (less than 1 minute old) + /// + public bool IsValid => DateTime.UtcNow - LastFetched < TimeSpan.FromMinutes(1.5); + } -/// -/// Reasons why a balance check might fail -/// -public enum BalanceCheckFailureReason -{ - None, - InsufficientUsdcBelowMinimum, - InsufficientUsdcForSwap, - SwapInProgress, - SwapCooldownActive, - BalanceFetchError, - SwapExecutionError -} -} \ No newline at end of file + /// + /// Result of a balance check operation + /// + [GenerateSerializer] + public class BalanceCheckResult + { + /// + /// Whether the balance check was successful + /// + [Id(0)] + public bool IsSuccessful { get; set; } + + /// + /// The reason for failure if not successful + /// + [Id(1)] + public BalanceCheckFailureReason FailureReason { get; set; } + + /// + /// Additional details about the result + /// + [Id(2)] + public string Message { get; set; } = string.Empty; + + /// + /// Whether the bot should stop due to this result + /// + [Id(3)] + public bool ShouldStopBot { get; set; } + } + + /// + /// Reasons why a balance check might fail + /// + public enum BalanceCheckFailureReason + { + None, + InsufficientUsdcBelowMinimum, + InsufficientUsdcForSwap, + SwapInProgress, + SwapCooldownActive, + BalanceFetchError, + SwapExecutionError, + InsufficientEthBelowMinimum + } +} \ No newline at end of file diff --git a/src/Managing.Application/Bots/TradingBotBase.cs b/src/Managing.Application/Bots/TradingBotBase.cs index 14e6dace..b1519667 100644 --- a/src/Managing.Application/Bots/TradingBotBase.cs +++ b/src/Managing.Application/Bots/TradingBotBase.cs @@ -6,6 +6,7 @@ using Managing.Application.Trading.Commands; using Managing.Application.Trading.Handlers; using Managing.Common; using Managing.Core; +using Managing.Core.Exceptions; using Managing.Domain.Accounts; using Managing.Domain.Bots; using Managing.Domain.Candles; @@ -822,6 +823,17 @@ public class TradingBotBase : ITradingBot return null; } + catch (InsufficientFundsException ex) + { + // Handle insufficient funds errors with user-friendly messaging + SetSignalStatus(signal.Identifier, SignalStatus.Expired); + await LogWarning(ex.UserMessage); + + // Log the technical details for debugging + Logger.LogError(ex, "Insufficient funds error for signal {SignalId}: {ErrorMessage}", signal.Identifier, ex.Message); + + return null; + } catch (Exception ex) { SetSignalStatus(signal.Identifier, SignalStatus.Expired); diff --git a/src/Managing.Application/ManageBot/BotService.cs b/src/Managing.Application/ManageBot/BotService.cs index 5f012c20..05f57552 100644 --- a/src/Managing.Application/ManageBot/BotService.cs +++ b/src/Managing.Application/ManageBot/BotService.cs @@ -3,7 +3,10 @@ using Managing.Application.Abstractions.Grains; using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Services; using Managing.Application.Bots; +using Managing.Application.Bots.Models; +using Managing.Common; using Managing.Core; +using Managing.Domain.Accounts; using Managing.Domain.Bots; using Managing.Domain.Scenarios; using Managing.Domain.Shared.Helpers; @@ -103,12 +106,31 @@ namespace Managing.Application.ManageBot } var botGrain = _grainFactory.GetGrain(identifier); + + // Check balances for EVM/GMX V2 bots before starting/restarting + var botConfig = await botGrain.GetConfiguration(); + var account = await ServiceScopeHelpers.WithScopedService( + _scopeFactory, + async accountService => await accountService.GetAccount(botConfig.AccountName, true, false)); + + if (account.Exchange == TradingExchanges.Evm || account.Exchange == TradingExchanges.GmxV2) + { + var balanceCheckResult = await CheckAccountBalancesAsync(account); + if (!balanceCheckResult.IsSuccessful) + { + _tradingBotLogger.LogWarning( + "Bot {Identifier} restart blocked due to insufficient balances: {Message}", + identifier, balanceCheckResult.Message); + throw new InvalidOperationException(balanceCheckResult.Message); + } + } + + var grainState = await botGrain.GetBotDataAsync(); + if (previousStatus == BotStatus.Saved) { // First time startup await botGrain.StartAsync(); - var grainState = await botGrain.GetBotDataAsync(); - var account = await botGrain.GetAccount(); var startupMessage = $"πŸš€ **Bot Started**\n\n" + $"🎯 **Agent:** {account.User.AgentName}\n" + $"πŸ€– **Bot Name:** {grainState.Config.Name}\n" + @@ -122,8 +144,6 @@ namespace Managing.Application.ManageBot { // Restart (bot was previously down) await botGrain.RestartAsync(); - var grainState = await botGrain.GetBotDataAsync(); - var account = await botGrain.GetAccount(); var restartMessage = $"πŸ”„ **Bot Restarted**\n\n" + $"🎯 **Agent:** {account.User.AgentName}\n" + $"πŸ€– **Bot Name:** {grainState.Config.Name}\n" + @@ -138,8 +158,8 @@ namespace Managing.Application.ManageBot } catch (Exception e) { - _tradingBotLogger.LogError(e, "Error restarting bot {Identifier}", identifier); - return BotStatus.Stopped; + SentrySdk.CaptureException(e); + throw; } } @@ -296,7 +316,6 @@ namespace Managing.Application.ManageBot var existingBot = await _botRepository.GetBotByIdentifierAsync(bot.Identifier); - // Check if bot already exists in database await ServiceScopeHelpers.WithScopedService( @@ -313,13 +332,13 @@ namespace Managing.Application.ManageBot "Created new bot statistics for bot {BotId}: Wins={Wins}, Losses={Losses}, PnL={PnL}, ROI={ROI}%, Volume={Volume}, Fees={Fees}", bot.Identifier, bot.TradeWins, bot.TradeLosses, bot.Pnl, bot.Roi, bot.Volume, bot.Fees); } - else if (existingBot.Status != bot.Status - || existingBot.Pnl != Math.Round(bot.Pnl, 8) - || existingBot.Roi != Math.Round(bot.Roi, 8) - || existingBot.Volume != Math.Round(bot.Volume, 8) - || existingBot.Fees != Math.Round(bot.Fees, 8) - || existingBot.LongPositionCount != bot.LongPositionCount - || existingBot.ShortPositionCount != bot.ShortPositionCount) + else if (existingBot.Status != bot.Status + || existingBot.Pnl != Math.Round(bot.Pnl, 8) + || existingBot.Roi != Math.Round(bot.Roi, 8) + || existingBot.Volume != Math.Round(bot.Volume, 8) + || existingBot.Fees != Math.Round(bot.Fees, 8) + || existingBot.LongPositionCount != bot.LongPositionCount + || existingBot.ShortPositionCount != bot.ShortPositionCount) { _tradingBotLogger.LogInformation("Update bot statistics for bot {BotId}", bot.Identifier); @@ -365,5 +384,79 @@ namespace Managing.Application.ManageBot sortDirection); }); } + + /// + /// Checks USDC and ETH balances for EVM/GMX V2 accounts + /// + /// The account to check balances for + /// Balance check result + public async Task CheckAccountBalancesAsync(Account account) + { + try + { + return await ServiceScopeHelpers + .WithScopedServices( + _scopeFactory, + async (exchangeService, accountService) => + { + // Get current balances + var balances = await exchangeService.GetBalances(account); + var ethBalance = balances.FirstOrDefault(b => b.TokenName?.ToUpper() == "ETH"); + var usdcBalance = balances.FirstOrDefault(b => b.TokenName?.ToUpper() == "USDC"); + + var ethValueInUsd = ethBalance?.Amount * ethBalance?.Price ?? 0; + var usdcValue = usdcBalance?.Value ?? 0; + + _tradingBotLogger.LogInformation( + "Balance check for bot restart - Account: {AccountName}, ETH: {EthValue:F2} USD, USDC: {UsdcValue:F2} USD", + account.Name, ethValueInUsd, usdcValue); + + // Check USDC minimum balance + if (usdcValue < Constants.GMX.Config.MinimumPositionAmount) + { + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.InsufficientUsdcBelowMinimum, + Message = + $"USDC balance ({usdcValue:F2} USD) is below minimum required amount ({Constants.GMX.Config.MinimumPositionAmount} USD). Please add more USDC to restart the bot.", + ShouldStopBot = true + }; + } + + // Check ETH minimum balance for trading + if (ethValueInUsd < Constants.GMX.Config.MinimumTradeEthBalanceUsd) + { + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.InsufficientEthBelowMinimum, + Message = + $"ETH balance ({ethValueInUsd:F2} USD) is below minimum required amount ({Constants.GMX.Config.MinimumTradeEthBalanceUsd} USD) for trading. Please add more ETH to restart the bot.", + ShouldStopBot = true + }; + } + + return new BalanceCheckResult + { + IsSuccessful = true, + FailureReason = BalanceCheckFailureReason.None, + Message = "Balance check successful - Sufficient USDC and ETH balances", + ShouldStopBot = false + }; + }); + } + catch (Exception ex) + { + _tradingBotLogger.LogError(ex, "Error checking balances for account {AccountName}", account.Name); + return new BalanceCheckResult + { + IsSuccessful = false, + FailureReason = BalanceCheckFailureReason.BalanceFetchError, + Message = $"Failed to check balances: {ex.Message}", + ShouldStopBot = false + }; + } + } } } \ No newline at end of file diff --git a/src/Managing.Application/ManageBot/StartBotCommandHandler.cs b/src/Managing.Application/ManageBot/StartBotCommandHandler.cs index 8d9c0612..f4ee4cac 100644 --- a/src/Managing.Application/ManageBot/StartBotCommandHandler.cs +++ b/src/Managing.Application/ManageBot/StartBotCommandHandler.cs @@ -1,4 +1,5 @@ -ο»Ώusing Managing.Application.Abstractions.Grains; +ο»Ώusing Managing.Application.Abstractions; +using Managing.Application.Abstractions.Grains; using Managing.Application.Abstractions.Services; using Managing.Application.ManageBot.Commands; using Managing.Common; @@ -11,12 +12,14 @@ namespace Managing.Application.ManageBot { private readonly IAccountService _accountService; private readonly IGrainFactory _grainFactory; + private readonly IBotService _botService; public StartBotCommandHandler( - IAccountService accountService, IGrainFactory grainFactory) + IAccountService accountService, IGrainFactory grainFactory, IBotService botService) { _accountService = accountService; _grainFactory = grainFactory; + _botService = botService; } public async Task Handle(StartBotCommand request, CancellationToken cancellationToken) @@ -39,20 +42,34 @@ namespace Managing.Application.ManageBot $"Bot trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}"); } - var account = await _accountService.GetAccount(request.Config.AccountName, true, true); + var account = await _accountService.GetAccount(request.Config.AccountName, true, false); if (account == null) { throw new Exception($"Account {request.Config.AccountName} not found"); } - var usdcBalance = account.Balances.FirstOrDefault(b => b.TokenName == Ticker.USDC.ToString()); - - if (usdcBalance == null || - usdcBalance.Value < Constants.GMX.Config.MinimumPositionAmount || - usdcBalance.Value < request.Config.BotTradingBalance) + // Check balances for EVM/GMX V2 accounts before starting + if (account.Exchange == TradingExchanges.Evm || account.Exchange == TradingExchanges.GmxV2) { - throw new Exception($"Account {request.Config.AccountName} has no USDC balance or not enough balance"); + var balanceCheckResult = await _botService.CheckAccountBalancesAsync(account); + if (!balanceCheckResult.IsSuccessful) + { + throw new InvalidOperationException(balanceCheckResult.Message); + } + } + + // For other exchanges, keep the original USDC balance check + if (account.Exchange != TradingExchanges.Evm && account.Exchange != TradingExchanges.GmxV2) + { + var usdcBalance = account.Balances.FirstOrDefault(b => b.TokenName == Ticker.USDC.ToString()); + + if (usdcBalance == null || + usdcBalance.Value < Constants.GMX.Config.MinimumPositionAmount || + usdcBalance.Value < request.Config.BotTradingBalance) + { + throw new Exception($"Account {request.Config.AccountName} has no USDC balance or not enough balance"); + } } try diff --git a/src/Managing.Application/Trading/Handlers/OpenPositionCommandHandler.cs b/src/Managing.Application/Trading/Handlers/OpenPositionCommandHandler.cs index 8675ebc5..e7eec14d 100644 --- a/src/Managing.Application/Trading/Handlers/OpenPositionCommandHandler.cs +++ b/src/Managing.Application/Trading/Handlers/OpenPositionCommandHandler.cs @@ -2,6 +2,7 @@ using Managing.Application.Abstractions.Services; using Managing.Application.Trading.Commands; using Managing.Common; +using Managing.Core.Exceptions; using Managing.Domain.Shared.Helpers; using Managing.Domain.Trades; using static Managing.Common.Enums; @@ -19,7 +20,6 @@ namespace Managing.Application.Trading.Handlers { var account = await accountService.GetAccount(request.AccountName, hideSecrets: false, getBalance: false); - var initiator = request.IsForPaperTrading ? PositionInitiator.PaperTrading : request.Initiator; var position = new Position(Guid.NewGuid(), request.AccountName, request.Direction, request.Ticker, @@ -44,6 +44,18 @@ namespace Managing.Application.Trading.Handlers $"Bot trading balance of {balanceToRisk} USD is less than the minimum {Constants.GMX.Config.MinimumPositionAmount} USD required to trade"); } + // Gas fee check for EVM exchanges + if (account.Exchange == TradingExchanges.Evm || account.Exchange == TradingExchanges.GmxV2) + { + var gasFeeUsd = await exchangeService.GetFee(account); + if (gasFeeUsd > Constants.GMX.Config.MaximumGasFeeUsd) + { + throw new InsufficientFundsException( + $"Gas fee too high for position opening: {gasFeeUsd:F2} USD (threshold: {Constants.GMX.Config.MaximumGasFeeUsd} USD). Position opening cancelled.", + InsufficientFundsType.InsufficientEth); + } + } + var price = request.IsForPaperTrading && request.Price.HasValue ? request.Price.Value : await exchangeService.GetPrice(account, request.Ticker, DateTime.Now); diff --git a/src/Managing.Common/Constants.cs b/src/Managing.Common/Constants.cs index 889074e6..c973bdd8 100644 --- a/src/Managing.Common/Constants.cs +++ b/src/Managing.Common/Constants.cs @@ -108,7 +108,9 @@ namespace Managing.Common } public const decimal MinimumPositionAmount = 5m; - public const decimal MinimumEthBalance = 2m; + public const decimal MinimumTradeEthBalanceUsd = 1.5m; + public static decimal MinimumSwapEthBalanceUsd = 1m; + public const decimal MaximumGasFeeUsd = 1.5m; public const double AutoSwapAmount = 3; } diff --git a/src/Managing.Core/Exceptions/CustomExceptions.cs b/src/Managing.Core/Exceptions/CustomExceptions.cs index d9140468..145677ad 100644 --- a/src/Managing.Core/Exceptions/CustomExceptions.cs +++ b/src/Managing.Core/Exceptions/CustomExceptions.cs @@ -75,4 +75,79 @@ public class ServiceUnavailableException : Exception public ServiceUnavailableException(string message) : base(message) { } +} + +/// +/// Exception thrown when there are insufficient funds or allowance for a transaction +/// This typically indicates the user needs to add more ETH for gas or approve token spending +/// +public class InsufficientFundsException : Exception +{ + /// + /// The type of insufficient funds error + /// + public InsufficientFundsType ErrorType { get; } + + /// + /// User-friendly message explaining what needs to be done + /// + public string UserMessage { get; } + + public InsufficientFundsException(string errorMessage, InsufficientFundsType errorType) + : base(errorMessage) + { + ErrorType = errorType; + UserMessage = GetUserFriendlyMessage(errorType); + } + + private static string GetUserFriendlyMessage(InsufficientFundsType errorType) + { + return errorType switch + { + InsufficientFundsType.InsufficientEth => + "❌ **Insufficient ETH for Gas Fees**\n" + + "Your wallet doesn't have enough ETH to pay for transaction gas fees.\n" + + "Please add ETH to your wallet and try again.", + + InsufficientFundsType.InsufficientAllowance => + "❌ **Insufficient Token Allowance**\n" + + "The trading contract doesn't have permission to spend your tokens.\n" + + "Please approve token spending in your wallet and try again.", + + InsufficientFundsType.InsufficientBalance => + "❌ **Insufficient Token Balance**\n" + + "Your wallet doesn't have enough tokens for this trade.\n" + + "Please add more tokens to your wallet and try again.", + + _ => "❌ **Transaction Failed**\n" + + "The transaction failed due to insufficient funds.\n" + + "Please check your wallet balance and try again." + }; + } +} + +/// +/// Types of insufficient funds errors +/// +public enum InsufficientFundsType +{ + /// + /// Not enough ETH for gas fees + /// + InsufficientEth, + + /// + /// Token allowance is insufficient (ERC20: transfer amount exceeds allowance) + /// + InsufficientAllowance, + + /// + /// Token balance is insufficient + /// + InsufficientBalance, + + /// + /// General insufficient funds error + /// + General } \ No newline at end of file diff --git a/src/Managing.Infrastructure.Exchanges/Abstractions/IExchangeProcessor.cs b/src/Managing.Infrastructure.Exchanges/Abstractions/IExchangeProcessor.cs index abee65c8..b84a4127 100644 --- a/src/Managing.Infrastructure.Exchanges/Abstractions/IExchangeProcessor.cs +++ b/src/Managing.Infrastructure.Exchanges/Abstractions/IExchangeProcessor.cs @@ -33,7 +33,7 @@ public interface IExchangeProcessor decimal GetVolume(Account account, Ticker ticker); Task> GetTrades(Account account, Ticker ticker); Task CancelOrder(Account account, Ticker ticker); - decimal GetFee(Account account, bool isForPaperTrading = false); + Task GetFee(Account account, bool isForPaperTrading = false); Task GetCandle(Account account, Ticker ticker, DateTime date); Task GetQuantityInPosition(Account account, Ticker ticker); Orderbook GetOrderbook(Account account, Ticker ticker); diff --git a/src/Managing.Infrastructure.Exchanges/ExchangeService.cs b/src/Managing.Infrastructure.Exchanges/ExchangeService.cs index 9eff8a0e..0fea82ee 100644 --- a/src/Managing.Infrastructure.Exchanges/ExchangeService.cs +++ b/src/Managing.Infrastructure.Exchanges/ExchangeService.cs @@ -51,6 +51,21 @@ namespace Managing.Infrastructure.Exchanges reduceOnly ? TradeStatus.PendingOpen : TradeStatus.Filled); } + // Check gas fees for EVM exchanges before opening position + if (IsEvmExchange(account)) + { + var gasFeeUsd = await GetFee(account); + if (gasFeeUsd > 0.5m) + { + _logger.LogWarning( + $"Gas fee too high for position opening: {gasFeeUsd:F2} USD (threshold: 0.5 USD). Cancelling position opening."); + + // Return a cancelled trade + return BuildEmptyTrade(ticker, price, quantity, direction, leverage, tradeType, currentDate.Value, + TradeStatus.Cancelled); + } + } + var processor = GetProcessor(account); return await processor.OpenTrade(account, ticker, direction, price, quantity, leverage, tradeType, reduceOnly, isForPaperTrading, currentDate, ioc, stopLossPrice, takeProfitPrice); @@ -242,10 +257,10 @@ namespace Managing.Infrastructure.Exchanges return await processor.GetBalance(account); } - public decimal GetFee(Account account, bool isForPaperTrading = false) + public async Task GetFee(Account account, bool isForPaperTrading = false) { var processor = GetProcessor(account); - return processor.GetFee(account); + return await processor.GetFee(account); } public async Task GetPrice(Account account, Ticker ticker, DateTime date) diff --git a/src/Managing.Infrastructure.Exchanges/Exchanges/BaseProcessor.cs b/src/Managing.Infrastructure.Exchanges/Exchanges/BaseProcessor.cs index 14e6a537..83367ff7 100644 --- a/src/Managing.Infrastructure.Exchanges/Exchanges/BaseProcessor.cs +++ b/src/Managing.Infrastructure.Exchanges/Exchanges/BaseProcessor.cs @@ -15,7 +15,7 @@ namespace Managing.Infrastructure.Exchanges.Exchanges public abstract Task GetBalance(Account account, bool isForPaperTrading = false); public abstract Task GetCandle(Account account, Ticker ticker, DateTime date); public abstract Task> GetCandles(Account account, Ticker ticker, DateTime startDate, Timeframe interval); - public abstract decimal GetFee(Account account, bool isForPaperTrading = false); + public abstract Task GetFee(Account account, bool isForPaperTrading = false); public abstract Task GetPrice(Account account, Ticker ticker, DateTime date); public abstract Task GetCurrentPrice(Account account, Ticker ticker); public abstract Task GetQuantityInPosition(Account account, Ticker ticker); diff --git a/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs b/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs index b1361582..5f3e7038 100644 --- a/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs +++ b/src/Managing.Infrastructure.Exchanges/Exchanges/EvmProcessor.cs @@ -78,9 +78,9 @@ public class EvmProcessor : BaseProcessor return await _evmManager.GetCandles(ticker, startDate, interval, isFirstCall); } - public override decimal GetFee(Account account, bool isForPaperTrading = false) + public override async Task GetFee(Account account, bool isForPaperTrading = false) { - return _evmManager.GetFee(Constants.Chains.Arbitrum).Result; + return await _evmManager.GetFee(Constants.Chains.Arbitrum); } public override async Task GetPrice(Account account, Ticker ticker, DateTime date) diff --git a/src/Managing.Infrastructure.Tests/ExchangeServicesTests.cs b/src/Managing.Infrastructure.Tests/ExchangeServicesTests.cs index f780e5dc..928894a0 100644 --- a/src/Managing.Infrastructure.Tests/ExchangeServicesTests.cs +++ b/src/Managing.Infrastructure.Tests/ExchangeServicesTests.cs @@ -137,10 +137,10 @@ namespace Managing.Infrastructure.Tests [Theory] [InlineData(TradingExchanges.Evm)] - public void Should_Return_Fee(TradingExchanges exchange) + public async Task Should_Return_Fee(TradingExchanges exchange) { var account = PrivateKeys.GetAccount(); - var fee = _exchangeService.GetFee(account); + var fee = await _exchangeService.GetFee(account); Assert.IsType(fee); Assert.True(fee > 0); } diff --git a/src/Managing.Infrastructure.Web3/EvmManager.cs b/src/Managing.Infrastructure.Web3/EvmManager.cs index d9763e7d..22b4acce 100644 --- a/src/Managing.Infrastructure.Web3/EvmManager.cs +++ b/src/Managing.Infrastructure.Web3/EvmManager.cs @@ -891,11 +891,15 @@ public class EvmManager : IEvmManager public async Task GetFee(string chainName) { - var chain = ChainService.GetChain(chainName); - var web3 = new Web3(chain.RpcUrl); - var etherPrice = (await GetPrices(new List { "ethereum" }))["ethereum"]["usd"]; - var fee = await GmxService.GetFee(web3, etherPrice); - return fee; + try + { + return await _web3ProxyService.GetEstimatedGasFeeUsdAsync(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error getting estimated gas fee: {ex.Message}"); + return 0; + } } public async Task> GetOrders(Account account, Ticker ticker) diff --git a/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs b/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs index d5457455..7e6b9c39 100644 --- a/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs +++ b/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs @@ -13,7 +13,7 @@ namespace Managing.Infrastructure.Evm.Models.Proxy /// [JsonPropertyName("success")] public bool Success { get; set; } - + /// /// Error message if not successful /// @@ -44,25 +44,25 @@ namespace Managing.Infrastructure.Evm.Models.Proxy /// [JsonPropertyName("type")] public string Type { get; set; } - + /// /// Error message /// [JsonPropertyName("message")] public string Message { get; set; } - + /// /// Error stack trace /// [JsonPropertyName("stack")] public string Stack { get; set; } - + /// /// HTTP status code (added by service) /// [JsonIgnore] public int StatusCode { get; set; } - + /// /// Returns a formatted error message with type and message /// @@ -90,7 +90,7 @@ namespace Managing.Infrastructure.Evm.Models.Proxy /// The error details from the API /// public Web3ProxyError Error { get; } - + /// /// Simple error message from API /// @@ -100,12 +100,12 @@ namespace Managing.Infrastructure.Evm.Models.Proxy /// Creates a new Web3ProxyException from a structured error /// /// The error details - public Web3ProxyException(Web3ProxyError error) + public Web3ProxyException(Web3ProxyError error) : base(error?.Message ?? "An error occurred in the Web3Proxy API") { Error = error; } - + /// /// Creates a new Web3ProxyException from a simple error message /// @@ -121,7 +121,7 @@ namespace Managing.Infrastructure.Evm.Models.Proxy /// /// Custom error message /// The error details - public Web3ProxyException(string message, Web3ProxyError error) + public Web3ProxyException(string message, Web3ProxyError error) : base(message) { Error = error; @@ -151,4 +151,28 @@ namespace Managing.Infrastructure.Evm.Models.Proxy [JsonPropertyName("balances")] public List Balances { get; set; } } -} \ No newline at end of file + + /// + /// Response model for gas fee information + /// + public class GasFeeResponse : Web3ProxyResponse + { + /// + /// Estimated gas fee in USD + /// + [JsonPropertyName("estimatedGasFeeUsd")] + public double? EstimatedGasFeeUsd { get; set; } + + /// + /// Current ETH price in USD + /// + [JsonPropertyName("ethPrice")] + public double? EthPrice { get; set; } + + /// + /// Gas price in Gwei + /// + [JsonPropertyName("gasPriceGwei")] + public double? GasPriceGwei { get; set; } + } +} \ No newline at end of file diff --git a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs index f9ddacb8..80adce68 100644 --- a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs +++ b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs @@ -5,6 +5,7 @@ using System.Text; using System.Text.Json; using System.Web; using Managing.Application.Abstractions.Services; +using Managing.Core.Exceptions; using Managing.Domain.Accounts; using Managing.Infrastructure.Evm.Models.Proxy; using Microsoft.Extensions.Logging; @@ -77,6 +78,64 @@ namespace Managing.Infrastructure.Evm.Services 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 @@ -91,6 +150,11 @@ namespace Managing.Infrastructure.Evm.Services 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); @@ -113,6 +177,11 @@ namespace Managing.Infrastructure.Evm.Services 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); @@ -325,6 +394,23 @@ namespace Managing.Infrastructure.Evm.Services 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}"); + } + + return (decimal)(response.EstimatedGasFeeUsd ?? 0); + } + private async Task HandleErrorResponse(HttpResponseMessage response) { var statusCode = (int)response.StatusCode; @@ -337,6 +423,13 @@ namespace Managing.Infrastructure.Evm.Services 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); } @@ -351,20 +444,53 @@ namespace Managing.Infrastructure.Evm.Services 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}"); } } diff --git a/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts b/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts index e6c1958d..fc9e4462 100644 --- a/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts +++ b/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts @@ -26,7 +26,7 @@ import {decodeReferralCode, encodeReferralCode} from '../../generated/gmxsdk/uti import {formatUsd} from '../../generated/gmxsdk/utils/numbers/formatting.js'; import {calculateDisplayDecimals} from '../../generated/gmxsdk/utils/numbers/index.js'; import {handleError} from '../../utils/errorHandler.js'; -import {Abi, zeroHash} from 'viem'; +import {Abi, formatEther, parseEther, zeroHash} from 'viem'; import {CLAIMABLE_FUNDING_AMOUNT} from '../../generated/gmxsdk/configs/dataStore.js'; import {hashDataMap, hashString} from '../../generated/gmxsdk/utils/hash.js'; import {ContractName, getContract} from '../../generated/gmxsdk/configs/contracts.js'; @@ -56,6 +56,7 @@ const MAX_CACHE_SIZE = 5; // Limit cache size to prevent memory issues const OPERATION_TIMEOUT = 30000; // 30 seconds timeout for operations const MEMORY_WARNING_THRESHOLD = 0.8; // Warn when memory usage exceeds 80% +const MAX_GAS_FEE_USD = 1; // Maximum gas fee in USD (1 USDC) // Memory monitoring function function checkMemoryUsage() { @@ -75,6 +76,118 @@ function checkMemoryUsage() { } } +/** + * Checks if the user has sufficient ETH balance for gas fees + * @param sdk The GMX SDK client + * @param estimatedGasFee The estimated gas fee in wei + * @returns Object with balance check result and details + */ +async function checkGasFeeBalance( + sdk: GmxSdk, + estimatedGasFee: bigint +): Promise<{ + hasSufficientBalance: boolean; + ethBalance: string; + estimatedGasFeeUsd: number; + ethPrice: number; + errorMessage?: string; +}> { + try { + // Get ETH balance using the public client + const ethBalance = await sdk.publicClient.getBalance({ address: sdk.account }); + const ethBalanceFormatted = formatEther(ethBalance); + + // Get ETH price from the market data + const {tokensData} = await getMarketsInfoWithCache(sdk); + const ethTokenData = getTokenDataFromTicker("ETH", tokensData); + + if (!ethTokenData || !ethTokenData.prices?.minPrice) { + throw new Error("Unable to get ETH price for gas fee calculation"); + } + + // Convert ETH price from 30 decimals to regular number + const ethPrice = Number(ethTokenData.prices.minPrice) / 1e30; + + // Calculate estimated gas fee in USD + const estimatedGasFeeEth = Number(formatEther(estimatedGasFee)); + const estimatedGasFeeUsd = estimatedGasFeeEth * ethPrice; + + // Check if gas fee exceeds maximum allowed (1 USDC) + const hasSufficientBalance = estimatedGasFeeUsd <= MAX_GAS_FEE_USD; + + console.log(`β›½ Gas fee check:`, { + ethBalance: ethBalanceFormatted, + estimatedGasFeeEth: estimatedGasFeeEth.toFixed(6), + estimatedGasFeeUsd: estimatedGasFeeUsd.toFixed(2), + ethPrice: ethPrice.toFixed(2), + maxAllowedUsd: MAX_GAS_FEE_USD, + hasSufficientBalance + }); + + return { + hasSufficientBalance, + ethBalance: ethBalanceFormatted, + estimatedGasFeeUsd, + ethPrice, + errorMessage: hasSufficientBalance ? undefined : + `Gas fee too high: $${estimatedGasFeeUsd.toFixed(2)} exceeds maximum of $${MAX_GAS_FEE_USD}. Please wait for lower gas fees or add more ETH.` + }; + + } catch (error) { + console.error('Error checking gas fee balance:', error); + return { + hasSufficientBalance: false, + ethBalance: "0", + estimatedGasFeeUsd: 0, + ethPrice: 0, + errorMessage: `Failed to check gas fee balance: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } +} + +/** + * Estimates gas fee for a position opening transaction + * @param sdk The GMX SDK client + * @param params The position increase parameters + * @returns Estimated gas fee in wei + */ +async function estimatePositionGasFee( + sdk: GmxSdk, + params: PositionIncreaseParams +): Promise { + try { + // Estimate gas for the position opening transaction + // This is a simplified estimation - in practice, you might want to use + // the actual transaction simulation or a more sophisticated gas estimation + const baseGasLimit = 500000n; // Base gas limit for position opening + + // Get gas price using the public client + const feeData = await sdk.publicClient.estimateFeesPerGas({ + type: "legacy", + chain: sdk.chain, + }); + const gasPrice = feeData.gasPrice || 0n; + + // Add some buffer for safety (20% more) + const estimatedGas = (baseGasLimit * 120n) / 100n; + const estimatedGasFee = estimatedGas * gasPrice; + + console.log(`β›½ Gas estimation:`, { + baseGasLimit: baseGasLimit.toString(), + gasPrice: gasPrice.toString(), + estimatedGas: estimatedGas.toString(), + estimatedGasFee: estimatedGasFee.toString() + }); + + return estimatedGasFee; + + } catch (error) { + console.error('Error estimating gas fee:', error); + // Return a conservative estimate if estimation fails + return parseEther("0.01"); // 0.01 ETH as fallback + } +} + // Fallback RPC configuration const FALLBACK_RPC_URL = "https://radial-shy-cherry.arbitrum-mainnet.quiknode.pro/098e57e961b05b24bcde008c4ca02fff6fb13b51/"; const PRIMARY_RPC_URL = "https://arb1.arbitrum.io/rpc"; @@ -518,6 +631,17 @@ export const openGmxPositionImpl = async ( direction: direction }); + // Check gas fees before opening position + console.log('β›½ Checking gas fees before opening position...'); + const estimatedGasFee = await estimatePositionGasFee(sdk, params); + const gasFeeCheck = await checkGasFeeBalance(sdk, estimatedGasFee); + + if (!gasFeeCheck.hasSufficientBalance) { + throw new Error(gasFeeCheck.errorMessage || 'Insufficient ETH balance for gas fees'); + } + + console.log('βœ… Gas fee check passed, proceeding with position opening...'); + console.log('πŸš€ Executing position order...'); if (direction === TradeDirection.Long) { @@ -596,6 +720,28 @@ export async function openGmxPosition( hash }; } catch (error) { + // Handle gas fee specific errors + if (error instanceof Error && error.message.includes('Gas fee too high')) { + reply.status(400); + return { + success: false, + error: error.message, + errorType: 'GAS_FEE_TOO_HIGH', + suggestion: 'Please wait for lower gas fees or add more ETH to your wallet.' + }; + } + + // Handle insufficient ETH balance errors + if (error instanceof Error && error.message.includes('Insufficient ETH balance')) { + reply.status(400); + return { + success: false, + error: error.message, + errorType: 'INSUFFICIENT_ETH_BALANCE', + suggestion: 'Please add more ETH to your wallet to cover gas fees.' + }; + } + return handleError(this, reply, error, 'gmx/open-position'); } }