Add whitelisting service + update the jwt valid audience

This commit is contained in:
2025-11-07 19:38:33 +07:00
parent 5578d272fa
commit 21110cd771
17 changed files with 2575 additions and 7 deletions

View File

@@ -24,7 +24,10 @@ public class JwtUtils : IJwtUtils
_secret = config.GetValue<string>("Jwt:Secret") _secret = config.GetValue<string>("Jwt:Secret")
?? throw new InvalidOperationException("JWT secret is not configured."); ?? throw new InvalidOperationException("JWT secret is not configured.");
_issuer = config.GetValue<string>("Authentication:Schemes:Bearer:ValidIssuer"); _issuer = config.GetValue<string>("Authentication:Schemes:Bearer:ValidIssuer");
_audience = config.GetValue<string>("Authentication:Schemes:Bearer:ValidAudiences"); // Get first audience from array (tokens are generated with a single audience)
var audiences = config.GetSection("Authentication:Schemes:Bearer:ValidAudiences")
.Get<string[]>() ?? Array.Empty<string>();
_audience = audiences.Length > 0 ? audiences[0] : null;
} }
public string GenerateJwtToken(User user, string publicAddress) public string GenerateJwtToken(User user, string publicAddress)
@@ -37,7 +40,7 @@ public class JwtUtils : IJwtUtils
Subject = new ClaimsIdentity(new[] { new Claim("address", publicAddress) }), Subject = new ClaimsIdentity(new[] { new Claim("address", publicAddress) }),
Expires = DateTime.UtcNow.AddDays(15), Expires = DateTime.UtcNow.AddDays(15),
Issuer = _issuer, // Include issuer if configured Issuer = _issuer, // Include issuer if configured
Audience = _audience, // Include audience if configured Audience = _audience, // Include audience if configured (uses first from array)
SigningCredentials = new SigningCredentials( SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key), new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature) SecurityAlgorithms.HmacSha256Signature)

View File

@@ -0,0 +1,231 @@
using Managing.Api.Models;
using Managing.Application.Abstractions.Services;
using Managing.Application.Shared;
using Managing.Domain.Whitelist;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Managing.Api.Controllers;
/// <summary>
/// Controller for managing whitelist accounts (Privy wallets).
/// Requires admin authorization for all operations.
/// </summary>
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class WhitelistController : BaseController
{
private readonly IWhitelistService _whitelistService;
private readonly IAdminConfigurationService _adminService;
private readonly ILogger<WhitelistController> _logger;
public WhitelistController(
IUserService userService,
IWhitelistService whitelistService,
IAdminConfigurationService adminService,
ILogger<WhitelistController> logger)
: base(userService)
{
_whitelistService = whitelistService;
_adminService = adminService;
_logger = logger;
}
/// <summary>
/// Gets a paginated list of whitelist accounts with optional search filters.
/// </summary>
/// <param name="pageNumber">Page number (1-based)</param>
/// <param name="pageSize">Page size (1-100)</param>
/// <param name="searchExternalEthereumAccount">Optional search term for external Ethereum account</param>
/// <param name="searchTwitterAccount">Optional search term for Twitter account</param>
/// <returns>Paginated list of whitelist accounts</returns>
[HttpGet]
public async Task<ActionResult<PaginatedWhitelistAccountsResponse>> GetWhitelistAccounts(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? searchExternalEthereumAccount = null,
[FromQuery] string? searchTwitterAccount = null)
{
var user = await GetUser();
if (!_adminService.IsUserAdmin(user.Name))
{
_logger.LogWarning("User {UserName} attempted to list whitelist accounts without admin privileges", user.Name);
return Forbid("Only admin users can list whitelist accounts");
}
try
{
var (accounts, totalCount) = await _whitelistService.GetPaginatedAsync(
pageNumber,
pageSize,
searchExternalEthereumAccount,
searchTwitterAccount);
return Ok(new PaginatedWhitelistAccountsResponse
{
Accounts = accounts,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing whitelist accounts for admin user {UserName}", user.Name);
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Sets the IsWhitelisted status to true for a selected account.
/// </summary>
/// <param name="id">The ID of the account to whitelist</param>
/// <returns>The updated account</returns>
[HttpPost("{id}/whitelist")]
public async Task<ActionResult<WhitelistAccount>> SetWhitelisted(int id)
{
var user = await GetUser();
if (!_adminService.IsUserAdmin(user.Name))
{
_logger.LogWarning("User {UserName} attempted to set whitelisted status without admin privileges", user.Name);
return Forbid("Only admin users can set whitelisted status");
}
try
{
var updatedCount = await _whitelistService.SetIsWhitelistedAsync(new[] { id }, true);
if (updatedCount == 0)
{
return NotFound(new { error = $"Whitelist account with Id {id} not found" });
}
_logger.LogInformation("Admin user {UserName} set IsWhitelisted=true for account {Id}",
user.Name, id);
// Fetch the updated account to return it
var updatedAccount = await _whitelistService.GetByIdAsync(id);
if (updatedAccount == null)
{
return NotFound(new { error = $"Whitelist account with Id {id} not found after update" });
}
return Ok(updatedAccount);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error setting whitelisted status for admin user {UserName}", user.Name);
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Receives Privy webhook for wallet creation events.
/// This endpoint should be called by Privy when a wallet is created.
/// </summary>
/// <param name="webhook">The Privy webhook payload</param>
/// <returns>The created or updated whitelist account</returns>
[HttpPost("webhook")]
[AllowAnonymous] // Webhook endpoint - consider adding webhook signature verification
public async Task<ActionResult<WhitelistAccount>> ProcessPrivyWebhook(
[FromBody] PrivyWebhookDto webhook)
{
try
{
// Validate webhook type
if (webhook.Type != "user.wallet_created")
{
_logger.LogWarning("Received webhook with unexpected type: {Type}", webhook.Type);
return BadRequest(new { error = $"Unexpected webhook type: {webhook.Type}" });
}
// Validate required fields
if (string.IsNullOrWhiteSpace(webhook.User?.Id))
{
return BadRequest(new { error = "User ID is required" });
}
if (string.IsNullOrWhiteSpace(webhook.Wallet?.Address))
{
return BadRequest(new { error = "Wallet address is required" });
}
// Validate linked accounts
if (webhook.User.LinkedAccounts == null || !webhook.User.LinkedAccounts.Any())
{
return BadRequest(new { error = "At least one linked account is required" });
}
if (webhook.User.LinkedAccounts.Count > 1)
{
_logger.LogWarning("Webhook received with {Count} linked accounts, expected 1. Using first account.",
webhook.User.LinkedAccounts.Count);
}
// Get the first linked account (as per requirement)
var linkedAccount = webhook.User.LinkedAccounts.First();
// Determine ExternalEthereumAccount or TwitterAccount based on linked account type
string? externalEthereumAccount = null;
string? twitterAccount = null;
var accountType = linkedAccount.Type?.ToLowerInvariant();
switch (accountType)
{
case "ethereum":
case "wallet":
case "evm":
externalEthereumAccount = linkedAccount.Address;
break;
case "twitter":
case "twitter_oauth":
twitterAccount = linkedAccount.Address;
break;
default:
_logger.LogWarning("Unknown linked account type: {Type}, address: {Address}",
linkedAccount.Type, linkedAccount.Address);
// Could be email or other types - we'll just log it
break;
}
// Process the webhook
var whitelistAccount = await _whitelistService.ProcessPrivyWebhookAsync(
webhook.User.Id,
webhook.User.CreatedAt,
webhook.Wallet.Address,
externalEthereumAccount,
twitterAccount);
_logger.LogInformation("Privy webhook processed successfully - AccountId: {Id}, PrivyId: {PrivyId}",
whitelistAccount.Id, whitelistAccount.PrivyId);
return Ok(whitelistAccount);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Privy webhook");
SentrySdk.CaptureException(ex);
return StatusCode(500, new { error = ex.Message });
}
}
}
/// <summary>
/// Response model for paginated whitelist accounts.
/// </summary>
public class PaginatedWhitelistAccountsResponse
{
public IEnumerable<WhitelistAccount> Accounts { get; set; } = new List<WhitelistAccount>();
public int TotalCount { get; set; }
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalPages { get; set; }
}

View File

@@ -0,0 +1,79 @@
using System.Text.Json.Serialization;
namespace Managing.Api.Models;
/// <summary>
/// Privy webhook payload for wallet creation events.
/// </summary>
public class PrivyWebhookDto
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("user")]
public PrivyUserDto User { get; set; } = new();
[JsonPropertyName("wallet")]
public PrivyWalletDto Wallet { get; set; } = new();
}
/// <summary>
/// Privy user information from webhook.
/// </summary>
public class PrivyUserDto
{
[JsonPropertyName("created_at")]
public long CreatedAt { get; set; }
[JsonPropertyName("has_accepted_terms")]
public bool HasAcceptedTerms { get; set; }
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("is_guest")]
public bool IsGuest { get; set; }
[JsonPropertyName("linked_accounts")]
public List<PrivyLinkedAccountDto> LinkedAccounts { get; set; } = new();
[JsonPropertyName("mfa_methods")]
public List<object> MfaMethods { get; set; } = new();
}
/// <summary>
/// Privy linked account information from webhook.
/// </summary>
public class PrivyLinkedAccountDto
{
[JsonPropertyName("address")]
public string Address { get; set; } = string.Empty;
[JsonPropertyName("first_verified_at")]
public long? FirstVerifiedAt { get; set; }
[JsonPropertyName("latest_verified_at")]
public long? LatestVerifiedAt { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("verified_at")]
public long? VerifiedAt { get; set; }
}
/// <summary>
/// Privy wallet information from webhook.
/// </summary>
public class PrivyWalletDto
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("address")]
public string Address { get; set; } = string.Empty;
[JsonPropertyName("chain_type")]
public string ChainType { get; set; } = string.Empty;
}

View File

@@ -14,6 +14,7 @@ using Managing.Infrastructure.Databases.InfluxDb.Models;
using Managing.Infrastructure.Databases.PostgreSql; using Managing.Infrastructure.Databases.PostgreSql;
using Managing.Infrastructure.Databases.PostgreSql.Configurations; using Managing.Infrastructure.Databases.PostgreSql.Configurations;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core;
@@ -223,7 +224,8 @@ if (jwtSecret.Length < 32)
// Get issuer and audience configuration // Get issuer and audience configuration
var validIssuer = builder.Configuration["Authentication:Schemes:Bearer:ValidIssuer"]; var validIssuer = builder.Configuration["Authentication:Schemes:Bearer:ValidIssuer"];
var validAudience = builder.Configuration["Authentication:Schemes:Bearer:ValidAudiences"]; var validAudiences = builder.Configuration.GetSection("Authentication:Schemes:Bearer:ValidAudiences")
.Get<string[]>() ?? Array.Empty<string>();
// Determine if validation should be enabled (enable in production, allow override via config) // Determine if validation should be enabled (enable in production, allow override via config)
var enableIssuerValidation = builder.Configuration.GetValue<bool>("Jwt:ValidateIssuer", var enableIssuerValidation = builder.Configuration.GetValue<bool>("Jwt:ValidateIssuer",
@@ -247,11 +249,11 @@ builder.Services
o.TokenValidationParameters = new TokenValidationParameters o.TokenValidationParameters = new TokenValidationParameters
{ {
ValidIssuer = validIssuer, ValidIssuer = validIssuer,
ValidAudience = validAudience, ValidAudiences = validAudiences.Length > 0 ? validAudiences : null,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
ValidateIssuer = enableIssuerValidation && !string.IsNullOrWhiteSpace(validIssuer), ValidateIssuer = enableIssuerValidation && !string.IsNullOrWhiteSpace(validIssuer),
ValidateAudience = enableAudienceValidation && !string.IsNullOrWhiteSpace(validAudience), ValidateAudience = enableAudienceValidation && validAudiences.Length > 0,
ValidateLifetime = true, // Explicitly validate token expiration ValidateLifetime = true, // Explicitly validate token expiration
ClockSkew = clockSkew, // Configure clock skew tolerance ClockSkew = clockSkew, // Configure clock skew tolerance
RequireExpirationTime = true, // Ensure tokens have expiration RequireExpirationTime = true, // Ensure tokens have expiration
@@ -275,6 +277,19 @@ builder.Services
var logger = context.HttpContext.RequestServices var logger = context.HttpContext.RequestServices
.GetService<ILogger<Program>>(); .GetService<ILogger<Program>>();
// Check if the endpoint allows anonymous access
var endpoint = context.HttpContext.GetEndpoint();
var allowAnonymous = endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null;
// For anonymous endpoints with malformed tokens, skip authentication instead of failing
if (allowAnonymous && context.Exception is SecurityTokenMalformedException)
{
logger?.LogDebug("Skipping malformed token validation for anonymous endpoint: {Path}",
context.Request.Path);
context.NoResult(); // Skip authentication, don't fail
return Task.CompletedTask;
}
if (context.Exception is SecurityTokenExpiredException) if (context.Exception is SecurityTokenExpiredException)
{ {
context.Response.Headers["Token-Expired"] = "true"; context.Response.Headers["Token-Expired"] = "true";

View File

@@ -44,14 +44,18 @@
"Cors": { "Cors": {
"AllowedOrigins": [ "AllowedOrigins": [
"https://app.kaigen.ai", "https://app.kaigen.ai",
"https://api.kaigen.ai" "https://api.kaigen.ai",
"https://web-ui.kai.managing.live"
] ]
}, },
"Authentication": { "Authentication": {
"Schemes": { "Schemes": {
"Bearer": { "Bearer": {
"ValidIssuer": "https://api.kaigen.ai", "ValidIssuer": "https://api.kaigen.ai",
"ValidAudiences": "https://app.kaigen.ai" "ValidAudiences": [
"https://app.kaigen.ai",
"https://web-ui.kai.managing.live"
]
} }
} }
}, },

View File

@@ -0,0 +1,19 @@
using Managing.Domain.Whitelist;
namespace Managing.Application.Abstractions.Repositories;
public interface IWhitelistRepository
{
Task<(IEnumerable<WhitelistAccount> Accounts, int TotalCount)> GetPaginatedAsync(
int pageNumber,
int pageSize,
string? searchExternalEthereumAccount = null,
string? searchTwitterAccount = null);
Task<int> SetIsWhitelistedAsync(IEnumerable<int> accountIds, bool isWhitelisted);
Task<WhitelistAccount?> GetByIdAsync(int id);
Task<WhitelistAccount?> GetByPrivyIdAsync(string privyId);
Task<WhitelistAccount?> GetByEmbeddedWalletAsync(string embeddedWallet);
Task<WhitelistAccount> CreateOrUpdateAsync(WhitelistAccount whitelistAccount);
}

View File

@@ -0,0 +1,17 @@
using Managing.Domain.Whitelist;
namespace Managing.Application.Abstractions.Services;
public interface IWhitelistService
{
Task<(IEnumerable<WhitelistAccount> Accounts, int TotalCount)> GetPaginatedAsync(
int pageNumber,
int pageSize,
string? searchExternalEthereumAccount = null,
string? searchTwitterAccount = null);
Task<int> SetIsWhitelistedAsync(IEnumerable<int> accountIds, bool isWhitelisted);
Task<WhitelistAccount?> GetByIdAsync(int id);
Task<WhitelistAccount> ProcessPrivyWebhookAsync(string privyUserId, long privyCreatedAt, string walletAddress, string? externalEthereumAccount, string? twitterAccount);
}

View File

@@ -0,0 +1,111 @@
using Managing.Application.Abstractions.Repositories;
using Managing.Application.Abstractions.Services;
using Managing.Domain.Whitelist;
using Microsoft.Extensions.Logging;
namespace Managing.Application.Whitelist;
public class WhitelistService : IWhitelistService
{
private readonly IWhitelistRepository _whitelistRepository;
private readonly ILogger<WhitelistService> _logger;
public WhitelistService(
IWhitelistRepository whitelistRepository,
ILogger<WhitelistService> logger)
{
_whitelistRepository = whitelistRepository;
_logger = logger;
}
public async Task<(IEnumerable<WhitelistAccount> Accounts, int TotalCount)> GetPaginatedAsync(
int pageNumber,
int pageSize,
string? searchExternalEthereumAccount = null,
string? searchTwitterAccount = null)
{
if (pageNumber < 1)
{
throw new ArgumentException("Page number must be greater than 0", nameof(pageNumber));
}
if (pageSize < 1 || pageSize > 100)
{
throw new ArgumentException("Page size must be between 1 and 100", nameof(pageSize));
}
return await _whitelistRepository.GetPaginatedAsync(
pageNumber,
pageSize,
searchExternalEthereumAccount,
searchTwitterAccount);
}
public async Task<int> SetIsWhitelistedAsync(IEnumerable<int> accountIds, bool isWhitelisted)
{
var idsList = accountIds?.ToList() ?? new List<int>();
if (!idsList.Any())
{
throw new ArgumentException("At least one account ID must be provided", nameof(accountIds));
}
_logger.LogInformation("Setting IsWhitelisted to {IsWhitelisted} for {Count} account(s)",
isWhitelisted, idsList.Count);
var updatedCount = await _whitelistRepository.SetIsWhitelistedAsync(idsList, isWhitelisted);
_logger.LogInformation("Successfully updated {Count} account(s) to IsWhitelisted = {IsWhitelisted}",
updatedCount, isWhitelisted);
return updatedCount;
}
public async Task<WhitelistAccount?> GetByIdAsync(int id)
{
return await _whitelistRepository.GetByIdAsync(id);
}
public async Task<WhitelistAccount> ProcessPrivyWebhookAsync(
string privyUserId,
long privyCreatedAt,
string walletAddress,
string? externalEthereumAccount,
string? twitterAccount)
{
_logger.LogInformation("Processing Privy webhook - PrivyId: {PrivyId}, Wallet: {Wallet}, ExternalEthereum: {ExternalEthereum}, Twitter: {Twitter}",
privyUserId, walletAddress, externalEthereumAccount ?? "null", twitterAccount ?? "null");
// Convert Unix timestamp to DateTime
var privyCreationDate = DateTimeOffset.FromUnixTimeSeconds(privyCreatedAt).DateTime;
// Check if account already exists
var existing = await _whitelistRepository.GetByPrivyIdAsync(privyUserId) ??
await _whitelistRepository.GetByEmbeddedWalletAsync(walletAddress);
var whitelistAccount = new WhitelistAccount
{
PrivyId = privyUserId,
PrivyCreationDate = privyCreationDate,
EmbeddedWallet = walletAddress,
ExternalEthereumAccount = externalEthereumAccount,
TwitterAccount = twitterAccount,
IsWhitelisted = false, // Default to false, admin will set to true later
CreatedAt = existing?.CreatedAt ?? DateTime.UtcNow
};
// If existing, preserve the ID
if (existing != null)
{
whitelistAccount.Id = existing.Id;
}
var result = await _whitelistRepository.CreateOrUpdateAsync(whitelistAccount);
_logger.LogInformation("Privy webhook processed successfully - AccountId: {Id}, PrivyId: {PrivyId}",
result.Id, result.PrivyId);
return result;
}
}

View File

@@ -23,6 +23,7 @@ using Managing.Application.Trading;
using Managing.Application.Trading.Commands; using Managing.Application.Trading.Commands;
using Managing.Application.Trading.Handlers; using Managing.Application.Trading.Handlers;
using Managing.Application.Users; using Managing.Application.Users;
using Managing.Application.Whitelist;
using Managing.Application.Workers; using Managing.Application.Workers;
using Managing.Domain.Trades; using Managing.Domain.Trades;
using Managing.Infrastructure.Databases; using Managing.Infrastructure.Databases;
@@ -408,6 +409,7 @@ public static class ApiBootstrap
services.AddTransient<IWeb3ProxyService, Web3ProxyService>(); services.AddTransient<IWeb3ProxyService, Web3ProxyService>();
services.AddTransient<IWebhookService, WebhookService>(); services.AddTransient<IWebhookService, WebhookService>();
services.AddTransient<IKaigenService, KaigenService>(); services.AddTransient<IKaigenService, KaigenService>();
services.AddTransient<IWhitelistService, WhitelistService>();
services.AddSingleton<IMessengerService, MessengerService>(); services.AddSingleton<IMessengerService, MessengerService>();
services.AddSingleton<IDiscordService, DiscordService>(); services.AddSingleton<IDiscordService, DiscordService>();
@@ -449,6 +451,7 @@ public static class ApiBootstrap
services.AddTransient<IBotRepository, PostgreSqlBotRepository>(); services.AddTransient<IBotRepository, PostgreSqlBotRepository>();
services.AddTransient<IWorkerRepository, PostgreSqlWorkerRepository>(); services.AddTransient<IWorkerRepository, PostgreSqlWorkerRepository>();
services.AddTransient<ISynthRepository, PostgreSqlSynthRepository>(); services.AddTransient<ISynthRepository, PostgreSqlSynthRepository>();
services.AddTransient<IWhitelistRepository, PostgreSqlWhitelistRepository>();
// InfluxDb Repositories - Use Singleton for proper connection management in Orleans grains // InfluxDb Repositories - Use Singleton for proper connection management in Orleans grains
services.AddSingleton<IInfluxDbRepository, InfluxDbRepository>(); services.AddSingleton<IInfluxDbRepository, InfluxDbRepository>();

View File

@@ -0,0 +1,15 @@
namespace Managing.Domain.Whitelist;
public class WhitelistAccount
{
public int Id { get; set; }
public required string PrivyId { get; set; }
public DateTime PrivyCreationDate { get; set; }
public required string EmbeddedWallet { get; set; }
public string? ExternalEthereumAccount { get; set; }
public string? TwitterAccount { get; set; }
public bool IsWhitelisted { get; set; } = false;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Managing.Infrastructure.Databases.Migrations
{
/// <inheritdoc />
public partial class AddWhitelistAccountsTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WhitelistAccounts",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PrivyId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
PrivyCreationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
EmbeddedWallet = table.Column<string>(type: "character varying(42)", maxLength: 42, nullable: false),
ExternalEthereumAccount = table.Column<string>(type: "character varying(42)", maxLength: 42, nullable: true),
TwitterAccount = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
IsWhitelisted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WhitelistAccounts", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_WhitelistAccounts_CreatedAt",
table: "WhitelistAccounts",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_WhitelistAccounts_EmbeddedWallet",
table: "WhitelistAccounts",
column: "EmbeddedWallet",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_WhitelistAccounts_ExternalEthereumAccount",
table: "WhitelistAccounts",
column: "ExternalEthereumAccount");
migrationBuilder.CreateIndex(
name: "IX_WhitelistAccounts_PrivyId",
table: "WhitelistAccounts",
column: "PrivyId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_WhitelistAccounts_TwitterAccount",
table: "WhitelistAccounts",
column: "TwitterAccount");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WhitelistAccounts");
}
}
}

View File

@@ -1325,6 +1325,63 @@ namespace Managing.Infrastructure.Databases.Migrations
b.ToTable("Users"); b.ToTable("Users");
}); });
modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.WhitelistAccountEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EmbeddedWallet")
.IsRequired()
.HasMaxLength(42)
.HasColumnType("character varying(42)");
b.Property<string>("ExternalEthereumAccount")
.HasMaxLength(42)
.HasColumnType("character varying(42)");
b.Property<bool>("IsWhitelisted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<DateTime>("PrivyCreationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("PrivyId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("TwitterAccount")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("EmbeddedWallet")
.IsUnique();
b.HasIndex("ExternalEthereumAccount");
b.HasIndex("PrivyId")
.IsUnique();
b.HasIndex("TwitterAccount");
b.ToTable("WhitelistAccounts");
});
modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.WorkerEntity", b => modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.WorkerEntity", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

View File

@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Managing.Infrastructure.Databases.PostgreSql.Entities;
[Table("WhitelistAccounts")]
public class WhitelistAccountEntity
{
[Key] public int Id { get; set; }
[Required]
[MaxLength(255)]
public required string PrivyId { get; set; }
[Required]
public DateTime PrivyCreationDate { get; set; }
[Required]
[MaxLength(42)]
public required string EmbeddedWallet { get; set; }
[MaxLength(42)]
public string? ExternalEthereumAccount { get; set; }
[MaxLength(255)]
public string? TwitterAccount { get; set; }
[Required]
public bool IsWhitelisted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
}

View File

@@ -55,6 +55,7 @@ public class ManagingDbContext : DbContext
public DbSet<SynthMinersLeaderboardEntity> SynthMinersLeaderboards { get; set; } public DbSet<SynthMinersLeaderboardEntity> SynthMinersLeaderboards { get; set; }
public DbSet<SynthPredictionEntity> SynthPredictions { get; set; } public DbSet<SynthPredictionEntity> SynthPredictions { get; set; }
public DbSet<WhitelistAccountEntity> WhitelistAccounts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -573,6 +574,29 @@ public class ManagingDbContext : DbContext
entity.HasIndex(e => e.CacheKey).IsUnique(); entity.HasIndex(e => e.CacheKey).IsUnique();
}); });
// Configure WhitelistAccount entity
modelBuilder.Entity<WhitelistAccountEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.PrivyId).IsRequired().HasMaxLength(255);
entity.Property(e => e.PrivyCreationDate).IsRequired();
entity.Property(e => e.EmbeddedWallet).IsRequired().HasMaxLength(42);
entity.Property(e => e.ExternalEthereumAccount).HasMaxLength(42);
entity.Property(e => e.TwitterAccount).HasMaxLength(255);
entity.Property(e => e.IsWhitelisted)
.IsRequired()
.HasDefaultValue(false);
entity.Property(e => e.CreatedAt).IsRequired();
entity.Property(e => e.UpdatedAt);
// Create indexes for search performance
entity.HasIndex(e => e.PrivyId).IsUnique();
entity.HasIndex(e => e.EmbeddedWallet).IsUnique();
entity.HasIndex(e => e.ExternalEthereumAccount);
entity.HasIndex(e => e.TwitterAccount);
entity.HasIndex(e => e.CreatedAt);
});
// Configure AgentSummary entity // Configure AgentSummary entity
modelBuilder.Entity<AgentSummaryEntity>(entity => modelBuilder.Entity<AgentSummaryEntity>(entity =>
{ {

View File

@@ -10,6 +10,7 @@ using Managing.Domain.Statistics;
using Managing.Domain.Strategies; using Managing.Domain.Strategies;
using Managing.Domain.Trades; using Managing.Domain.Trades;
using Managing.Domain.Users; using Managing.Domain.Users;
using Managing.Domain.Whitelist;
using Managing.Domain.Workers; using Managing.Domain.Workers;
using Managing.Infrastructure.Databases.PostgreSql.Entities; using Managing.Infrastructure.Databases.PostgreSql.Entities;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -992,6 +993,51 @@ public static class PostgreSqlMappers
#endregion #endregion
#region WhitelistAccount Mappings
public static WhitelistAccount Map(WhitelistAccountEntity entity)
{
if (entity == null) return null;
return new WhitelistAccount
{
Id = entity.Id,
PrivyId = entity.PrivyId,
PrivyCreationDate = entity.PrivyCreationDate,
EmbeddedWallet = entity.EmbeddedWallet,
ExternalEthereumAccount = entity.ExternalEthereumAccount,
TwitterAccount = entity.TwitterAccount,
IsWhitelisted = entity.IsWhitelisted,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
};
}
public static WhitelistAccountEntity Map(WhitelistAccount whitelistAccount)
{
if (whitelistAccount == null) return null;
return new WhitelistAccountEntity
{
Id = whitelistAccount.Id,
PrivyId = whitelistAccount.PrivyId,
PrivyCreationDate = whitelistAccount.PrivyCreationDate,
EmbeddedWallet = whitelistAccount.EmbeddedWallet,
ExternalEthereumAccount = whitelistAccount.ExternalEthereumAccount,
TwitterAccount = whitelistAccount.TwitterAccount,
IsWhitelisted = whitelistAccount.IsWhitelisted,
CreatedAt = whitelistAccount.CreatedAt,
UpdatedAt = whitelistAccount.UpdatedAt
};
}
public static IEnumerable<WhitelistAccount> Map(IEnumerable<WhitelistAccountEntity> entities)
{
return entities?.Select(Map) ?? Enumerable.Empty<WhitelistAccount>();
}
#endregion
private static int? ExtractBundleIndex(string name) private static int? ExtractBundleIndex(string name)
{ {
if (string.IsNullOrWhiteSpace(name)) return null; if (string.IsNullOrWhiteSpace(name)) return null;

View File

@@ -0,0 +1,237 @@
using Managing.Application.Abstractions.Repositories;
using Managing.Domain.Whitelist;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Managing.Infrastructure.Databases.PostgreSql;
public class PostgreSqlWhitelistRepository : BaseRepositoryWithLogging, IWhitelistRepository
{
public PostgreSqlWhitelistRepository(
ManagingDbContext context,
ILogger<SqlQueryLogger> logger,
SentrySqlMonitoringService sentryMonitoringService)
: base(context, logger, sentryMonitoringService)
{
}
public async Task<(IEnumerable<WhitelistAccount> Accounts, int TotalCount)> GetPaginatedAsync(
int pageNumber,
int pageSize,
string? searchExternalEthereumAccount = null,
string? searchTwitterAccount = null)
{
return await ExecuteWithLoggingAsync(async () =>
{
try
{
await PostgreSqlConnectionHelper.EnsureConnectionOpenAsync(_context);
var query = _context.WhitelistAccounts.AsNoTracking().AsQueryable();
// Apply search filters
if (!string.IsNullOrWhiteSpace(searchExternalEthereumAccount))
{
var searchTerm = searchExternalEthereumAccount.Trim().ToLower();
query = query.Where(e =>
e.ExternalEthereumAccount != null &&
e.ExternalEthereumAccount.ToLower().Contains(searchTerm));
}
if (!string.IsNullOrWhiteSpace(searchTwitterAccount))
{
var searchTerm = searchTwitterAccount.Trim().ToLower();
query = query.Where(e =>
e.TwitterAccount != null &&
e.TwitterAccount.ToLower().Contains(searchTerm));
}
// Get total count before pagination
var totalCount = await query.CountAsync().ConfigureAwait(false);
// Apply pagination and ordering
var entities = await query
.OrderByDescending(e => e.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync()
.ConfigureAwait(false);
var accounts = entities.Select(PostgreSqlMappers.Map);
return (accounts, totalCount);
}
finally
{
await PostgreSqlConnectionHelper.SafeCloseConnectionAsync(_context);
}
}, nameof(GetPaginatedAsync),
("pageNumber", pageNumber.ToString()),
("pageSize", pageSize.ToString()));
}
public async Task<int> SetIsWhitelistedAsync(IEnumerable<int> accountIds, bool isWhitelisted)
{
return await ExecuteWithLoggingAsync(async () =>
{
try
{
await PostgreSqlConnectionHelper.EnsureConnectionOpenAsync(_context);
var idsList = accountIds.ToList();
if (!idsList.Any())
{
return 0;
}
var entities = await _context.WhitelistAccounts
.AsTracking()
.Where(e => idsList.Contains(e.Id))
.ToListAsync()
.ConfigureAwait(false);
foreach (var entity in entities)
{
entity.IsWhitelisted = isWhitelisted;
entity.UpdatedAt = DateTime.UtcNow;
}
var updatedCount = await _context.SaveChangesAsync().ConfigureAwait(false);
return updatedCount;
}
finally
{
await PostgreSqlConnectionHelper.SafeCloseConnectionAsync(_context);
}
}, nameof(SetIsWhitelistedAsync),
("accountIds", string.Join(",", accountIds)),
("isWhitelisted", isWhitelisted.ToString()));
}
public async Task<WhitelistAccount?> GetByIdAsync(int id)
{
return await ExecuteWithLoggingAsync(async () =>
{
try
{
await PostgreSqlConnectionHelper.EnsureConnectionOpenAsync(_context);
var entity = await _context.WhitelistAccounts
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == id)
.ConfigureAwait(false);
return entity != null ? PostgreSqlMappers.Map(entity) : null;
}
finally
{
await PostgreSqlConnectionHelper.SafeCloseConnectionAsync(_context);
}
}, nameof(GetByIdAsync), ("id", id.ToString()));
}
public async Task<WhitelistAccount?> GetByPrivyIdAsync(string privyId)
{
return await ExecuteWithLoggingAsync(async () =>
{
try
{
await PostgreSqlConnectionHelper.EnsureConnectionOpenAsync(_context);
var entity = await _context.WhitelistAccounts
.AsNoTracking()
.FirstOrDefaultAsync(e => e.PrivyId == privyId)
.ConfigureAwait(false);
return entity != null ? PostgreSqlMappers.Map(entity) : null;
}
finally
{
await PostgreSqlConnectionHelper.SafeCloseConnectionAsync(_context);
}
}, nameof(GetByPrivyIdAsync), ("privyId", privyId));
}
public async Task<WhitelistAccount?> GetByEmbeddedWalletAsync(string embeddedWallet)
{
return await ExecuteWithLoggingAsync(async () =>
{
try
{
await PostgreSqlConnectionHelper.EnsureConnectionOpenAsync(_context);
var entity = await _context.WhitelistAccounts
.AsNoTracking()
.FirstOrDefaultAsync(e => e.EmbeddedWallet.ToLower() == embeddedWallet.ToLower())
.ConfigureAwait(false);
return entity != null ? PostgreSqlMappers.Map(entity) : null;
}
finally
{
await PostgreSqlConnectionHelper.SafeCloseConnectionAsync(_context);
}
}, nameof(GetByEmbeddedWalletAsync), ("embeddedWallet", embeddedWallet));
}
public async Task<WhitelistAccount> CreateOrUpdateAsync(WhitelistAccount whitelistAccount)
{
return await ExecuteWithLoggingAsync(async () =>
{
try
{
await PostgreSqlConnectionHelper.EnsureConnectionOpenAsync(_context);
// Check if account exists by PrivyId or EmbeddedWallet
var existing = await _context.WhitelistAccounts
.AsTracking()
.FirstOrDefaultAsync(e =>
e.PrivyId == whitelistAccount.PrivyId ||
e.EmbeddedWallet.ToLower() == whitelistAccount.EmbeddedWallet.ToLower())
.ConfigureAwait(false);
if (existing != null)
{
// Update existing account
existing.PrivyId = whitelistAccount.PrivyId;
existing.PrivyCreationDate = whitelistAccount.PrivyCreationDate;
existing.EmbeddedWallet = whitelistAccount.EmbeddedWallet;
// Only update if new value is provided
if (!string.IsNullOrWhiteSpace(whitelistAccount.ExternalEthereumAccount))
{
existing.ExternalEthereumAccount = whitelistAccount.ExternalEthereumAccount;
}
if (!string.IsNullOrWhiteSpace(whitelistAccount.TwitterAccount))
{
existing.TwitterAccount = whitelistAccount.TwitterAccount;
}
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync().ConfigureAwait(false);
return PostgreSqlMappers.Map(existing);
}
else
{
// Create new account
var entity = PostgreSqlMappers.Map(whitelistAccount);
entity.CreatedAt = DateTime.UtcNow;
_context.WhitelistAccounts.Add(entity);
await _context.SaveChangesAsync().ConfigureAwait(false);
return PostgreSqlMappers.Map(entity);
}
}
finally
{
await PostgreSqlConnectionHelper.SafeCloseConnectionAsync(_context);
}
}, nameof(CreateOrUpdateAsync), ("privyId", whitelistAccount.PrivyId));
}
}