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())