diff --git a/src/Managing.Application.Abstractions/Services/IKaigenService.cs b/src/Managing.Application.Abstractions/Services/IKaigenService.cs index d8611f43..37209b23 100644 --- a/src/Managing.Application.Abstractions/Services/IKaigenService.cs +++ b/src/Managing.Application.Abstractions/Services/IKaigenService.cs @@ -19,4 +19,33 @@ public interface IKaigenService /// The user to refund /// True if refund was successful Task RefundUserCreditsAsync(string requestId, User user); + + /// + /// Gets the owned keys for a user + /// + /// The user to get owned keys for + /// The owned keys response containing items and overall total + Task GetOwnedKeysAsync(User user); +} + +/// +/// Response DTO for owned keys API +/// +public class OwnedKeysResponse +{ + public List Items { get; set; } + public decimal Overall { get; set; } +} + +/// +/// Individual owned key item DTO +/// +public class OwnedKeyItem +{ + public string AgentName { get; set; } + public string WalletAddress { get; set; } + public decimal Owned { get; set; } + public string UnitPrice { get; set; } + public decimal Change24h { get; set; } + public decimal Total { get; set; } } \ No newline at end of file diff --git a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs index 35dc7959..a04b8d56 100644 --- a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs +++ b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs @@ -29,6 +29,7 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable private readonly IPersistentState _state; private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; + private readonly IKaigenService _kaigenService; private TradingBotBase? _tradingBot; private IDisposable? _timer; private string _reminderName = "RebootReminder"; @@ -38,11 +39,13 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable [PersistentState("live-trading-bot", "bot-store")] IPersistentState state, ILogger logger, - IServiceScopeFactory scopeFactory) + IServiceScopeFactory scopeFactory, + IKaigenService kaigenService) { _logger = logger; _scopeFactory = scopeFactory; _state = state; + _kaigenService = kaigenService; } public override async Task OnActivateAsync(CancellationToken cancellationToken) @@ -505,6 +508,35 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable return; } + // Check if copy trading authorization is still valid + if (_state.State.Config.IsForCopyTrading && _state.State.Config.MasterBotIdentifier.HasValue) + { + try + { + var ownedKeys = await _kaigenService.GetOwnedKeysAsync(_state.State.User); + var hasMasterStrategyKey = ownedKeys.Items.Any(key => + string.Equals(key.AgentName, _state.State.Config.MasterBotIdentifier.Value.ToString(), StringComparison.OrdinalIgnoreCase) && + key.Owned >= 1); + + if (!hasMasterStrategyKey) + { + _logger.LogWarning( + "Copy trading bot {GrainId} no longer has authorization for master strategy {MasterBotId}. Stopping bot.", + this.GetPrimaryKey(), _state.State.Config.MasterBotIdentifier.Value); + + await StopAsync("Copy trading authorization revoked - user no longer owns keys for master strategy"); + return; + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to verify copy trading authorization for bot {GrainId} with master strategy {MasterBotId}. Continuing execution.", + this.GetPrimaryKey(), _state.State.Config.MasterBotIdentifier.Value); + SentrySdk.CaptureException(ex); + } + } + if (_tradingBot.Positions.Any(p => p.Value.IsOpen() || p.Value.Status.Equals(PositionStatus.New))) { _logger.LogInformation( diff --git a/src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs b/src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs index 46fea9c4..d9cb9686 100644 --- a/src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs +++ b/src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs @@ -7,6 +7,7 @@ using Managing.Domain.Accounts; using Managing.Domain.Bots; using MediatR; using static Managing.Common.Enums; +using System; namespace Managing.Application.ManageBot { @@ -15,13 +16,15 @@ namespace Managing.Application.ManageBot private readonly IAccountService _accountService; private readonly IGrainFactory _grainFactory; private readonly IBotService _botService; + private readonly IKaigenService _kaigenService; public StartCopyTradingCommandHandler( - IAccountService accountService, IGrainFactory grainFactory, IBotService botService) + IAccountService accountService, IGrainFactory grainFactory, IBotService botService, IKaigenService kaigenService) { _accountService = accountService; _grainFactory = grainFactory; _botService = botService; + _kaigenService = kaigenService; } public async Task Handle(StartCopyTradingCommand request, CancellationToken cancellationToken) @@ -40,11 +43,18 @@ namespace Managing.Application.ManageBot throw new ArgumentException($"Master bot with identifier {request.MasterBotIdentifier} not found"); } - // Verify the user owns the master bot keys - // if (masterBot.User?.Name != request.User.Name) - // { - // throw new UnauthorizedAccessException("You don't have permission to copy trades from this bot"); - // } + // Verify the user owns the keys of the master strategy + var ownedKeys = await _kaigenService.GetOwnedKeysAsync(request.User); + var hasMasterStrategyKey = ownedKeys.Items.Any(key => + string.Equals(key.AgentName, request.MasterBotIdentifier.ToString(), StringComparison.OrdinalIgnoreCase) && + key.Owned >= 1); + + if (!hasMasterStrategyKey) + { + throw new UnauthorizedAccessException( + $"You don't own the keys for the master strategy '{request.MasterBotIdentifier}'. " + + "You must own at least 1 key for this strategy to copy trade from it."); + } // Get the master bot configuration var masterConfig = await _botService.GetBotConfig(request.MasterBotIdentifier); diff --git a/src/Managing.Infrastructure.Web3/Services/KaigenService.cs b/src/Managing.Infrastructure.Web3/Services/KaigenService.cs index 1c499bb4..6c959856 100644 --- a/src/Managing.Infrastructure.Web3/Services/KaigenService.cs +++ b/src/Managing.Infrastructure.Web3/Services/KaigenService.cs @@ -19,6 +19,7 @@ public class KaigenSettings public string BaseUrl { get; set; } = "https://api.kaigen.managing.live"; public string DebitEndpoint { get; set; } = "/api/credits/debit"; public string RefundEndpoint { get; set; } = "/api/credits/refund"; + public string OwnedKeysEndpoint { get; set; } = "/api/keys/owned"; public string SecretKey { get; set; } = string.Empty; } @@ -169,24 +170,48 @@ public class KaigenService : IKaigenService } } + public async Task GetOwnedKeysAsync(User user) + { + // If credits are disabled, return empty response + if (!_creditsEnabled) + { + _logger.LogInformation("Credits disabled - returning empty owned keys for user {UserName}", + user.Name); + return new OwnedKeysResponse { Items = new List(), Overall = 0 }; + } + + try + { + var walletAddress = GetUserWalletAddress(user); + + _logger.LogInformation( + "Fetching owned keys for user {UserName} (wallet: {WalletAddress})", + user.Name, walletAddress); + + var result = await SendAuthenticatedGetRequestAsync( + $"{_settings.BaseUrl}{_settings.OwnedKeysEndpoint}", + user); + + _logger.LogInformation( + "Successfully fetched owned keys for user {UserName} (wallet: {WalletAddress})", + user.Name, walletAddress); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching owned keys for user {UserName}", user.Name); + throw; + } + } + private async Task SendAuthenticatedRequestAsync(string url, object payload, User user) { - // Create the auth token: "walletaddress-username" - var authToken = $"{GetUserWalletAddress(user)}-{user.Name}"; - - // Encrypt the auth token using AES-256-GCM - var encryptedToken = CryptoHelpers.EncryptAesGcm(authToken, _settings.SecretKey); - - // Create Basic Auth header with the encrypted token - var basicAuthString = $"{encryptedToken}:"; - var base64Auth = Convert.ToBase64String(Encoding.ASCII.GetBytes(basicAuthString)); - - // Create a new request with the auth header var request = new HttpRequestMessage(HttpMethod.Put, url) { Content = JsonContent.Create(payload, options: _jsonOptions) }; - request.Headers.Authorization = new AuthenticationHeaderValue("Basic", base64Auth); + request.Headers.Authorization = BuildAuthorizationHeader(user); var response = await _httpClient.SendAsync(request); @@ -202,6 +227,40 @@ public class KaigenService : IKaigenService return result ?? new KaigenResponse { Success = false, Message = "Failed to parse response" }; } + private async Task SendAuthenticatedGetRequestAsync(string url, User user) + { + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = BuildAuthorizationHeader(user); + + var response = await _httpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError("GET request failed. Status: {StatusCode}, Error: {Error}", + response.StatusCode, errorContent); + throw new Exception($"GET request failed: HTTP {response.StatusCode}: {errorContent}"); + } + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions); + return result ?? new OwnedKeysResponse { Items = new List(), Overall = 0 }; + } + + private AuthenticationHeaderValue BuildAuthorizationHeader(User user) + { + // Create the auth token: "walletaddress-username" + var authToken = $"{GetUserWalletAddress(user)}-{user.Name}"; + + // Encrypt the auth token using AES-256-GCM + var encryptedToken = CryptoHelpers.EncryptAesGcm(authToken, _settings.SecretKey); + + // Create Basic Auth header with the encrypted token + var basicAuthString = $"{encryptedToken}:"; + var base64Auth = Convert.ToBase64String(Encoding.ASCII.GetBytes(basicAuthString)); + + return new AuthenticationHeaderValue("Basic", base64Auth); + } + private string GetUserWalletAddress(User user) { if (user?.Accounts == null || !user.Accounts.Any())