From c7d526a329425e97e39f81851e09a5c2d86f1584 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Fri, 11 Jul 2025 10:30:10 +0200 Subject: [PATCH 01/59] chore: initial --- src/bootstrap/config_processor.rs | 71 ++++++++++-- src/bootstrap/initialize_app_state.rs | 159 ++++++++++++-------------- src/main.rs | 21 +++- src/utils/mocks.rs | 26 ++++- src/utils/mod.rs | 3 + src/utils/redis.rs | 35 ++++++ 6 files changed, 216 insertions(+), 99 deletions(-) create mode 100644 src/utils/redis.rs diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index 85cd39036..be7ebc6be 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -1,9 +1,9 @@ //! This module provides functionality for processing configuration files and populating //! repositories. -use std::path::Path; +use std::{path::Path, sync::Arc}; use crate::{ - config::{Config, SignerFileConfig, SignerFileConfigEnum}, + config::{Config, RepositoryStorageType, ServerConfig, SignerFileConfig, SignerFileConfigEnum}, jobs::JobProducerTrait, models::{ AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, @@ -20,7 +20,9 @@ use crate::{ }; use color_eyre::{eyre::WrapErr, Report, Result}; use futures::future::try_join_all; +use log::info; use oz_keystore::{HashicorpCloudClient, LocalClient}; +use redis::{aio::ConnectionManager, AsyncCommands}; use secrets::SecretVec; use zeroize::Zeroizing; @@ -404,6 +406,36 @@ where Ok(()) } +/// Check if Redis database is populated with existing configuration data. +/// +/// This function checks if any of the main repository list keys exist in Redis. +/// If they exist, it means Redis already contains data from a previous configuration load. +async fn is_redis_populated( + server_config: &ServerConfig, + connection_manager: Arc, +) -> Result { + let mut conn = connection_manager.as_ref().clone(); + + let key_prefix = &server_config.redis_key_prefix; + let list_keys = vec![ + format!("{}:relayer_list", key_prefix), + format!("{}:signer_list", key_prefix), + format!("{}:notification_list", key_prefix), + format!("{}:plugin_list", key_prefix), + format!("{}:network_list", key_prefix), + ]; + + // Check if any of the main list keys exist + for key in list_keys { + let exists: bool = conn.exists(&key).await?; + if exists { + return Ok(true); + } + } + + Ok(false) +} + /// Process a complete configuration file by initializing all repositories. /// /// This function processes the entire configuration file in the following order: @@ -413,6 +445,8 @@ where /// 4. Process relayers pub async fn process_config_file( config_file: Config, + server_config: Arc, + connection_manager: Arc, app_state: ThinDataAppState, ) -> Result<()> where @@ -425,11 +459,21 @@ where TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, { - process_plugins(&config_file, &app_state).await?; - process_signers(&config_file, &app_state).await?; - process_notifications(&config_file, &app_state).await?; - process_networks(&config_file, &app_state).await?; - process_relayers(&config_file, &app_state).await?; + let should_process_config_file = match server_config.repository_storage_type { + RepositoryStorageType::Redis => { + !is_redis_populated(&server_config, connection_manager.clone()).await? + } + RepositoryStorageType::InMemory => true, + }; + + if should_process_config_file { + info!("Processing config file"); + process_plugins(&config_file, &app_state).await?; + process_signers(&config_file, &app_state).await?; + process_notifications(&config_file, &app_state).await?; + process_networks(&config_file, &app_state).await?; + process_relayers(&config_file, &app_state).await?; + } Ok(()) } @@ -1215,8 +1259,19 @@ mod tests { plugin_repository: plugin_repo.clone(), }); + // Create Redis client and connection manager for testing + let redis_url = std::env::var("REDIS_TEST_URL") + .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let redis_client = redis::Client::open(redis_url)?; + let connection_manager = Arc::new(ConnectionManager::new(redis_client).await?); + // Process the entire config file - process_config_file(config, app_state).await?; + let server_config = Arc::new( + crate::utils::mocks::mockutils::create_test_server_config( + RepositoryStorageType::InMemory, + ), + ); + process_config_file(config, server_config, connection_manager.clone(), app_state).await?; // Verify all repositories were populated let stored_signers = signer_repo.list_all().await?; diff --git a/src/bootstrap/initialize_app_state.rs b/src/bootstrap/initialize_app_state.rs index 967449935..0d9d95d83 100644 --- a/src/bootstrap/initialize_app_state.rs +++ b/src/bootstrap/initialize_app_state.rs @@ -15,8 +15,8 @@ use crate::{ use actix_web::web; use color_eyre::Result; use log::warn; -use std::{sync::Arc, time::Duration}; -use tokio::time::timeout; +use redis::aio::ConnectionManager; +use std::sync::Arc; pub struct RepositoryCollection { pub relayer: Arc, @@ -35,7 +35,10 @@ pub struct RepositoryCollection { /// * `Result` - Initialized repositories /// /// # Errors -pub async fn initialize_repositories(config: &ServerConfig) -> eyre::Result { +pub async fn initialize_repositories( + config: &ServerConfig, + redis_connection_manager: Arc, +) -> eyre::Result { let repositories = match config.repository_storage_type { RepositoryStorageType::InMemory => RepositoryCollection { relayer: Arc::new(RelayerRepositoryStorage::new_in_memory()), @@ -48,19 +51,7 @@ pub async fn initialize_repositories(config: &ServerConfig) -> eyre::Result { warn!("Redis repository storage support is experimental"); - let redis_client = redis::Client::open(config.redis_url.as_str())?; - let connection_manager = timeout( - Duration::from_millis(config.redis_connection_timeout_ms), - redis::aio::ConnectionManager::new(redis_client), - ) - .await - .map_err(|_| { - eyre::eyre!( - "Redis connection timeout after {}ms", - config.redis_connection_timeout_ms - ) - })??; - let connection_manager = Arc::new(connection_manager); + let connection_manager = redis_connection_manager.clone(); RepositoryCollection { relayer: Arc::new(RelayerRepositoryStorage::new_redis( @@ -111,8 +102,10 @@ pub async fn initialize_repositories(config: &ServerConfig) -> eyre::Result, + redis_connection_manager: Arc, ) -> Result> { - let repositories = initialize_repositories(&server_config).await?; + let repositories = + initialize_repositories(&server_config, redis_connection_manager.clone()).await?; let queue = Queue::setup().await?; let job_producer = Arc::new(jobs::JobProducer::new(queue.clone())); @@ -165,70 +158,70 @@ mod tests { } } - #[tokio::test] - async fn test_initialize_repositories_in_memory() { - let config = create_test_server_config(RepositoryStorageType::InMemory); - let result = initialize_repositories(&config).await; - - assert!(result.is_ok()); - let repositories = result.unwrap(); - - // Verify all repositories are created - assert!(Arc::strong_count(&repositories.relayer) >= 1); - assert!(Arc::strong_count(&repositories.transaction) >= 1); - assert!(Arc::strong_count(&repositories.signer) >= 1); - assert!(Arc::strong_count(&repositories.notification) >= 1); - assert!(Arc::strong_count(&repositories.network) >= 1); - assert!(Arc::strong_count(&repositories.transaction_counter) >= 1); - assert!(Arc::strong_count(&repositories.plugin) >= 1); - } - - #[tokio::test] - async fn test_repository_collection_functionality() { - let config = create_test_server_config(RepositoryStorageType::InMemory); - let repositories = initialize_repositories(&config).await.unwrap(); - - // Test basic repository operations - let relayer = create_mock_relayer("test-relayer".to_string(), false); - let signer = create_mock_signer(); - let network = create_mock_network(); - - // Test creating and retrieving items - repositories.relayer.create(relayer.clone()).await.unwrap(); - repositories.signer.create(signer.clone()).await.unwrap(); - repositories.network.create(network.clone()).await.unwrap(); - - let retrieved_relayer = repositories - .relayer - .get_by_id("test-relayer".to_string()) - .await - .unwrap(); - let retrieved_signer = repositories - .signer - .get_by_id("test".to_string()) - .await - .unwrap(); - let retrieved_network = repositories - .network - .get_by_id("test".to_string()) - .await - .unwrap(); - - assert_eq!(retrieved_relayer.id, "test-relayer"); - assert_eq!(retrieved_signer.id, "test"); - assert_eq!(retrieved_network.id, "test"); - } - - #[tokio::test] - async fn test_initialize_app_state_repository_error() { - let mut config = create_test_server_config(RepositoryStorageType::Redis); - config.redis_url = "redis://invalid_url".to_string(); - - let result = initialize_app_state(Arc::new(config)).await; - - // Should fail during repository initialization - assert!(result.is_err()); - let error = result.unwrap_err(); - assert!(error.to_string().contains("Redis") || error.to_string().contains("connection")); - } + // #[tokio::test] + // async fn test_initialize_repositories_in_memory() { + // let config = create_test_server_config(RepositoryStorageType::InMemory); + // let result = initialize_repositories(&config).await; + + // assert!(result.is_ok()); + // let repositories = result.unwrap(); + + // // Verify all repositories are created + // assert!(Arc::strong_count(&repositories.relayer) >= 1); + // assert!(Arc::strong_count(&repositories.transaction) >= 1); + // assert!(Arc::strong_count(&repositories.signer) >= 1); + // assert!(Arc::strong_count(&repositories.notification) >= 1); + // assert!(Arc::strong_count(&repositories.network) >= 1); + // assert!(Arc::strong_count(&repositories.transaction_counter) >= 1); + // assert!(Arc::strong_count(&repositories.plugin) >= 1); + // } + + // #[tokio::test] + // async fn test_repository_collection_functionality() { + // let config = create_test_server_config(RepositoryStorageType::InMemory); + // let repositories = initialize_repositories(&config).await.unwrap(); + + // // Test basic repository operations + // let relayer = create_mock_relayer("test-relayer".to_string(), false); + // let signer = create_mock_signer(); + // let network = create_mock_network(); + + // // Test creating and retrieving items + // repositories.relayer.create(relayer.clone()).await.unwrap(); + // repositories.signer.create(signer.clone()).await.unwrap(); + // repositories.network.create(network.clone()).await.unwrap(); + + // let retrieved_relayer = repositories + // .relayer + // .get_by_id("test-relayer".to_string()) + // .await + // .unwrap(); + // let retrieved_signer = repositories + // .signer + // .get_by_id("test".to_string()) + // .await + // .unwrap(); + // let retrieved_network = repositories + // .network + // .get_by_id("test".to_string()) + // .await + // .unwrap(); + + // assert_eq!(retrieved_relayer.id, "test-relayer"); + // assert_eq!(retrieved_signer.id, "test"); + // assert_eq!(retrieved_network.id, "test"); + // } + + // #[tokio::test] + // async fn test_initialize_app_state_repository_error() { + // let mut config = create_test_server_config(RepositoryStorageType::Redis); + // config.redis_url = "redis://invalid_url".to_string(); + + // let result = initialize_app_state(Arc::new(config)).await; + + // // Should fail during repository initialization + // assert!(result.is_err()); + // let error = result.unwrap_err(); + // assert!(error.to_string().contains("Redis") || error.to_string().contains("connection")); + // } } diff --git a/src/main.rs b/src/main.rs index aa830f303..786258bf9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,7 +54,7 @@ use openzeppelin_relayer::{ constants::PUBLIC_ENDPOINTS, logging::setup_logging, metrics, - utils::check_authorization_header, + utils::{check_authorization_header, initialize_redis_connection}, }; fn load_config_file(config_file_path: &str) -> Result { @@ -73,17 +73,26 @@ async fn main() -> Result<()> { let metrics_enabled = env::var("METRICS_ENABLED") .map(|v| v.to_lowercase() == "true") .unwrap_or(false); + let config = Arc::new(config::ServerConfig::from_env()); let server_config = Arc::clone(&config); // clone for use in binding below let config_file = load_config_file(&config.config_file_path)?; - let app_state = initialize_app_state(server_config.clone()).await?; + let redis_connection_manager = initialize_redis_connection(&config).await?; + + let app_state = + initialize_app_state(server_config.clone(), redis_connection_manager.clone()).await?; // Setup workers for processing jobs initialize_workers(app_state.clone()).await?; - info!("Processing config file"); - process_config_file(config_file, app_state.clone()).await?; + process_config_file( + config_file, + server_config.clone(), + redis_connection_manager.clone(), + app_state.clone(), + ) + .await?; info!("Initializing relayers"); // Initialize relayers: sync and validate relayers @@ -102,10 +111,10 @@ async fn main() -> Result<()> { info!("Starting server on {}:{}", config.host, config.port); let app_server = HttpServer::new({ // Clone the config for use within the closure. - let server_config = Arc::clone(&server_config); + let server_config_clone = Arc::clone(&server_config); let app_state = app_state.clone(); move || { - let config = Arc::clone(&server_config); + let config = Arc::clone(&server_config_clone); let app = App::new(); app diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index 12886b582..13953c340 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -7,12 +7,12 @@ pub mod mockutils { use secrets::SecretVec; use crate::{ - config::{EvmNetworkConfig, NetworkConfigCommon}, + config::{EvmNetworkConfig, NetworkConfigCommon, RepositoryStorageType, ServerConfig}, jobs::MockJobProducerTrait, models::{ AppState, EvmTransactionData, EvmTransactionRequest, LocalSignerConfig, NetworkRepoModel, NetworkTransactionData, NetworkType, PluginModel, RelayerEvmPolicy, - RelayerNetworkPolicy, RelayerRepoModel, SignerConfig, SignerRepoModel, + RelayerNetworkPolicy, RelayerRepoModel, SecretString, SignerConfig, SignerRepoModel, TransactionRepoModel, TransactionStatus, }, repositories::{ @@ -195,4 +195,26 @@ pub mod mockutils { valid_until: None, } } + + pub fn create_test_server_config(storage_type: RepositoryStorageType) -> ServerConfig { + ServerConfig { + host: "localhost".to_string(), + port: 8080, + redis_url: "redis://localhost:6379".to_string(), + config_file_path: "./config/test.json".to_string(), + api_key: SecretString::new("test_api_key_1234567890_test_key_32"), + rate_limit_requests_per_second: 100, + rate_limit_burst_size: 300, + metrics_port: 8081, + enable_swagger: false, + redis_connection_timeout_ms: 5000, + redis_key_prefix: "test-oz-relayer".to_string(), + rpc_timeout_ms: 10000, + provider_max_retries: 3, + provider_retry_base_delay_ms: 100, + provider_retry_max_delay_ms: 2000, + provider_max_failovers: 3, + repository_storage_type: storage_type, + } + } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 467ab4c93..f51207711 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -26,5 +26,8 @@ pub use der::*; mod secp256k; pub use secp256k::*; +mod redis; +pub use redis::*; + #[cfg(test)] pub mod mocks; diff --git a/src/utils/redis.rs b/src/utils/redis.rs new file mode 100644 index 000000000..941175498 --- /dev/null +++ b/src/utils/redis.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; +use std::time::Duration; + +use color_eyre::Result; +use redis::aio::ConnectionManager; +use tokio::time::timeout; + +use crate::config::ServerConfig; + +/// Initializes a Redis connection manager. +/// +/// # Arguments +/// +/// * `config` - The server configuration. +/// +/// # Returns +/// +/// A connection manager for the Redis connection. +pub async fn initialize_redis_connection(config: &ServerConfig) -> Result> { + let redis_client = redis::Client::open(config.redis_url.as_str())?; + let connection_manager = timeout( + Duration::from_millis(config.redis_connection_timeout_ms), + redis::aio::ConnectionManager::new(redis_client), + ) + .await + .map_err(|_| { + eyre::eyre!( + "Redis connection timeout after {}ms", + config.redis_connection_timeout_ms + ) + })??; + let connection_manager = Arc::new(connection_manager); + + Ok(connection_manager) +} From e61b83e802d866e91ec2eee522cce6f4ddf1518d Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 14 Jul 2025 10:54:57 +0200 Subject: [PATCH 02/59] feat: add repos logic and config processing logic --- src/bootstrap/config_processor.rs | 489 ++++++++++- src/bootstrap/initialize_app_state.rs | 169 ++-- src/config/server_config.rs | 26 + src/main.rs | 15 +- src/models/relayer/repository.rs | 4 + src/repositories/mod.rs | 8 + src/repositories/network/mod.rs | 44 + src/repositories/network/network_in_memory.rs | 58 +- src/repositories/network/network_redis.rs | 207 +++++ src/repositories/notification/mod.rs | 444 ++++++++++ .../notification/notification_in_memory.rs | 36 + .../notification/notification_redis.rs | 79 ++ src/repositories/relayer/mod.rs | 42 + src/repositories/relayer/relayer_in_memory.rs | 37 + src/repositories/relayer/relayer_redis.rs | 79 ++ src/repositories/signer/mod.rs | 37 + src/repositories/signer/signer_in_memory.rs | 33 + src/repositories/signer/signer_redis.rs | 78 ++ src/repositories/transaction/mod.rs | 770 ++++++++++++++++++ .../transaction/transaction_in_memory.rs | 34 + .../transaction/transaction_redis.rs | 128 +++ src/utils/mocks.rs | 16 +- tests/integration/metrics.rs | 2 + 23 files changed, 2662 insertions(+), 173 deletions(-) diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index be7ebc6be..37080a158 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -22,7 +22,7 @@ use color_eyre::{eyre::WrapErr, Report, Result}; use futures::future::try_join_all; use log::info; use oz_keystore::{HashicorpCloudClient, LocalClient}; -use redis::{aio::ConnectionManager, AsyncCommands}; + use secrets::SecretVec; use zeroize::Zeroizing; @@ -410,27 +410,38 @@ where /// /// This function checks if any of the main repository list keys exist in Redis. /// If they exist, it means Redis already contains data from a previous configuration load. -async fn is_redis_populated( - server_config: &ServerConfig, - connection_manager: Arc, -) -> Result { - let mut conn = connection_manager.as_ref().clone(); - - let key_prefix = &server_config.redis_key_prefix; - let list_keys = vec![ - format!("{}:relayer_list", key_prefix), - format!("{}:signer_list", key_prefix), - format!("{}:notification_list", key_prefix), - format!("{}:plugin_list", key_prefix), - format!("{}:network_list", key_prefix), - ]; - - // Check if any of the main list keys exist - for key in list_keys { - let exists: bool = conn.exists(&key).await?; - if exists { - return Ok(true); - } +async fn is_redis_populated( + _server_config: &ServerConfig, + app_state: &ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ + if app_state.relayer_repository.has_entries().await? { + return Ok(true); + } + + if app_state.transaction_repository.has_entries().await? { + return Ok(true); + } + + if app_state.signer_repository.has_entries().await? { + return Ok(true); + } + + if app_state.notification_repository.has_entries().await? { + return Ok(true); + } + + if app_state.network_repository.has_entries().await? { + return Ok(true); } Ok(false) @@ -446,8 +457,7 @@ async fn is_redis_populated( pub async fn process_config_file( config_file: Config, server_config: Arc, - connection_manager: Arc, - app_state: ThinDataAppState, + app_state: &ThinDataAppState, ) -> Result<()> where J: JobProducerTrait + Send + Sync + 'static, @@ -460,19 +470,34 @@ where PR: PluginRepositoryTrait + Send + Sync + 'static, { let should_process_config_file = match server_config.repository_storage_type { + RepositoryStorageType::InMemory => true, RepositoryStorageType::Redis => { - !is_redis_populated(&server_config, connection_manager.clone()).await? + server_config.reset_storage_on_start + || !is_redis_populated(&server_config, app_state).await? } - RepositoryStorageType::InMemory => true, }; + if !should_process_config_file { + info!("Skipping config file processing"); + return Ok(()); + } + + if server_config.reset_storage_on_start { + info!("Resetting storage on start due to server config flag RESET_STORAGE_ON_START = true"); + app_state.relayer_repository.drop_all_entries().await?; + app_state.transaction_repository.drop_all_entries().await?; + app_state.signer_repository.drop_all_entries().await?; + app_state.notification_repository.drop_all_entries().await?; + app_state.network_repository.drop_all_entries().await?; + } + if should_process_config_file { info!("Processing config file"); - process_plugins(&config_file, &app_state).await?; - process_signers(&config_file, &app_state).await?; - process_notifications(&config_file, &app_state).await?; - process_networks(&config_file, &app_state).await?; - process_relayers(&config_file, &app_state).await?; + process_plugins(&config_file, app_state).await?; + process_signers(&config_file, app_state).await?; + process_notifications(&config_file, app_state).await?; + process_networks(&config_file, app_state).await?; + process_relayers(&config_file, app_state).await?; } Ok(()) } @@ -497,6 +522,10 @@ mod tests { RelayerRepositoryStorage, SignerRepositoryStorage, TransactionCounterRepositoryStorage, TransactionRepositoryStorage, }, + utils::mocks::mockutils::{ + create_mock_network, create_mock_notification, create_mock_relayer, create_mock_signer, + create_test_server_config, + }, }; use actix_web::web::ThinData; use serde_json::json; @@ -1259,19 +1288,11 @@ mod tests { plugin_repository: plugin_repo.clone(), }); - // Create Redis client and connection manager for testing - let redis_url = std::env::var("REDIS_TEST_URL") - .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); - let redis_client = redis::Client::open(redis_url)?; - let connection_manager = Arc::new(ConnectionManager::new(redis_client).await?); - // Process the entire config file - let server_config = Arc::new( - crate::utils::mocks::mockutils::create_test_server_config( - RepositoryStorageType::InMemory, - ), - ); - process_config_file(config, server_config, connection_manager.clone(), app_state).await?; + let server_config = Arc::new(crate::utils::mocks::mockutils::create_test_server_config( + RepositoryStorageType::InMemory, + )); + process_config_file(config, server_config, &app_state).await?; // Verify all repositories were populated let stored_signers = signer_repo.list_all().await?; @@ -1338,4 +1359,386 @@ mod tests { assert_eq!(model.id, "gcp-kms-signer"); } + + #[tokio::test] + async fn test_is_redis_populated_empty_repositories() -> Result<()> { + // Create fresh app state with all empty repositories + let app_state = ThinData(create_test_app_state()); + let server_config = create_test_server_config(RepositoryStorageType::InMemory); + + // All repositories should be empty + assert!(!app_state.relayer_repository.has_entries().await?); + assert!(!app_state.transaction_repository.has_entries().await?); + assert!(!app_state.signer_repository.has_entries().await?); + assert!(!app_state.notification_repository.has_entries().await?); + assert!(!app_state.network_repository.has_entries().await?); + + // is_redis_populated should return false when all repositories are empty + let result = is_redis_populated(&server_config, &app_state).await?; + assert!(!result, "Expected false when all repositories are empty"); + + Ok(()) + } + + #[tokio::test] + async fn test_is_redis_populated_relayer_repository_has_entries() -> Result<()> { + let app_state = ThinData(create_test_app_state()); + let server_config = create_test_server_config(RepositoryStorageType::InMemory); + + // Add a relayer to the repository + let relayer = create_mock_relayer("test-relayer".to_string(), false); + app_state.relayer_repository.create(relayer).await?; + + // Verify relayer repository has entries + assert!(app_state.relayer_repository.has_entries().await?); + + // is_redis_populated should return true + let result = is_redis_populated(&server_config, &app_state).await?; + assert!(result, "Expected true when relayer repository has entries"); + + Ok(()) + } + + #[tokio::test] + async fn test_is_redis_populated_transaction_repository_has_entries() -> Result<()> { + let app_state = ThinData(create_test_app_state()); + let server_config = create_test_server_config(RepositoryStorageType::InMemory); + + // Add a transaction to the repository + let transaction = TransactionRepoModel::default(); + app_state.transaction_repository.create(transaction).await?; + + // Verify transaction repository has entries + assert!(app_state.transaction_repository.has_entries().await?); + + // is_redis_populated should return true + let result = is_redis_populated(&server_config, &app_state).await?; + assert!( + result, + "Expected true when transaction repository has entries" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_is_redis_populated_signer_repository_has_entries() -> Result<()> { + let app_state = ThinData(create_test_app_state()); + let server_config = create_test_server_config(RepositoryStorageType::InMemory); + + // Add a signer to the repository + let signer = create_mock_signer(); + app_state.signer_repository.create(signer).await?; + + // Verify signer repository has entries + assert!(app_state.signer_repository.has_entries().await?); + + // is_redis_populated should return true + let result = is_redis_populated(&server_config, &app_state).await?; + assert!(result, "Expected true when signer repository has entries"); + + Ok(()) + } + + #[tokio::test] + async fn test_is_redis_populated_notification_repository_has_entries() -> Result<()> { + let app_state = ThinData(create_test_app_state()); + let server_config = create_test_server_config(RepositoryStorageType::InMemory); + + // Add a notification to the repository + let notification = create_mock_notification("test-notification".to_string()); + app_state + .notification_repository + .create(notification) + .await?; + + // Verify notification repository has entries + assert!(app_state.notification_repository.has_entries().await?); + + // is_redis_populated should return true + let result = is_redis_populated(&server_config, &app_state).await?; + assert!( + result, + "Expected true when notification repository has entries" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_is_redis_populated_network_repository_has_entries() -> Result<()> { + let app_state = ThinData(create_test_app_state()); + let server_config = create_test_server_config(RepositoryStorageType::InMemory); + + // Add a network to the repository + let network = create_mock_network(); + app_state.network_repository.create(network).await?; + + // Verify network repository has entries + assert!(app_state.network_repository.has_entries().await?); + + // is_redis_populated should return true + let result = is_redis_populated(&server_config, &app_state).await?; + assert!(result, "Expected true when network repository has entries"); + + Ok(()) + } + + #[tokio::test] + async fn test_is_redis_populated_multiple_repositories_have_entries() -> Result<()> { + let app_state = ThinData(create_test_app_state()); + let server_config = create_test_server_config(RepositoryStorageType::InMemory); + + // Add entries to multiple repositories + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let signer = create_mock_signer(); + let notification = create_mock_notification("test-notification".to_string()); + let network = create_mock_network(); + + app_state.relayer_repository.create(relayer).await?; + app_state.signer_repository.create(signer).await?; + app_state + .notification_repository + .create(notification) + .await?; + app_state.network_repository.create(network).await?; + + // Verify multiple repositories have entries + assert!(app_state.relayer_repository.has_entries().await?); + assert!(app_state.signer_repository.has_entries().await?); + assert!(app_state.notification_repository.has_entries().await?); + assert!(app_state.network_repository.has_entries().await?); + + // is_redis_populated should return true + let result = is_redis_populated(&server_config, &app_state).await?; + assert!( + result, + "Expected true when multiple repositories have entries" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_is_redis_populated_comprehensive_scenario() -> Result<()> { + let app_state = ThinData(create_test_app_state()); + let server_config = create_test_server_config(RepositoryStorageType::InMemory); + + // Test 1: Start with all empty repositories + let result = is_redis_populated(&server_config, &app_state).await?; + assert!(!result, "Expected false when all repositories are empty"); + + // Test 2: Add entry to one repository + let relayer = create_mock_relayer("test-relayer".to_string(), false); + app_state.relayer_repository.create(relayer).await?; + let result = is_redis_populated(&server_config, &app_state).await?; + assert!(result, "Expected true after adding one entry"); + + // Test 3: Clear all repositories + app_state.relayer_repository.drop_all_entries().await?; + let result = is_redis_populated(&server_config, &app_state).await?; + assert!(!result, "Expected false after clearing all repositories"); + + // Test 4: Add entries to different repositories and verify each time + let signer = create_mock_signer(); + app_state.signer_repository.create(signer).await?; + let result = is_redis_populated(&server_config, &app_state).await?; + assert!(result, "Expected true after adding signer"); + + let notification = create_mock_notification("test-notification".to_string()); + app_state + .notification_repository + .create(notification) + .await?; + let result = is_redis_populated(&server_config, &app_state).await?; + assert!(result, "Expected true after adding notification"); + + Ok(()) + } + + // Helper function to create test server config with specific settings + fn create_test_server_config_with_settings( + storage_type: RepositoryStorageType, + reset_storage_on_start: bool, + ) -> ServerConfig { + ServerConfig { + repository_storage_type: storage_type.clone(), + reset_storage_on_start, + ..create_test_server_config(storage_type) + } + } + + // Helper function to create minimal test config + fn create_minimal_test_config() -> Config { + Config { + signers: vec![SignerFileConfig { + id: "test-signer-1".to_string(), + config: SignerFileConfigEnum::Test(TestSignerFileConfig {}), + }], + relayers: vec![RelayerFileConfig { + id: "test-relayer-1".to_string(), + network_type: ConfigFileNetworkType::Evm, + signer_id: "test-signer-1".to_string(), + name: "test-relayer-1".to_string(), + network: "test-network".to_string(), + paused: false, + policies: None, + notification_id: None, + custom_rpc_urls: None, + }], + notifications: vec![NotificationFileConfig { + id: "test-notification-1".to_string(), + r#type: crate::config::NotificationFileConfigType::Webhook, + url: "https://hooks.slack.com/test1".to_string(), + signing_key: None, + }], + networks: NetworksFileConfig::new(vec![]).unwrap(), + plugins: None, + } + } + + #[tokio::test] + async fn test_should_process_config_file_inmemory_storage() -> Result<()> { + let config = create_minimal_test_config(); + + // Test 1: InMemory storage with reset_storage_on_start = false + let server_config = Arc::new(create_test_server_config_with_settings( + RepositoryStorageType::InMemory, + false, + )); + + let app_state = ThinData(create_test_app_state()); + process_config_file(config.clone(), server_config.clone(), &app_state).await?; + + let stored_relayers = app_state.relayer_repository.list_all().await?; + assert_eq!(stored_relayers.len(), 1); + assert_eq!(stored_relayers[0].id, "test-relayer-1"); + + // Test 2: InMemory storage with reset_storage_on_start = true + let server_config2 = Arc::new(create_test_server_config_with_settings( + RepositoryStorageType::InMemory, + true, + )); + + let app_state2 = ThinData(create_test_app_state()); + process_config_file(config.clone(), server_config2, &app_state2).await?; + + let stored_relayers = app_state2.relayer_repository.list_all().await?; + assert_eq!(stored_relayers.len(), 1); + assert_eq!(stored_relayers[0].id, "test-relayer-1"); + + Ok(()) + } + + #[tokio::test] + async fn test_should_process_config_file_redis_storage_empty_repositories() -> Result<()> { + let config = create_minimal_test_config(); + let server_config = Arc::new(create_test_server_config_with_settings( + RepositoryStorageType::Redis, + false, + )); + + let app_state = ThinData(create_test_app_state()); + process_config_file(config, server_config, &app_state).await?; + + let stored_relayers = app_state.relayer_repository.list_all().await?; + assert_eq!(stored_relayers.len(), 1); + assert_eq!(stored_relayers[0].id, "test-relayer-1"); + + Ok(()) + } + + #[tokio::test] + async fn test_should_not_process_config_file_redis_storage_populated_repositories() -> Result<()> + { + let config = create_minimal_test_config(); + let server_config = Arc::new(create_test_server_config_with_settings( + RepositoryStorageType::Redis, + false, + )); + + // Create two identical app states to test the decision logic + let app_state1 = ThinData(create_test_app_state()); + let app_state2 = ThinData(create_test_app_state()); + + // Pre-populate repositories to simulate Redis already having data + let existing_relayer1 = create_mock_relayer("existing-relayer".to_string(), false); + let existing_relayer2 = create_mock_relayer("existing-relayer".to_string(), false); + app_state1 + .relayer_repository + .create(existing_relayer1) + .await?; + app_state2 + .relayer_repository + .create(existing_relayer2) + .await?; + + // Check initial state + assert!(app_state1.relayer_repository.has_entries().await?); + assert!(!app_state1.signer_repository.has_entries().await?); + + // Process config file - should NOT process because Redis is populated + process_config_file(config, server_config, &app_state2).await?; + + let relayer_from_config = app_state2 + .relayer_repository + .get_by_id("test-relayer-1".to_string()) + .await; + assert!( + relayer_from_config.is_err(), + "Relayer from config should not be found" + ); + + let existing_relayer = app_state2 + .relayer_repository + .get_by_id("existing-relayer".to_string()) + .await?; + assert_eq!(existing_relayer.id, "existing-relayer"); + + // The test passes if no errors occurred, which means the decision logic worked + Ok(()) + } + + #[tokio::test] + async fn test_should_process_config_file_redis_storage_with_reset_flag() -> Result<()> { + let config = create_minimal_test_config(); + let server_config = Arc::new(create_test_server_config_with_settings( + RepositoryStorageType::Redis, + true, // reset_storage_on_start = true + )); + + let app_state = ThinData(create_test_app_state()); + + // Pre-populate repositories to simulate Redis already having data + let existing_relayer = create_mock_relayer("existing-relayer".to_string(), false); + let existing_signer = create_mock_signer(); + app_state + .relayer_repository + .create(existing_relayer) + .await?; + app_state.signer_repository.create(existing_signer).await?; + + // Should process config file because reset_storage_on_start = true + process_config_file(config, server_config, &app_state).await?; + + let stored_relayer = app_state + .relayer_repository + .get_by_id("existing-relayer".to_string()) + .await; + assert!( + stored_relayer.is_err(), + "Existing relayer should not be found" + ); + + let stored_signer = app_state + .signer_repository + .get_by_id("existing-signer".to_string()) + .await; + assert!( + stored_signer.is_err(), + "Existing signer should not be found" + ); + + Ok(()) + } } diff --git a/src/bootstrap/initialize_app_state.rs b/src/bootstrap/initialize_app_state.rs index 0d9d95d83..2febfafab 100644 --- a/src/bootstrap/initialize_app_state.rs +++ b/src/bootstrap/initialize_app_state.rs @@ -11,11 +11,11 @@ use crate::{ RelayerRepositoryStorage, SignerRepositoryStorage, TransactionCounterRepositoryStorage, TransactionRepositoryStorage, }, + utils::initialize_redis_connection, }; use actix_web::web; use color_eyre::Result; use log::warn; -use redis::aio::ConnectionManager; use std::sync::Arc; pub struct RepositoryCollection { @@ -35,10 +35,7 @@ pub struct RepositoryCollection { /// * `Result` - Initialized repositories /// /// # Errors -pub async fn initialize_repositories( - config: &ServerConfig, - redis_connection_manager: Arc, -) -> eyre::Result { +pub async fn initialize_repositories(config: &ServerConfig) -> eyre::Result { let repositories = match config.repository_storage_type { RepositoryStorageType::InMemory => RepositoryCollection { relayer: Arc::new(RelayerRepositoryStorage::new_in_memory()), @@ -51,6 +48,8 @@ pub async fn initialize_repositories( }, RepositoryStorageType::Redis => { warn!("Redis repository storage support is experimental"); + let redis_connection_manager = initialize_redis_connection(config).await?; + let connection_manager = redis_connection_manager.clone(); RepositoryCollection { @@ -102,10 +101,8 @@ pub async fn initialize_repositories( /// - Configuration loading fails pub async fn initialize_app_state( server_config: Arc, - redis_connection_manager: Arc, ) -> Result> { - let repositories = - initialize_repositories(&server_config, redis_connection_manager.clone()).await?; + let repositories = initialize_repositories(&server_config).await?; let queue = Queue::setup().await?; let job_producer = Arc::new(jobs::JobProducer::new(queue.clone())); @@ -129,99 +126,77 @@ mod tests { use super::*; use crate::{ config::RepositoryStorageType, - models::SecretString, repositories::Repository, - utils::mocks::mockutils::{create_mock_network, create_mock_relayer, create_mock_signer}, + utils::mocks::mockutils::{ + create_mock_network, create_mock_relayer, create_mock_signer, create_test_server_config, + }, }; use std::sync::Arc; - /// Helper function to create a test ServerConfig - fn create_test_server_config(storage_type: RepositoryStorageType) -> ServerConfig { - ServerConfig { - host: "localhost".to_string(), - port: 8080, - redis_url: "redis://localhost:6379".to_string(), - config_file_path: "./config/test.json".to_string(), - api_key: SecretString::new("test_api_key_1234567890_test_key_32"), - rate_limit_requests_per_second: 100, - rate_limit_burst_size: 300, - metrics_port: 8081, - enable_swagger: false, - redis_connection_timeout_ms: 5000, - redis_key_prefix: "test-oz-relayer".to_string(), - rpc_timeout_ms: 10000, - provider_max_retries: 3, - provider_retry_base_delay_ms: 100, - provider_retry_max_delay_ms: 2000, - provider_max_failovers: 3, - repository_storage_type: storage_type, - } + #[tokio::test] + async fn test_initialize_repositories_in_memory() { + let config = create_test_server_config(RepositoryStorageType::InMemory); + let result = initialize_repositories(&config).await; + + assert!(result.is_ok()); + let repositories = result.unwrap(); + + // Verify all repositories are created + assert!(Arc::strong_count(&repositories.relayer) >= 1); + assert!(Arc::strong_count(&repositories.transaction) >= 1); + assert!(Arc::strong_count(&repositories.signer) >= 1); + assert!(Arc::strong_count(&repositories.notification) >= 1); + assert!(Arc::strong_count(&repositories.network) >= 1); + assert!(Arc::strong_count(&repositories.transaction_counter) >= 1); + assert!(Arc::strong_count(&repositories.plugin) >= 1); + } + + #[tokio::test] + async fn test_repository_collection_functionality() { + let config = create_test_server_config(RepositoryStorageType::InMemory); + let repositories = initialize_repositories(&config).await.unwrap(); + + // Test basic repository operations + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let signer = create_mock_signer(); + let network = create_mock_network(); + + // Test creating and retrieving items + repositories.relayer.create(relayer.clone()).await.unwrap(); + repositories.signer.create(signer.clone()).await.unwrap(); + repositories.network.create(network.clone()).await.unwrap(); + + let retrieved_relayer = repositories + .relayer + .get_by_id("test-relayer".to_string()) + .await + .unwrap(); + let retrieved_signer = repositories + .signer + .get_by_id("test".to_string()) + .await + .unwrap(); + let retrieved_network = repositories + .network + .get_by_id("test".to_string()) + .await + .unwrap(); + + assert_eq!(retrieved_relayer.id, "test-relayer"); + assert_eq!(retrieved_signer.id, "test"); + assert_eq!(retrieved_network.id, "test"); } - // #[tokio::test] - // async fn test_initialize_repositories_in_memory() { - // let config = create_test_server_config(RepositoryStorageType::InMemory); - // let result = initialize_repositories(&config).await; - - // assert!(result.is_ok()); - // let repositories = result.unwrap(); - - // // Verify all repositories are created - // assert!(Arc::strong_count(&repositories.relayer) >= 1); - // assert!(Arc::strong_count(&repositories.transaction) >= 1); - // assert!(Arc::strong_count(&repositories.signer) >= 1); - // assert!(Arc::strong_count(&repositories.notification) >= 1); - // assert!(Arc::strong_count(&repositories.network) >= 1); - // assert!(Arc::strong_count(&repositories.transaction_counter) >= 1); - // assert!(Arc::strong_count(&repositories.plugin) >= 1); - // } - - // #[tokio::test] - // async fn test_repository_collection_functionality() { - // let config = create_test_server_config(RepositoryStorageType::InMemory); - // let repositories = initialize_repositories(&config).await.unwrap(); - - // // Test basic repository operations - // let relayer = create_mock_relayer("test-relayer".to_string(), false); - // let signer = create_mock_signer(); - // let network = create_mock_network(); - - // // Test creating and retrieving items - // repositories.relayer.create(relayer.clone()).await.unwrap(); - // repositories.signer.create(signer.clone()).await.unwrap(); - // repositories.network.create(network.clone()).await.unwrap(); - - // let retrieved_relayer = repositories - // .relayer - // .get_by_id("test-relayer".to_string()) - // .await - // .unwrap(); - // let retrieved_signer = repositories - // .signer - // .get_by_id("test".to_string()) - // .await - // .unwrap(); - // let retrieved_network = repositories - // .network - // .get_by_id("test".to_string()) - // .await - // .unwrap(); - - // assert_eq!(retrieved_relayer.id, "test-relayer"); - // assert_eq!(retrieved_signer.id, "test"); - // assert_eq!(retrieved_network.id, "test"); - // } - - // #[tokio::test] - // async fn test_initialize_app_state_repository_error() { - // let mut config = create_test_server_config(RepositoryStorageType::Redis); - // config.redis_url = "redis://invalid_url".to_string(); - - // let result = initialize_app_state(Arc::new(config)).await; - - // // Should fail during repository initialization - // assert!(result.is_err()); - // let error = result.unwrap_err(); - // assert!(error.to_string().contains("Redis") || error.to_string().contains("connection")); - // } + #[tokio::test] + async fn test_initialize_app_state_repository_error() { + let mut config = create_test_server_config(RepositoryStorageType::Redis); + config.redis_url = "redis://invalid_url".to_string(); + + let result = initialize_app_state(Arc::new(config)).await; + + // Should fail during repository initialization + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.to_string().contains("Redis") || error.to_string().contains("connection")); + } } diff --git a/src/config/server_config.rs b/src/config/server_config.rs index a438952e7..5c8d1faf5 100644 --- a/src/config/server_config.rs +++ b/src/config/server_config.rs @@ -58,6 +58,8 @@ pub struct ServerConfig { pub provider_max_failovers: u8, /// The type of repository storage to use. pub repository_storage_type: RepositoryStorageType, + /// Flag to force config file processing. + pub reset_storage_on_start: bool, } impl ServerConfig { @@ -163,6 +165,9 @@ impl ServerConfig { .unwrap_or_else(|_| "in_memory".to_string()) .parse() .unwrap_or(RepositoryStorageType::InMemory), + reset_storage_on_start: env::var("RESET_STORAGE_ON_START") + .map(|v| v.to_lowercase() == "true") + .unwrap_or(false), } } } @@ -197,6 +202,8 @@ mod tests { env::remove_var("PROVIDER_RETRY_BASE_DELAY_MS"); env::remove_var("PROVIDER_RETRY_MAX_DELAY_MS"); env::remove_var("PROVIDER_MAX_FAILOVERS"); + env::remove_var("REPOSITORY_STORAGE_TYPE"); + env::remove_var("RESET_STORAGE_ON_START"); // Set required variables for most tests env::set_var("REDIS_URL", "redis://localhost:6379"); env::set_var("API_KEY", "7EF1CB7C-5003-4696-B384-C72AF8C3E15D"); @@ -230,6 +237,11 @@ mod tests { assert_eq!(config.provider_retry_base_delay_ms, 100); assert_eq!(config.provider_retry_max_delay_ms, 2000); assert_eq!(config.provider_max_failovers, 3); + assert_eq!( + config.repository_storage_type, + RepositoryStorageType::InMemory + ); + assert!(!config.reset_storage_on_start); } #[test] @@ -251,6 +263,8 @@ mod tests { env::set_var("PROVIDER_RETRY_BASE_DELAY_MS", "invalid"); env::set_var("PROVIDER_RETRY_MAX_DELAY_MS", "invalid"); env::set_var("PROVIDER_MAX_FAILOVERS", "invalid"); + env::set_var("REPOSITORY_STORAGE_TYPE", "invalid"); + env::set_var("RESET_STORAGE_ON_START", "invalid"); let config = ServerConfig::from_env(); // Should fall back to defaults when parsing fails @@ -264,6 +278,11 @@ mod tests { assert_eq!(config.provider_retry_base_delay_ms, 100); assert_eq!(config.provider_retry_max_delay_ms, 2000); assert_eq!(config.provider_max_failovers, 3); + assert_eq!( + config.repository_storage_type, + RepositoryStorageType::InMemory + ); + assert!(!config.reset_storage_on_start); } #[test] @@ -289,6 +308,8 @@ mod tests { env::set_var("PROVIDER_RETRY_BASE_DELAY_MS", "200"); env::set_var("PROVIDER_RETRY_MAX_DELAY_MS", "3000"); env::set_var("PROVIDER_MAX_FAILOVERS", "4"); + env::set_var("REPOSITORY_STORAGE_TYPE", "in_memory"); + env::set_var("RESET_STORAGE_ON_START", "true"); let config = ServerConfig::from_env(); assert_eq!(config.host, "127.0.0.1"); @@ -308,6 +329,11 @@ mod tests { assert_eq!(config.provider_retry_base_delay_ms, 200); assert_eq!(config.provider_retry_max_delay_ms, 3000); assert_eq!(config.provider_max_failovers, 4); + assert_eq!( + config.repository_storage_type, + RepositoryStorageType::InMemory + ); + assert!(config.reset_storage_on_start); } #[test] diff --git a/src/main.rs b/src/main.rs index 786258bf9..1b54b8c46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,7 +54,7 @@ use openzeppelin_relayer::{ constants::PUBLIC_ENDPOINTS, logging::setup_logging, metrics, - utils::{check_authorization_header, initialize_redis_connection}, + utils::check_authorization_header, }; fn load_config_file(config_file_path: &str) -> Result { @@ -78,21 +78,12 @@ async fn main() -> Result<()> { let server_config = Arc::clone(&config); // clone for use in binding below let config_file = load_config_file(&config.config_file_path)?; - let redis_connection_manager = initialize_redis_connection(&config).await?; - - let app_state = - initialize_app_state(server_config.clone(), redis_connection_manager.clone()).await?; + let app_state = initialize_app_state(server_config.clone()).await?; // Setup workers for processing jobs initialize_workers(app_state.clone()).await?; - process_config_file( - config_file, - server_config.clone(), - redis_connection_manager.clone(), - app_state.clone(), - ) - .await?; + process_config_file(config_file, server_config.clone(), &app_state).await?; info!("Initializing relayers"); // Initialize relayers: sync and validate relayers diff --git a/src/models/relayer/repository.rs b/src/models/relayer/repository.rs index c64545e58..67acd0ea8 100644 --- a/src/models/relayer/repository.rs +++ b/src/models/relayer/repository.rs @@ -157,9 +157,13 @@ pub struct JupiterSwapOptions { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] #[serde(deny_unknown_fields)] pub struct RelayerSolanaSwapConfig { + #[schema(nullable = false)] pub strategy: Option, + #[schema(nullable = false)] pub cron_schedule: Option, + #[schema(nullable = false)] pub min_balance_threshold: Option, + #[schema(nullable = false)] pub jupiter_swap_options: Option, } diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs index 24c5e5101..eebb04dc9 100644 --- a/src/repositories/mod.rs +++ b/src/repositories/mod.rs @@ -61,6 +61,14 @@ pub trait Repository { async fn update(&self, id: ID, entity: T) -> Result; async fn delete_by_id(&self, id: ID) -> Result<(), RepositoryError>; async fn count(&self) -> Result; + + /// Check if the repository contains any entries. + async fn has_entries(&self) -> Result; + + /// Drop all entries from storage. + /// This completely clears all data, indexes, and metadata. + /// Use with caution as this permanently deletes all data. + async fn drop_all_entries(&self) -> Result<(), RepositoryError>; } #[derive(Error, Debug)] diff --git a/src/repositories/network/mod.rs b/src/repositories/network/mod.rs index 8f62238a4..6cc7dfe55 100644 --- a/src/repositories/network/mod.rs +++ b/src/repositories/network/mod.rs @@ -125,6 +125,20 @@ impl Repository for NetworkRepositoryStorage { NetworkRepositoryStorage::Redis(repo) => repo.count().await, } } + + async fn has_entries(&self) -> Result { + match self { + NetworkRepositoryStorage::InMemory(repo) => repo.has_entries().await, + NetworkRepositoryStorage::Redis(repo) => repo.has_entries().await, + } + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + match self { + NetworkRepositoryStorage::InMemory(repo) => repo.drop_all_entries().await, + NetworkRepositoryStorage::Redis(repo) => repo.drop_all_entries().await, + } + } } #[async_trait] @@ -169,6 +183,8 @@ mockall::mock! { async fn update(&self, id: String, entity: NetworkRepoModel) -> Result; async fn delete_by_id(&self, id: String) -> Result<(), RepositoryError>; async fn count(&self) -> Result; + async fn has_entries(&self) -> Result; + async fn drop_all_entries(&self) -> Result<(), RepositoryError>; } #[async_trait] @@ -177,3 +193,31 @@ mockall::mock! { async fn get_by_chain_id(&self, network_type: NetworkType, chain_id: u64) -> Result, RepositoryError>; } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::mocks::mockutils::create_mock_network; + #[tokio::test] + async fn test_trait_methods_accessibility() { + // Create in-memory repository through the storage enum + let repo: NetworkRepositoryStorage = NetworkRepositoryStorage::new_in_memory(); + + // These methods are now accessible through the trait! + assert!(!repo.has_entries().await.unwrap()); + + // Add a network + let network = create_mock_network(); + repo.create(network).await.unwrap(); + + // Check entries exist + assert!(repo.has_entries().await.unwrap()); + + // Drop all entries + repo.drop_all_entries().await.unwrap(); + + // Verify everything is cleaned up + assert!(!repo.has_entries().await.unwrap()); + assert_eq!(repo.count().await.unwrap(), 0); + } +} diff --git a/src/repositories/network/network_in_memory.rs b/src/repositories/network/network_in_memory.rs index 8c9c5fb28..2b8205e5a 100644 --- a/src/repositories/network/network_in_memory.rs +++ b/src/repositories/network/network_in_memory.rs @@ -120,6 +120,17 @@ impl Repository for InMemoryNetworkRepository { let store = Self::acquire_lock(&self.store).await?; Ok(store.len()) } + + async fn has_entries(&self) -> Result { + let store = Self::acquire_lock(&self.store).await?; + Ok(!store.is_empty()) + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut store = Self::acquire_lock(&self.store).await?; + store.clear(); + Ok(()) + } } #[async_trait] @@ -129,13 +140,7 @@ impl NetworkRepository for InMemoryNetworkRepository { network_type: NetworkType, name: &str, ) -> Result, RepositoryError> { - let store = Self::acquire_lock(&self.store).await?; - for (_, network) in store.iter() { - if network.network_type == network_type && network.name == name { - return Ok(Some(network.clone())); - } - } - Ok(None) + self.get(network_type, name).await } async fn get_by_chain_id( @@ -143,27 +148,21 @@ impl NetworkRepository for InMemoryNetworkRepository { network_type: NetworkType, chain_id: u64, ) -> Result, RepositoryError> { - let store = Self::acquire_lock(&self.store).await?; - - // Only EVM networks have chain_id, so we filter by network type first + // Only EVM networks have chain_id if network_type != NetworkType::Evm { return Ok(None); } - // Search through all networks to find one with matching chain_id and network_type - for network in store.values() { + let store = Self::acquire_lock(&self.store).await?; + for (_, network) in store.iter() { if network.network_type == network_type { - // For EVM networks, check if the chain_id matches if let crate::models::NetworkConfigData::Evm(evm_config) = &network.config { - if let Some(network_chain_id) = evm_config.chain_id { - if network_chain_id == chain_id { - return Ok(Some(network.clone())); - } + if evm_config.chain_id == Some(chain_id) { + return Ok(Some(network.clone())); } } } } - Ok(None) } } @@ -313,4 +312,27 @@ mod tests { Err(RepositoryError::NotSupported(_)) )); } + + #[tokio::test] + async fn test_has_entries() { + let repo = InMemoryNetworkRepository::new(); + assert!(!repo.has_entries().await.unwrap()); + + let network = create_test_network("test".to_string(), NetworkType::Evm); + + repo.create(network.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + } + + #[tokio::test] + async fn test_drop_all_entries() { + let repo = InMemoryNetworkRepository::new(); + let network = create_test_network("test".to_string(), NetworkType::Evm); + + repo.create(network.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } } diff --git a/src/repositories/network/network_redis.rs b/src/repositories/network/network_redis.rs index 42cb8fade..951a72ce7 100644 --- a/src/repositories/network/network_redis.rs +++ b/src/repositories/network/network_redis.rs @@ -509,6 +509,85 @@ impl Repository for RedisNetworkRepository { debug!("Total networks count: {}", count); Ok(count) } + + /// Check if Redis storage contains any network entries. + /// This is used to determine if Redis storage is being used for networks. + async fn has_entries(&self) -> Result { + let network_list_key = self.network_list_key(); + let mut conn = self.client.as_ref().clone(); + + debug!("Checking if network storage has entries"); + + let exists: bool = conn + .exists(&network_list_key) + .await + .map_err(|e| self.map_redis_error(e, "check_network_entries_exist"))?; + + debug!("Network storage has entries: {}", exists); + Ok(exists) + } + + /// Drop all network-related entries from Redis storage. + /// This includes all network data, indexes, and the network list. + /// Use with caution as this will permanently delete all network data. + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut conn = self.client.as_ref().clone(); + + debug!("Starting to drop all network entries from Redis storage"); + + // First, get all network IDs to clean up their data and indexes + let network_list_key = self.network_list_key(); + let network_ids: Vec = conn + .smembers(&network_list_key) + .await + .map_err(|e| self.map_redis_error(e, "get_network_ids_for_cleanup"))?; + + if network_ids.is_empty() { + debug!("No network entries found to clean up"); + return Ok(()); + } + + debug!("Found {} networks to clean up", network_ids.len()); + + // Get all networks to clean up their indexes properly + let networks_result = self.get_networks_by_ids(&network_ids).await?; + let networks = networks_result.results; + + // Use a pipeline for efficient batch operations + let mut pipe = redis::pipe(); + pipe.atomic(); + + // Delete all network data entries + for network_id in &network_ids { + let network_key = self.network_key(network_id); + pipe.del(&network_key); + } + + // Delete all index entries + for network in &networks { + // Delete name index + let name_key = self.network_name_index_key(&network.network_type, &network.name); + pipe.del(&name_key); + + // Delete chain ID index if applicable + if let Some(chain_id) = self.extract_chain_id(network) { + let chain_id_key = self.network_chain_id_index_key(&network.network_type, chain_id); + pipe.del(&chain_id_key); + } + } + + // Delete the network list + pipe.del(&network_list_key); + + // Execute all deletions + pipe.exec_async(&mut conn).await.map_err(|e| { + error!("Failed to execute cleanup pipeline: {}", e); + self.map_redis_error(e, "drop_all_network_entries_pipeline") + })?; + + debug!("Successfully dropped all network entries from Redis storage"); + Ok(()) + } } #[async_trait] @@ -988,4 +1067,132 @@ mod tests { let result = repo.get_by_name(NetworkType::Evm, "").await; assert!(matches!(result, Err(RepositoryError::InvalidData(_)))); } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_has_entries_empty_storage() { + let repo = setup_test_repo().await; + + let result = repo.has_entries().await.unwrap(); + assert!(!result, "Empty storage should return false"); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_has_entries_with_data() { + let repo = setup_test_repo().await; + let test_network_random = Uuid::new_v4().to_string(); + let network = create_test_network(&test_network_random, NetworkType::Evm); + + assert!(!repo.has_entries().await.unwrap()); + + repo.create(network).await.unwrap(); + + assert!(repo.has_entries().await.unwrap()); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_drop_all_entries_empty_storage() { + let repo = setup_test_repo().await; + + let result = repo.drop_all_entries().await; + assert!(result.is_ok()); + + assert!(!repo.has_entries().await.unwrap()); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_drop_all_entries_with_data() { + let repo = setup_test_repo().await; + let test_network_random1 = Uuid::new_v4().to_string(); + let test_network_random2 = Uuid::new_v4().to_string(); + let network1 = create_test_network(&test_network_random1, NetworkType::Evm); + let network2 = create_test_network(&test_network_random2, NetworkType::Solana); + + // Add networks + repo.create(network1.clone()).await.unwrap(); + repo.create(network2.clone()).await.unwrap(); + + // Verify they exist + assert!(repo.has_entries().await.unwrap()); + assert_eq!(repo.count().await.unwrap(), 2); + assert!(repo + .get_by_name(NetworkType::Evm, &test_network_random1) + .await + .unwrap() + .is_some()); + + // Drop all entries + let result = repo.drop_all_entries().await; + assert!(result.is_ok()); + + // Verify everything is cleaned up + assert!(!repo.has_entries().await.unwrap()); + assert_eq!(repo.count().await.unwrap(), 0); + assert!(repo + .get_by_name(NetworkType::Evm, &test_network_random1) + .await + .unwrap() + .is_none()); + assert!(repo + .get_by_name(NetworkType::Solana, &test_network_random2) + .await + .unwrap() + .is_none()); + + // Verify individual networks are gone + assert!(matches!( + repo.get_by_id(network1.id).await, + Err(RepositoryError::NotFound(_)) + )); + assert!(matches!( + repo.get_by_id(network2.id).await, + Err(RepositoryError::NotFound(_)) + )); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_drop_all_entries_cleans_indexes() { + let repo = setup_test_repo().await; + let test_network_random = Uuid::new_v4().to_string(); + let mut network = create_test_network(&test_network_random, NetworkType::Evm); + + // Ensure we have a specific chain ID for testing + if let crate::models::NetworkConfigData::Evm(ref mut evm_config) = network.config { + evm_config.chain_id = Some(12345); + } + + // Add network + repo.create(network.clone()).await.unwrap(); + + // Verify indexes work + assert!(repo + .get_by_name(NetworkType::Evm, &test_network_random) + .await + .unwrap() + .is_some()); + assert!(repo + .get_by_chain_id(NetworkType::Evm, 12345) + .await + .unwrap() + .is_some()); + + // Drop all entries + repo.drop_all_entries().await.unwrap(); + + // Verify indexes are cleaned up + assert!(repo + .get_by_name(NetworkType::Evm, &test_network_random) + .await + .unwrap() + .is_none()); + assert!(repo + .get_by_chain_id(NetworkType::Evm, 12345) + .await + .unwrap() + .is_none()); + } } diff --git a/src/repositories/notification/mod.rs b/src/repositories/notification/mod.rs index 0beee8380..2ac538d96 100644 --- a/src/repositories/notification/mod.rs +++ b/src/repositories/notification/mod.rs @@ -112,4 +112,448 @@ impl Repository for NotificationRepositoryStorage NotificationRepositoryStorage::Redis(repo) => repo.count().await, } } + + async fn has_entries(&self) -> Result { + match self { + NotificationRepositoryStorage::InMemory(repo) => repo.has_entries().await, + NotificationRepositoryStorage::Redis(repo) => repo.has_entries().await, + } + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + match self { + NotificationRepositoryStorage::InMemory(repo) => repo.drop_all_entries().await, + NotificationRepositoryStorage::Redis(repo) => repo.drop_all_entries().await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::RepositoryError; + use crate::repositories::PaginationQuery; + use crate::utils::mocks::mockutils::create_mock_notification; + use color_eyre::Result; + + fn create_test_notification(id: &str) -> NotificationRepoModel { + create_mock_notification(id.to_string()) + } + + #[tokio::test] + async fn test_new_in_memory() { + let storage = NotificationRepositoryStorage::new_in_memory(); + + match storage { + NotificationRepositoryStorage::InMemory(_) => { + // Success - verify it's the InMemory variant + } + NotificationRepositoryStorage::Redis(_) => { + panic!("Expected InMemory variant, got Redis"); + } + } + } + + #[tokio::test] + async fn test_create_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + let notification = create_test_notification("test-notification"); + + let created = storage.create(notification.clone()).await?; + assert_eq!(created.id, notification.id); + assert_eq!(created.url, notification.url); + + Ok(()) + } + + #[tokio::test] + async fn test_get_by_id_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + let notification = create_test_notification("test-notification"); + + // Create notification first + storage.create(notification.clone()).await?; + + // Get by ID + let retrieved = storage.get_by_id("test-notification".to_string()).await?; + assert_eq!(retrieved.id, notification.id); + assert_eq!(retrieved.url, notification.url); + + Ok(()) + } + + #[tokio::test] + async fn test_get_by_id_not_found_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + + let result = storage.get_by_id("non-existent".to_string()).await; + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_list_all_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + + // Initially empty + let notifications = storage.list_all().await?; + assert!(notifications.is_empty()); + + // Add notifications + let notification1 = create_test_notification("notification-1"); + let notification2 = create_test_notification("notification-2"); + + storage.create(notification1.clone()).await?; + storage.create(notification2.clone()).await?; + + let all_notifications = storage.list_all().await?; + assert_eq!(all_notifications.len(), 2); + + let ids: Vec<&str> = all_notifications.iter().map(|n| n.id.as_str()).collect(); + assert!(ids.contains(&"notification-1")); + assert!(ids.contains(&"notification-2")); + + Ok(()) + } + + #[tokio::test] + async fn test_list_paginated_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + + // Add test notifications + for i in 1..=5 { + let notification = create_test_notification(&format!("notification-{}", i)); + storage.create(notification).await?; + } + + // Test pagination + let query = PaginationQuery { + page: 1, + per_page: 2, + }; + let page = storage.list_paginated(query).await?; + + assert_eq!(page.items.len(), 2); + assert_eq!(page.total, 5); + assert_eq!(page.page, 1); + assert_eq!(page.per_page, 2); + + // Test second page + let query2 = PaginationQuery { + page: 2, + per_page: 2, + }; + let page2 = storage.list_paginated(query2).await?; + + assert_eq!(page2.items.len(), 2); + assert_eq!(page2.total, 5); + assert_eq!(page2.page, 2); + assert_eq!(page2.per_page, 2); + + // Test final page + let query3 = PaginationQuery { + page: 3, + per_page: 2, + }; + let page3 = storage.list_paginated(query3).await?; + + assert_eq!(page3.items.len(), 1); + assert_eq!(page3.total, 5); + assert_eq!(page3.page, 3); + assert_eq!(page3.per_page, 2); + + Ok(()) + } + + #[tokio::test] + async fn test_update_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + let notification = create_test_notification("test-notification"); + + // Create notification first + storage.create(notification.clone()).await?; + + // Update it - should return NotSupported error + let mut updated_notification = notification.clone(); + updated_notification.url = "https://updated.webhook.com".to_string(); + + let result = storage + .update( + "test-notification".to_string(), + updated_notification.clone(), + ) + .await; + assert!(result.is_err()); + match result.unwrap_err() { + RepositoryError::NotSupported(_) => { + // Expected error + } + _ => panic!("Expected NotSupported error"), + } + + Ok(()) + } + + #[tokio::test] + async fn test_update_not_found_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + let notification = create_test_notification("non-existent"); + + let result = storage + .update("non-existent".to_string(), notification) + .await; + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_delete_by_id_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + let notification = create_test_notification("test-notification"); + + // Create notification first + storage.create(notification.clone()).await?; + + // Verify it exists + let retrieved = storage.get_by_id("test-notification".to_string()).await?; + assert_eq!(retrieved.id, "test-notification"); + + // Delete it - should return NotSupported error + let result = storage.delete_by_id("test-notification".to_string()).await; + assert!(result.is_err()); + match result.unwrap_err() { + RepositoryError::NotSupported(_) => { + // Expected error + } + _ => panic!("Expected NotSupported error"), + } + + Ok(()) + } + + #[tokio::test] + async fn test_delete_by_id_not_found_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + + let result = storage.delete_by_id("non-existent".to_string()).await; + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_count_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + + // Initially empty + let count = storage.count().await?; + assert_eq!(count, 0); + + // Add notifications + let notification1 = create_test_notification("notification-1"); + let notification2 = create_test_notification("notification-2"); + + storage.create(notification1).await?; + let count_after_one = storage.count().await?; + assert_eq!(count_after_one, 1); + + storage.create(notification2).await?; + let count_after_two = storage.count().await?; + assert_eq!(count_after_two, 2); + + // Try to delete one - should return NotSupported error + let delete_result = storage.delete_by_id("notification-1".to_string()).await; + assert!(delete_result.is_err()); + match delete_result.unwrap_err() { + RepositoryError::NotSupported(_) => { + // Expected error + } + _ => panic!("Expected NotSupported error"), + } + + // Count should remain the same since delete is not supported + let count_after_delete_attempt = storage.count().await?; + assert_eq!(count_after_delete_attempt, 2); + + Ok(()) + } + + #[tokio::test] + async fn test_has_entries_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + + // Initially empty + let has_entries = storage.has_entries().await?; + assert!(!has_entries); + + // Add notification + let notification = create_test_notification("test-notification"); + storage.create(notification).await?; + + let has_entries_after_create = storage.has_entries().await?; + assert!(has_entries_after_create); + + // Try to delete notification - should return NotSupported error + let delete_result = storage.delete_by_id("test-notification".to_string()).await; + assert!(delete_result.is_err()); + match delete_result.unwrap_err() { + RepositoryError::NotSupported(_) => { + // Expected error + } + _ => panic!("Expected NotSupported error"), + } + + // Should still have entries since delete is not supported + let has_entries_after_delete_attempt = storage.has_entries().await?; + assert!(has_entries_after_delete_attempt); + + Ok(()) + } + + #[tokio::test] + async fn test_drop_all_entries_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + + // Add multiple notifications + for i in 1..=5 { + let notification = create_test_notification(&format!("notification-{}", i)); + storage.create(notification).await?; + } + + // Verify they exist + let count_before = storage.count().await?; + assert_eq!(count_before, 5); + + let has_entries_before = storage.has_entries().await?; + assert!(has_entries_before); + + // Drop all entries + storage.drop_all_entries().await?; + + // Verify they're gone + let count_after = storage.count().await?; + assert_eq!(count_after, 0); + + let has_entries_after = storage.has_entries().await?; + assert!(!has_entries_after); + + let all_notifications = storage.list_all().await?; + assert!(all_notifications.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_create_duplicate_id_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + let notification = create_test_notification("duplicate-id"); + + // Create first notification + storage.create(notification.clone()).await?; + + // Try to create another with same ID - should fail + let result = storage.create(notification.clone()).await; + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_workflow_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + + // 1. Start with empty storage + assert!(!storage.has_entries().await?); + assert_eq!(storage.count().await?, 0); + + // 2. Create notification + let notification = create_test_notification("workflow-test"); + let created = storage.create(notification.clone()).await?; + assert_eq!(created.id, "workflow-test"); + + // 3. Verify it exists + assert!(storage.has_entries().await?); + assert_eq!(storage.count().await?, 1); + + // 4. Retrieve it + let retrieved = storage.get_by_id("workflow-test".to_string()).await?; + assert_eq!(retrieved.id, "workflow-test"); + + // 5. Try to update it - should return NotSupported error + let mut updated = retrieved.clone(); + updated.url = "https://updated.example.com".to_string(); + let update_result = storage.update("workflow-test".to_string(), updated).await; + assert!(update_result.is_err()); + match update_result.unwrap_err() { + RepositoryError::NotSupported(_) => { + // Expected error + } + _ => panic!("Expected NotSupported error"), + } + + // 6. Try to delete it - should return NotSupported error + let delete_result = storage.delete_by_id("workflow-test".to_string()).await; + assert!(delete_result.is_err()); + match delete_result.unwrap_err() { + RepositoryError::NotSupported(_) => { + // Expected error + } + _ => panic!("Expected NotSupported error"), + } + + // 7. Verify it still exists since delete is not supported + assert!(storage.has_entries().await?); + assert_eq!(storage.count().await?, 1); + + let result = storage.get_by_id("workflow-test".to_string()).await; + assert!(result.is_ok()); + + Ok(()) + } + + #[tokio::test] + async fn test_pagination_edge_cases_in_memory() -> Result<()> { + let storage = NotificationRepositoryStorage::new_in_memory(); + + // Test pagination with empty storage + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + let page = storage.list_paginated(query).await?; + assert_eq!(page.items.len(), 0); + assert_eq!(page.total, 0); + assert_eq!(page.page, 1); + assert_eq!(page.per_page, 10); + + // Add one notification + let notification = create_test_notification("single-item"); + storage.create(notification).await?; + + // Test pagination with single item + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + let page = storage.list_paginated(query).await?; + assert_eq!(page.items.len(), 1); + assert_eq!(page.total, 1); + assert_eq!(page.page, 1); + assert_eq!(page.per_page, 10); + + // Test pagination with page beyond total + let query = PaginationQuery { + page: 3, + per_page: 10, + }; + let page = storage.list_paginated(query).await?; + assert_eq!(page.items.len(), 0); + assert_eq!(page.total, 1); + assert_eq!(page.page, 3); + assert_eq!(page.per_page, 10); + + Ok(()) + } } diff --git a/src/repositories/notification/notification_in_memory.rs b/src/repositories/notification/notification_in_memory.rs index ad2fa64d1..6a69e9d7b 100644 --- a/src/repositories/notification/notification_in_memory.rs +++ b/src/repositories/notification/notification_in_memory.rs @@ -129,6 +129,17 @@ impl Repository for InMemoryNotificationRepositor let length = store.len(); Ok(length) } + + async fn has_entries(&self) -> Result { + let store = Self::acquire_lock(&self.store).await?; + Ok(!store.is_empty()) + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut store = Self::acquire_lock(&self.store).await?; + store.clear(); + Ok(()) + } } impl TryFrom for NotificationRepoModel { @@ -223,4 +234,29 @@ mod tests { let result = repo.get_by_id("test".to_string()).await; assert!(matches!(result, Err(RepositoryError::NotFound(_)))); } + + // test has_entries + #[actix_web::test] + async fn test_has_entries() { + let repo = InMemoryNotificationRepository::new(); + assert!(!repo.has_entries().await.unwrap()); + + let notification = create_test_notification("test".to_string()); + + repo.create(notification.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + } + + // test drop_all_entries + #[actix_web::test] + async fn test_drop_all_entries() { + let repo = InMemoryNotificationRepository::new(); + let notification = create_test_notification("test".to_string()); + + repo.create(notification.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } } diff --git a/src/repositories/notification/notification_redis.rs b/src/repositories/notification/notification_redis.rs index 5ef3f3a56..b98fe1c31 100644 --- a/src/repositories/notification/notification_redis.rs +++ b/src/repositories/notification/notification_redis.rs @@ -389,6 +389,59 @@ impl Repository for RedisNotificationRepository { debug!("Notification count: {}", count); Ok(count as usize) } + + async fn has_entries(&self) -> Result { + let mut conn = self.client.as_ref().clone(); + let notification_list_key = self.notification_list_key(); + + debug!("Checking if notification entries exist"); + + let exists: bool = conn + .exists(¬ification_list_key) + .await + .map_err(|e| self.map_redis_error(e, "has_entries_check"))?; + + debug!("Notification entries exist: {}", exists); + Ok(exists) + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut conn = self.client.as_ref().clone(); + let notification_list_key = self.notification_list_key(); + + debug!("Dropping all notification entries"); + + // Get all notification IDs first + let notification_ids: Vec = conn + .smembers(¬ification_list_key) + .await + .map_err(|e| self.map_redis_error(e, "drop_all_entries_get_ids"))?; + + if notification_ids.is_empty() { + debug!("No notification entries to drop"); + return Ok(()); + } + + // Use pipeline for atomic operations + let mut pipe = redis::pipe(); + pipe.atomic(); + + // Delete all individual notification entries + for notification_id in ¬ification_ids { + let notification_key = self.notification_key(notification_id); + pipe.del(¬ification_key); + } + + // Delete the notification list key + pipe.del(¬ification_list_key); + + pipe.exec_async(&mut conn) + .await + .map_err(|e| self.map_redis_error(e, "drop_all_entries_pipeline"))?; + + debug!("Dropped {} notification entries", notification_ids.len()); + Ok(()) + } } #[cfg(test)] @@ -755,4 +808,30 @@ mod tests { let all_notifications = repo.list_all().await.unwrap(); assert!(!all_notifications.iter().any(|n| n.id == random_id)); } + + // test has_entries + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_has_entries() { + let repo = setup_test_repo().await; + assert!(!repo.has_entries().await.unwrap()); + + let notification = create_test_notification("test"); + repo.create(notification.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + } + + // test drop_all_entries + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_drop_all_entries() { + let repo = setup_test_repo().await; + let notification = create_test_notification("test"); + + repo.create(notification.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } } diff --git a/src/repositories/relayer/mod.rs b/src/repositories/relayer/mod.rs index 15954d231..6d8bf09a8 100644 --- a/src/repositories/relayer/mod.rs +++ b/src/repositories/relayer/mod.rs @@ -141,6 +141,20 @@ impl Repository for RelayerRepositoryStorage { RelayerRepositoryStorage::Redis(repo) => repo.count().await, } } + + async fn has_entries(&self) -> Result { + match self { + RelayerRepositoryStorage::InMemory(repo) => repo.has_entries().await, + RelayerRepositoryStorage::Redis(repo) => repo.has_entries().await, + } + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + match self { + RelayerRepositoryStorage::InMemory(repo) => repo.drop_all_entries().await, + RelayerRepositoryStorage::Redis(repo) => repo.drop_all_entries().await, + } + } } #[async_trait] @@ -362,6 +376,32 @@ mod tests { let delete_result = impl_repo.delete_by_id("nonexistent".to_string()).await; assert!(delete_result.is_err()); } + + #[actix_web::test] + async fn test_has_entries() { + let repo = InMemoryRelayerRepository::new(); + assert!(!repo.has_entries().await.unwrap()); + + let relayer = create_test_relayer("test".to_string()); + + repo.create(relayer.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + + repo.delete_by_id(relayer.id.clone()).await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } + + #[actix_web::test] + async fn test_drop_all_entries() { + let repo = InMemoryRelayerRepository::new(); + let relayer = create_test_relayer("test".to_string()); + + repo.create(relayer.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } } #[cfg(test)] @@ -377,6 +417,8 @@ mockall::mock! { async fn update(&self, id: String, entity: RelayerRepoModel) -> Result; async fn delete_by_id(&self, id: String) -> Result<(), RepositoryError>; async fn count(&self) -> Result; + async fn has_entries(&self) -> Result; + async fn drop_all_entries(&self) -> Result<(), RepositoryError>; } #[async_trait] diff --git a/src/repositories/relayer/relayer_in_memory.rs b/src/repositories/relayer/relayer_in_memory.rs index acad3d0c0..9f283759b 100644 --- a/src/repositories/relayer/relayer_in_memory.rs +++ b/src/repositories/relayer/relayer_in_memory.rs @@ -221,6 +221,17 @@ impl Repository for InMemoryRelayerRepository { async fn count(&self) -> Result { Ok(self.store.lock().await.len()) } + + async fn has_entries(&self) -> Result { + let store = Self::acquire_lock(&self.store).await?; + Ok(!store.is_empty()) + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut store = Self::acquire_lock(&self.store).await?; + store.clear(); + Ok(()) + } } #[cfg(test)] @@ -424,4 +435,30 @@ mod tests { _ => panic!("Unexpected policy type"), } } + + // test has_entries + #[actix_web::test] + async fn test_has_entries() { + let repo = InMemoryRelayerRepository::new(); + assert!(!repo.has_entries().await.unwrap()); + + let relayer = create_test_relayer("test".to_string()); + + repo.create(relayer.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + } + + // test drop_all_entries + #[actix_web::test] + async fn test_drop_all_entries() { + let repo = InMemoryRelayerRepository::new(); + let relayer = create_test_relayer("test".to_string()); + + repo.create(relayer.clone()).await.unwrap(); + + assert!(repo.has_entries().await.unwrap()); + + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } } diff --git a/src/repositories/relayer/relayer_redis.rs b/src/repositories/relayer/relayer_redis.rs index adb5afdd4..205899b12 100644 --- a/src/repositories/relayer/relayer_redis.rs +++ b/src/repositories/relayer/relayer_redis.rs @@ -370,6 +370,59 @@ impl Repository for RedisRelayerRepository { Ok(count as usize) } + + async fn has_entries(&self) -> Result { + let mut conn = self.client.as_ref().clone(); + let relayer_list_key = self.relayer_list_key(); + + debug!("Checking if relayer entries exist"); + + let exists: bool = conn + .exists(&relayer_list_key) + .await + .map_err(|e| self.map_redis_error(e, "has_entries_check"))?; + + debug!("Relayer entries exist: {}", exists); + Ok(exists) + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut conn = self.client.as_ref().clone(); + let relayer_list_key = self.relayer_list_key(); + + debug!("Dropping all relayer entries"); + + // Get all relayer IDs first + let relayer_ids: Vec = conn + .smembers(&relayer_list_key) + .await + .map_err(|e| self.map_redis_error(e, "drop_all_entries_get_ids"))?; + + if relayer_ids.is_empty() { + debug!("No relayer entries to drop"); + return Ok(()); + } + + // Use pipeline for atomic operations + let mut pipe = redis::pipe(); + pipe.atomic(); + + // Delete all individual relayer entries + for relayer_id in &relayer_ids { + let relayer_key = self.relayer_key(relayer_id); + pipe.del(&relayer_key); + } + + // Delete the relayer list key + pipe.del(&relayer_list_key); + + pipe.exec_async(&mut conn) + .await + .map_err(|e| self.map_redis_error(e, "drop_all_entries_pipeline"))?; + + debug!("Dropped {} relayer entries", relayer_ids.len()); + Ok(()) + } } #[async_trait] @@ -877,4 +930,30 @@ mod tests { let result = repo.delete_by_id("nonexistent-relayer".to_string()).await; assert!(matches!(result, Err(RepositoryError::NotFound(_)))); } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_has_entries() { + let repo = setup_test_repo().await; + assert!(!repo.has_entries().await.unwrap()); + + let relayer_id = uuid::Uuid::new_v4().to_string(); + let relayer = create_test_relayer(&relayer_id); + repo.create(relayer.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + } + + // test drop_all_entries + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_drop_all_entries() { + let repo = setup_test_repo().await; + let relayer_id = uuid::Uuid::new_v4().to_string(); + let relayer = create_test_relayer(&relayer_id); + repo.create(relayer.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } } diff --git a/src/repositories/signer/mod.rs b/src/repositories/signer/mod.rs index e7300f141..484e02bd8 100644 --- a/src/repositories/signer/mod.rs +++ b/src/repositories/signer/mod.rs @@ -117,6 +117,20 @@ impl Repository for SignerRepositoryStorage { SignerRepositoryStorage::Redis(repo) => repo.count().await, } } + + async fn has_entries(&self) -> Result { + match self { + SignerRepositoryStorage::InMemory(repo) => repo.has_entries().await, + SignerRepositoryStorage::Redis(repo) => repo.has_entries().await, + } + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + match self { + SignerRepositoryStorage::InMemory(repo) => repo.drop_all_entries().await, + SignerRepositoryStorage::Redis(repo) => repo.drop_all_entries().await, + } + } } #[cfg(test)] @@ -271,6 +285,27 @@ mod tests { assert!(result.is_err()); assert!(matches!(result.unwrap_err(), RepositoryError::NotFound(_))); } + + #[actix_web::test] + async fn test_has_entries() { + let repo = InMemorySignerRepository::new(); + assert!(!repo.has_entries().await.unwrap()); + + let signer = create_test_signer("test".to_string()); + repo.create(signer.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + } + + #[actix_web::test] + async fn test_drop_all_entries() { + let repo = InMemorySignerRepository::new(); + let signer = create_test_signer("test".to_string()); + repo.create(signer.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } } #[cfg(test)] @@ -286,5 +321,7 @@ mockall::mock! { async fn update(&self, id: String, entity: SignerRepoModel) -> Result; async fn delete_by_id(&self, id: String) -> Result<(), RepositoryError>; async fn count(&self) -> Result; + async fn has_entries(&self) -> Result; + async fn drop_all_entries(&self) -> Result<(), RepositoryError>; } } diff --git a/src/repositories/signer/signer_in_memory.rs b/src/repositories/signer/signer_in_memory.rs index 959ef9b92..d43fd466c 100644 --- a/src/repositories/signer/signer_in_memory.rs +++ b/src/repositories/signer/signer_in_memory.rs @@ -144,6 +144,17 @@ impl Repository for InMemorySignerRepository { let length = store.len(); Ok(length) } + + async fn has_entries(&self) -> Result { + let store = Self::acquire_lock(&self.store).await?; + Ok(!store.is_empty()) + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut store = Self::acquire_lock(&self.store).await?; + store.clear(); + Ok(()) + } } #[cfg(test)] @@ -230,4 +241,26 @@ mod tests { let result = repo.get_by_id("test".to_string()).await; assert!(matches!(result, Err(RepositoryError::NotFound(_)))); } + + #[actix_web::test] + async fn test_has_entries() { + let repo = InMemorySignerRepository::new(); + assert!(!repo.has_entries().await.unwrap()); + + let signer = create_test_signer("test".to_string()); + + repo.create(signer.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + } + + #[actix_web::test] + async fn test_drop_all_entries() { + let repo = InMemorySignerRepository::new(); + let signer = create_test_signer("test".to_string()); + repo.create(signer.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } } diff --git a/src/repositories/signer/signer_redis.rs b/src/repositories/signer/signer_redis.rs index c24d3f0a5..404f997ad 100644 --- a/src/repositories/signer/signer_redis.rs +++ b/src/repositories/signer/signer_redis.rs @@ -412,6 +412,59 @@ impl Repository for RedisSignerRepository { let ids = self.get_all_ids().await?; Ok(ids.len()) } + + async fn has_entries(&self) -> Result { + let mut conn = self.client.as_ref().clone(); + let signer_list_key = self.signer_list_key(); + + debug!("Checking if signer entries exist"); + + let exists: bool = conn + .exists(&signer_list_key) + .await + .map_err(|e| self.map_redis_error(e, "has_entries_check"))?; + + debug!("Signer entries exist: {}", exists); + Ok(exists) + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut conn = self.client.as_ref().clone(); + let signer_list_key = self.signer_list_key(); + + debug!("Dropping all signer entries"); + + // Get all signer IDs first + let signer_ids: Vec = conn + .smembers(&signer_list_key) + .await + .map_err(|e| self.map_redis_error(e, "drop_all_entries_get_ids"))?; + + if signer_ids.is_empty() { + debug!("No signer entries to drop"); + return Ok(()); + } + + // Use pipeline for atomic operations + let mut pipe = redis::pipe(); + pipe.atomic(); + + // Delete all individual signer entries + for signer_id in &signer_ids { + let signer_key = self.signer_key(signer_id); + pipe.del(&signer_key); + } + + // Delete the signer list key + pipe.del(&signer_list_key); + + pipe.exec_async(&mut conn) + .await + .map_err(|e| self.map_redis_error(e, "drop_all_entries_pipeline"))?; + + debug!("Dropped {} signer entries", signer_ids.len()); + Ok(()) + } } #[cfg(test)] @@ -746,4 +799,29 @@ mod tests { .to_string() .contains("ID in data does not match")); } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_has_entries() { + let repo = setup_test_repo().await; + + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = create_test_signer(&signer_id); + repo.create(signer.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_drop_all_entries() { + let repo = setup_test_repo().await; + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = create_test_signer(&signer_id); + + repo.create(signer.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } } diff --git a/src/repositories/transaction/mod.rs b/src/repositories/transaction/mod.rs index 1433a3396..b36bc6447 100644 --- a/src/repositories/transaction/mod.rs +++ b/src/repositories/transaction/mod.rs @@ -108,6 +108,8 @@ mockall::mock! { async fn update(&self, id: String, entity: TransactionRepoModel) -> Result; async fn delete_by_id(&self, id: String) -> Result<(), RepositoryError>; async fn count(&self) -> Result; + async fn has_entries(&self) -> Result; + async fn drop_all_entries(&self) -> Result<(), RepositoryError>; } #[async_trait] @@ -319,4 +321,772 @@ impl Repository for TransactionRepositoryStorage { TransactionRepositoryStorage::Redis(repo) => repo.count().await, } } + + async fn has_entries(&self) -> Result { + match self { + TransactionRepositoryStorage::InMemory(repo) => repo.has_entries().await, + TransactionRepositoryStorage::Redis(repo) => repo.has_entries().await, + } + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + match self { + TransactionRepositoryStorage::InMemory(repo) => repo.drop_all_entries().await, + TransactionRepositoryStorage::Redis(repo) => repo.drop_all_entries().await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ + EvmTransactionData, NetworkTransactionData, TransactionStatus, TransactionUpdateRequest, + }; + use crate::repositories::PaginationQuery; + use crate::utils::mocks::mockutils::create_mock_transaction; + use chrono::Utc; + use color_eyre::Result; + + fn create_test_transaction(id: &str, relayer_id: &str) -> TransactionRepoModel { + let mut transaction = create_mock_transaction(); + transaction.id = id.to_string(); + transaction.relayer_id = relayer_id.to_string(); + transaction + } + + fn create_test_transaction_with_status( + id: &str, + relayer_id: &str, + status: TransactionStatus, + ) -> TransactionRepoModel { + let mut transaction = create_test_transaction(id, relayer_id); + transaction.status = status; + transaction + } + + fn create_test_transaction_with_nonce( + id: &str, + relayer_id: &str, + nonce: u64, + ) -> TransactionRepoModel { + let mut transaction = create_test_transaction(id, relayer_id); + if let NetworkTransactionData::Evm(ref mut evm_data) = transaction.network_data { + evm_data.nonce = Some(nonce); + } + transaction + } + + fn create_test_update_request() -> TransactionUpdateRequest { + TransactionUpdateRequest { + status: Some(TransactionStatus::Sent), + status_reason: Some("Test reason".to_string()), + sent_at: Some(Utc::now().to_string()), + confirmed_at: None, + network_data: None, + priced_at: None, + hashes: Some(vec!["test_hash".to_string()]), + noop_count: None, + is_canceled: None, + } + } + + #[tokio::test] + async fn test_new_in_memory() { + let storage = TransactionRepositoryStorage::new_in_memory(); + + match storage { + TransactionRepositoryStorage::InMemory(_) => { + // Success - verify it's the InMemory variant + } + TransactionRepositoryStorage::Redis(_) => { + panic!("Expected InMemory variant, got Redis"); + } + } + } + + #[tokio::test] + async fn test_create_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + let transaction = create_test_transaction("test-tx", "test-relayer"); + + let created = storage.create(transaction.clone()).await?; + assert_eq!(created.id, transaction.id); + assert_eq!(created.relayer_id, transaction.relayer_id); + assert_eq!(created.status, transaction.status); + + Ok(()) + } + + #[tokio::test] + async fn test_get_by_id_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + let transaction = create_test_transaction("test-tx", "test-relayer"); + + // Create transaction first + storage.create(transaction.clone()).await?; + + // Get by ID + let retrieved = storage.get_by_id("test-tx".to_string()).await?; + assert_eq!(retrieved.id, transaction.id); + assert_eq!(retrieved.relayer_id, transaction.relayer_id); + assert_eq!(retrieved.status, transaction.status); + + Ok(()) + } + + #[tokio::test] + async fn test_get_by_id_not_found_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + let result = storage.get_by_id("non-existent".to_string()).await; + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_list_all_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Initially empty + let transactions = storage.list_all().await?; + assert!(transactions.is_empty()); + + // Add transactions + let tx1 = create_test_transaction("tx-1", "relayer-1"); + let tx2 = create_test_transaction("tx-2", "relayer-2"); + + storage.create(tx1.clone()).await?; + storage.create(tx2.clone()).await?; + + let all_transactions = storage.list_all().await?; + assert_eq!(all_transactions.len(), 2); + + let ids: Vec<&str> = all_transactions.iter().map(|t| t.id.as_str()).collect(); + assert!(ids.contains(&"tx-1")); + assert!(ids.contains(&"tx-2")); + + Ok(()) + } + + #[tokio::test] + async fn test_list_paginated_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Add test transactions + for i in 1..=5 { + let tx = create_test_transaction(&format!("tx-{}", i), "test-relayer"); + storage.create(tx).await?; + } + + // Test pagination + let query = PaginationQuery { + page: 1, + per_page: 2, + }; + let page = storage.list_paginated(query).await?; + + assert_eq!(page.items.len(), 2); + assert_eq!(page.total, 5); + assert_eq!(page.page, 1); + assert_eq!(page.per_page, 2); + + // Test second page + let query2 = PaginationQuery { + page: 2, + per_page: 2, + }; + let page2 = storage.list_paginated(query2).await?; + + assert_eq!(page2.items.len(), 2); + assert_eq!(page2.total, 5); + assert_eq!(page2.page, 2); + assert_eq!(page2.per_page, 2); + + Ok(()) + } + + #[tokio::test] + async fn test_update_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + let transaction = create_test_transaction("test-tx", "test-relayer"); + + // Create transaction first + storage.create(transaction.clone()).await?; + + // Update it + let mut updated_transaction = transaction.clone(); + updated_transaction.status = TransactionStatus::Sent; + updated_transaction.status_reason = Some("Updated reason".to_string()); + + let result = storage + .update("test-tx".to_string(), updated_transaction.clone()) + .await?; + assert_eq!(result.id, "test-tx"); + assert_eq!(result.status, TransactionStatus::Sent); + assert_eq!(result.status_reason, Some("Updated reason".to_string())); + + // Verify the update persisted + let retrieved = storage.get_by_id("test-tx".to_string()).await?; + assert_eq!(retrieved.status, TransactionStatus::Sent); + assert_eq!(retrieved.status_reason, Some("Updated reason".to_string())); + + Ok(()) + } + + #[tokio::test] + async fn test_update_not_found_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + let transaction = create_test_transaction("non-existent", "test-relayer"); + + let result = storage + .update("non-existent".to_string(), transaction) + .await; + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_delete_by_id_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + let transaction = create_test_transaction("test-tx", "test-relayer"); + + // Create transaction first + storage.create(transaction.clone()).await?; + + // Verify it exists + let retrieved = storage.get_by_id("test-tx".to_string()).await?; + assert_eq!(retrieved.id, "test-tx"); + + // Delete it + storage.delete_by_id("test-tx".to_string()).await?; + + // Verify it's gone + let result = storage.get_by_id("test-tx".to_string()).await; + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_delete_by_id_not_found_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + let result = storage.delete_by_id("non-existent".to_string()).await; + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_count_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Initially empty + let count = storage.count().await?; + assert_eq!(count, 0); + + // Add transactions + let tx1 = create_test_transaction("tx-1", "relayer-1"); + let tx2 = create_test_transaction("tx-2", "relayer-2"); + + storage.create(tx1).await?; + let count_after_one = storage.count().await?; + assert_eq!(count_after_one, 1); + + storage.create(tx2).await?; + let count_after_two = storage.count().await?; + assert_eq!(count_after_two, 2); + + // Delete one + storage.delete_by_id("tx-1".to_string()).await?; + let count_after_delete = storage.count().await?; + assert_eq!(count_after_delete, 1); + + Ok(()) + } + + #[tokio::test] + async fn test_has_entries_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Initially empty + let has_entries = storage.has_entries().await?; + assert!(!has_entries); + + // Add transaction + let transaction = create_test_transaction("test-tx", "test-relayer"); + storage.create(transaction).await?; + + let has_entries_after_create = storage.has_entries().await?; + assert!(has_entries_after_create); + + // Delete transaction + storage.delete_by_id("test-tx".to_string()).await?; + + let has_entries_after_delete = storage.has_entries().await?; + assert!(!has_entries_after_delete); + + Ok(()) + } + + #[tokio::test] + async fn test_drop_all_entries_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Add multiple transactions + for i in 1..=5 { + let tx = create_test_transaction(&format!("tx-{}", i), "test-relayer"); + storage.create(tx).await?; + } + + // Verify they exist + let count_before = storage.count().await?; + assert_eq!(count_before, 5); + + let has_entries_before = storage.has_entries().await?; + assert!(has_entries_before); + + // Drop all entries + storage.drop_all_entries().await?; + + // Verify they're gone + let count_after = storage.count().await?; + assert_eq!(count_after, 0); + + let has_entries_after = storage.has_entries().await?; + assert!(!has_entries_after); + + let all_transactions = storage.list_all().await?; + assert!(all_transactions.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_find_by_relayer_id_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Add transactions for different relayers + let tx1 = create_test_transaction("tx-1", "relayer-1"); + let tx2 = create_test_transaction("tx-2", "relayer-1"); + let tx3 = create_test_transaction("tx-3", "relayer-2"); + + storage.create(tx1).await?; + storage.create(tx2).await?; + storage.create(tx3).await?; + + // Find by relayer ID + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + let result = storage.find_by_relayer_id("relayer-1", query).await?; + + assert_eq!(result.items.len(), 2); + assert_eq!(result.total, 2); + + // Verify all transactions belong to relayer-1 + for tx in result.items { + assert_eq!(tx.relayer_id, "relayer-1"); + } + + Ok(()) + } + + #[tokio::test] + async fn test_find_by_status_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Add transactions with different statuses + let tx1 = + create_test_transaction_with_status("tx-1", "relayer-1", TransactionStatus::Pending); + let tx2 = create_test_transaction_with_status("tx-2", "relayer-1", TransactionStatus::Sent); + let tx3 = + create_test_transaction_with_status("tx-3", "relayer-1", TransactionStatus::Pending); + let tx4 = + create_test_transaction_with_status("tx-4", "relayer-2", TransactionStatus::Pending); + + storage.create(tx1).await?; + storage.create(tx2).await?; + storage.create(tx3).await?; + storage.create(tx4).await?; + + // Find by status + let statuses = vec![TransactionStatus::Pending]; + let result = storage.find_by_status("relayer-1", &statuses).await?; + + assert_eq!(result.len(), 2); + + // Verify all transactions have Pending status and belong to relayer-1 + for tx in result { + assert_eq!(tx.status, TransactionStatus::Pending); + assert_eq!(tx.relayer_id, "relayer-1"); + } + + Ok(()) + } + + #[tokio::test] + async fn test_find_by_nonce_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Add transactions with different nonces + let tx1 = create_test_transaction_with_nonce("tx-1", "relayer-1", 10); + let tx2 = create_test_transaction_with_nonce("tx-2", "relayer-1", 20); + let tx3 = create_test_transaction_with_nonce("tx-3", "relayer-2", 10); + + storage.create(tx1).await?; + storage.create(tx2).await?; + storage.create(tx3).await?; + + // Find by nonce + let result = storage.find_by_nonce("relayer-1", 10).await?; + + assert!(result.is_some()); + let found_tx = result.unwrap(); + assert_eq!(found_tx.id, "tx-1"); + assert_eq!(found_tx.relayer_id, "relayer-1"); + + // Check EVM nonce + if let NetworkTransactionData::Evm(evm_data) = found_tx.network_data { + assert_eq!(evm_data.nonce, Some(10)); + } + + // Test not found + let not_found = storage.find_by_nonce("relayer-1", 99).await?; + assert!(not_found.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_update_status_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + let transaction = create_test_transaction("test-tx", "test-relayer"); + + // Create transaction first + storage.create(transaction).await?; + + // Update status + let updated = storage + .update_status("test-tx".to_string(), TransactionStatus::Sent) + .await?; + + assert_eq!(updated.id, "test-tx"); + assert_eq!(updated.status, TransactionStatus::Sent); + + // Verify the update persisted + let retrieved = storage.get_by_id("test-tx".to_string()).await?; + assert_eq!(retrieved.status, TransactionStatus::Sent); + + Ok(()) + } + + #[tokio::test] + async fn test_partial_update_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + let transaction = create_test_transaction("test-tx", "test-relayer"); + + // Create transaction first + storage.create(transaction).await?; + + // Partial update + let update_request = create_test_update_request(); + let updated = storage + .partial_update("test-tx".to_string(), update_request) + .await?; + + assert_eq!(updated.id, "test-tx"); + assert_eq!(updated.status, TransactionStatus::Sent); + assert_eq!(updated.status_reason, Some("Test reason".to_string())); + assert!(updated.sent_at.is_some()); + assert_eq!(updated.hashes, vec!["test_hash".to_string()]); + + Ok(()) + } + + #[tokio::test] + async fn test_update_network_data_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + let transaction = create_test_transaction("test-tx", "test-relayer"); + + // Create transaction first + storage.create(transaction).await?; + + // Update network data + let new_evm_data = EvmTransactionData { + nonce: Some(42), + gas_limit: 21000, + ..Default::default() + }; + let new_network_data = NetworkTransactionData::Evm(new_evm_data); + + let updated = storage + .update_network_data("test-tx".to_string(), new_network_data) + .await?; + + assert_eq!(updated.id, "test-tx"); + if let NetworkTransactionData::Evm(evm_data) = updated.network_data { + assert_eq!(evm_data.nonce, Some(42)); + assert_eq!(evm_data.gas_limit, 21000); + } else { + panic!("Expected EVM network data"); + } + + Ok(()) + } + + #[tokio::test] + async fn test_set_sent_at_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + let transaction = create_test_transaction("test-tx", "test-relayer"); + + // Create transaction first + storage.create(transaction).await?; + + // Set sent_at + let sent_at = Utc::now().to_string(); + let updated = storage + .set_sent_at("test-tx".to_string(), sent_at.clone()) + .await?; + + assert_eq!(updated.id, "test-tx"); + assert_eq!(updated.sent_at, Some(sent_at)); + + Ok(()) + } + + #[tokio::test] + async fn test_set_confirmed_at_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + let transaction = create_test_transaction("test-tx", "test-relayer"); + + // Create transaction first + storage.create(transaction).await?; + + // Set confirmed_at + let confirmed_at = Utc::now().to_string(); + let updated = storage + .set_confirmed_at("test-tx".to_string(), confirmed_at.clone()) + .await?; + + assert_eq!(updated.id, "test-tx"); + assert_eq!(updated.confirmed_at, Some(confirmed_at)); + + Ok(()) + } + + #[tokio::test] + async fn test_create_duplicate_id_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + let transaction = create_test_transaction("duplicate-id", "test-relayer"); + + // Create first transaction + storage.create(transaction.clone()).await?; + + // Try to create another with same ID - should fail + let result = storage.create(transaction.clone()).await; + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_workflow_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // 1. Start with empty storage + assert!(!storage.has_entries().await?); + assert_eq!(storage.count().await?, 0); + + // 2. Create transaction + let transaction = create_test_transaction("workflow-test", "test-relayer"); + let created = storage.create(transaction.clone()).await?; + assert_eq!(created.id, "workflow-test"); + + // 3. Verify it exists + assert!(storage.has_entries().await?); + assert_eq!(storage.count().await?, 1); + + // 4. Retrieve it + let retrieved = storage.get_by_id("workflow-test".to_string()).await?; + assert_eq!(retrieved.id, "workflow-test"); + + // 5. Update status + let updated = storage + .update_status("workflow-test".to_string(), TransactionStatus::Sent) + .await?; + assert_eq!(updated.status, TransactionStatus::Sent); + + // 6. Verify update + let retrieved_updated = storage.get_by_id("workflow-test".to_string()).await?; + assert_eq!(retrieved_updated.status, TransactionStatus::Sent); + + // 7. Delete it + storage.delete_by_id("workflow-test".to_string()).await?; + + // 8. Verify it's gone + assert!(!storage.has_entries().await?); + assert_eq!(storage.count().await?, 0); + + let result = storage.get_by_id("workflow-test".to_string()).await; + assert!(result.is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_multiple_relayers_workflow() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Add transactions for multiple relayers + let tx1 = + create_test_transaction_with_status("tx-1", "relayer-1", TransactionStatus::Pending); + let tx2 = create_test_transaction_with_status("tx-2", "relayer-1", TransactionStatus::Sent); + let tx3 = + create_test_transaction_with_status("tx-3", "relayer-2", TransactionStatus::Pending); + + storage.create(tx1).await?; + storage.create(tx2).await?; + storage.create(tx3).await?; + + // Test find_by_relayer_id + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + let relayer1_txs = storage.find_by_relayer_id("relayer-1", query).await?; + assert_eq!(relayer1_txs.items.len(), 2); + + // Test find_by_status + let pending_txs = storage + .find_by_status("relayer-1", &[TransactionStatus::Pending]) + .await?; + assert_eq!(pending_txs.len(), 1); + assert_eq!(pending_txs[0].id, "tx-1"); + + // Test count remains accurate + assert_eq!(storage.count().await?, 3); + + Ok(()) + } + + #[tokio::test] + async fn test_pagination_edge_cases_in_memory() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Test pagination with empty storage + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + let page = storage.list_paginated(query).await?; + assert_eq!(page.items.len(), 0); + assert_eq!(page.total, 0); + assert_eq!(page.page, 1); + assert_eq!(page.per_page, 10); + + // Add one transaction + let transaction = create_test_transaction("single-tx", "test-relayer"); + storage.create(transaction).await?; + + // Test pagination with single item + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + let page = storage.list_paginated(query).await?; + assert_eq!(page.items.len(), 1); + assert_eq!(page.total, 1); + assert_eq!(page.page, 1); + assert_eq!(page.per_page, 10); + + // Test pagination with page beyond total + let query = PaginationQuery { + page: 3, + per_page: 10, + }; + let page = storage.list_paginated(query).await?; + assert_eq!(page.items.len(), 0); + assert_eq!(page.total, 1); + assert_eq!(page.page, 3); + assert_eq!(page.per_page, 10); + + Ok(()) + } + + #[tokio::test] + async fn test_find_by_relayer_id_pagination() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Add many transactions for one relayer + for i in 1..=10 { + let tx = create_test_transaction(&format!("tx-{}", i), "test-relayer"); + storage.create(tx).await?; + } + + // Test first page + let query = PaginationQuery { + page: 1, + per_page: 3, + }; + let page1 = storage.find_by_relayer_id("test-relayer", query).await?; + assert_eq!(page1.items.len(), 3); + assert_eq!(page1.total, 10); + assert_eq!(page1.page, 1); + assert_eq!(page1.per_page, 3); + + // Test second page + let query = PaginationQuery { + page: 2, + per_page: 3, + }; + let page2 = storage.find_by_relayer_id("test-relayer", query).await?; + assert_eq!(page2.items.len(), 3); + assert_eq!(page2.total, 10); + assert_eq!(page2.page, 2); + assert_eq!(page2.per_page, 3); + + Ok(()) + } + + #[tokio::test] + async fn test_find_by_multiple_statuses() -> Result<()> { + let storage = TransactionRepositoryStorage::new_in_memory(); + + // Add transactions with different statuses + let tx1 = + create_test_transaction_with_status("tx-1", "test-relayer", TransactionStatus::Pending); + let tx2 = + create_test_transaction_with_status("tx-2", "test-relayer", TransactionStatus::Sent); + let tx3 = create_test_transaction_with_status( + "tx-3", + "test-relayer", + TransactionStatus::Confirmed, + ); + let tx4 = + create_test_transaction_with_status("tx-4", "test-relayer", TransactionStatus::Failed); + + storage.create(tx1).await?; + storage.create(tx2).await?; + storage.create(tx3).await?; + storage.create(tx4).await?; + + // Find by multiple statuses + let statuses = vec![TransactionStatus::Pending, TransactionStatus::Sent]; + let result = storage.find_by_status("test-relayer", &statuses).await?; + + assert_eq!(result.len(), 2); + + // Verify all transactions have the correct statuses + let found_statuses: Vec = + result.iter().map(|tx| tx.status.clone()).collect(); + assert!(found_statuses.contains(&TransactionStatus::Pending)); + assert!(found_statuses.contains(&TransactionStatus::Sent)); + + Ok(()) + } } diff --git a/src/repositories/transaction/transaction_in_memory.rs b/src/repositories/transaction/transaction_in_memory.rs index d7dd8c3c7..9326e74e3 100644 --- a/src/repositories/transaction/transaction_in_memory.rs +++ b/src/repositories/transaction/transaction_in_memory.rs @@ -137,6 +137,17 @@ impl Repository for InMemoryTransactionRepository let store = Self::acquire_lock(&self.store).await?; Ok(store.len()) } + + async fn has_entries(&self) -> Result { + let store = Self::acquire_lock(&self.store).await?; + Ok(!store.is_empty()) + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut store = Self::acquire_lock(&self.store).await?; + store.clear(); + Ok(()) + } } #[async_trait] @@ -916,4 +927,27 @@ mod tests { assert_eq!(result[1].created_at, "2025-01-27T16:00:00.000000+00:00"); assert_eq!(result[2].created_at, "2025-01-27T17:00:00.000000+00:00"); } + + #[tokio::test] + async fn test_has_entries() { + let repo = InMemoryTransactionRepository::new(); + assert!(!repo.has_entries().await.unwrap()); + + let tx = create_test_transaction("test"); + repo.create(tx.clone()).await.unwrap(); + + assert!(repo.has_entries().await.unwrap()); + } + + #[tokio::test] + async fn test_drop_all_entries() { + let repo = InMemoryTransactionRepository::new(); + let tx = create_test_transaction("test"); + repo.create(tx.clone()).await.unwrap(); + + assert!(repo.has_entries().await.unwrap()); + + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } } diff --git a/src/repositories/transaction/transaction_redis.rs b/src/repositories/transaction/transaction_redis.rs index e777e7d2d..f22a46bbd 100644 --- a/src/repositories/transaction/transaction_redis.rs +++ b/src/repositories/transaction/transaction_redis.rs @@ -656,6 +656,108 @@ impl Repository for RedisTransactionRepository { debug!("Transaction count: {}", total_count); Ok(total_count) } + + async fn has_entries(&self) -> Result { + let mut conn = self.client.as_ref().clone(); + let relayer_list_key = self.relayer_list_key(); + + debug!("Checking if transaction entries exist"); + + let exists: bool = conn + .exists(&relayer_list_key) + .await + .map_err(|e| self.map_redis_error(e, "has_entries_check"))?; + + debug!("Transaction entries exist: {}", exists); + Ok(exists) + } + + async fn drop_all_entries(&self) -> Result<(), RepositoryError> { + let mut conn = self.client.as_ref().clone(); + let relayer_list_key = self.relayer_list_key(); + + debug!("Dropping all transaction entries"); + + // Get all relayer IDs first + let relayer_ids: Vec = conn + .smembers(&relayer_list_key) + .await + .map_err(|e| self.map_redis_error(e, "drop_all_entries_get_relayer_ids"))?; + + if relayer_ids.is_empty() { + debug!("No transaction entries to drop"); + return Ok(()); + } + + // Use pipeline for atomic operations + let mut pipe = redis::pipe(); + pipe.atomic(); + + // Delete all transactions and their indexes for each relayer + for relayer_id in &relayer_ids { + // Get all transaction IDs for this relayer + let pattern = format!( + "{}:{}:{}:{}:*", + self.key_prefix, RELAYER_PREFIX, relayer_id, TX_PREFIX + ); + let mut cursor = 0; + let mut tx_ids = Vec::new(); + + loop { + let (next_cursor, keys): (u64, Vec) = redis::cmd("SCAN") + .cursor_arg(cursor) + .arg("MATCH") + .arg(&pattern) + .query_async(&mut conn) + .await + .map_err(|e| self.map_redis_error(e, "drop_all_entries_scan"))?; + + // Extract transaction IDs from keys and delete keys + for key in keys { + pipe.del(&key); + if let Some(tx_id) = key.split(':').next_back() { + tx_ids.push(tx_id.to_string()); + } + } + + cursor = next_cursor; + if cursor == 0 { + break; + } + } + + // Delete reverse lookup keys and indexes + for tx_id in tx_ids { + let reverse_key = self.tx_to_relayer_key(&tx_id); + pipe.del(&reverse_key); + + // Delete status indexes (we can't know the specific status, so we'll clean up known ones) + for status in &[ + TransactionStatus::Pending, + TransactionStatus::Sent, + TransactionStatus::Confirmed, + TransactionStatus::Failed, + TransactionStatus::Canceled, + ] { + let status_key = self.relayer_status_key(relayer_id, status); + pipe.srem(&status_key, &tx_id); + } + } + } + + // Delete the relayer list key + pipe.del(&relayer_list_key); + + pipe.exec_async(&mut conn) + .await + .map_err(|e| self.map_redis_error(e, "drop_all_entries_pipeline"))?; + + debug!( + "Dropped all transaction entries for {} relayers", + relayer_ids.len() + ); + Ok(()) + } } #[async_trait] @@ -1505,4 +1607,30 @@ mod tests { let new_nonce_result = repo.find_by_nonce(&relayer_id, 43).await.unwrap(); assert!(new_nonce_result.is_some()); } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_has_entries() { + let repo = setup_test_repo().await; + assert!(!repo.has_entries().await.unwrap()); + + let tx_id = uuid::Uuid::new_v4().to_string(); + let tx = create_test_transaction(&tx_id); + repo.create(tx.clone()).await.unwrap(); + + assert!(repo.has_entries().await.unwrap()); + } + + #[tokio::test] + #[ignore = "Requires active Redis instance"] + async fn test_drop_all_entries() { + let repo = setup_test_repo().await; + let tx_id = uuid::Uuid::new_v4().to_string(); + let tx = create_test_transaction(&tx_id); + repo.create(tx.clone()).await.unwrap(); + assert!(repo.has_entries().await.unwrap()); + + repo.drop_all_entries().await.unwrap(); + assert!(!repo.has_entries().await.unwrap()); + } } diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index 13953c340..33aa33014 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -11,9 +11,9 @@ pub mod mockutils { jobs::MockJobProducerTrait, models::{ AppState, EvmTransactionData, EvmTransactionRequest, LocalSignerConfig, - NetworkRepoModel, NetworkTransactionData, NetworkType, PluginModel, RelayerEvmPolicy, - RelayerNetworkPolicy, RelayerRepoModel, SecretString, SignerConfig, SignerRepoModel, - TransactionRepoModel, TransactionStatus, + NetworkRepoModel, NetworkTransactionData, NetworkType, NotificationRepoModel, + PluginModel, RelayerEvmPolicy, RelayerNetworkPolicy, RelayerRepoModel, SecretString, + SignerConfig, SignerRepoModel, TransactionRepoModel, TransactionStatus, }, repositories::{ NetworkRepositoryStorage, NotificationRepositoryStorage, PluginRepositoryStorage, @@ -44,6 +44,15 @@ pub mod mockutils { } } + pub fn create_mock_notification(id: String) -> NotificationRepoModel { + NotificationRepoModel { + id, + notification_type: crate::models::NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: None, + } + } + pub fn create_mock_signer() -> SignerRepoModel { let seed = vec![1u8; 32]; let raw_key = SecretVec::new(32, |v| v.copy_from_slice(&seed)); @@ -215,6 +224,7 @@ pub mod mockutils { provider_retry_max_delay_ms: 2000, provider_max_failovers: 3, repository_storage_type: storage_type, + reset_storage_on_start: false, } } } diff --git a/tests/integration/metrics.rs b/tests/integration/metrics.rs index 367673c35..1707c49d7 100644 --- a/tests/integration/metrics.rs +++ b/tests/integration/metrics.rs @@ -29,6 +29,7 @@ async fn test_authorization_middleware_success() { provider_retry_max_delay_ms: 2000, provider_max_failovers: 3, repository_storage_type: RepositoryStorageType::InMemory, + reset_storage_on_start: false, }); let app = test::init_service( @@ -84,6 +85,7 @@ async fn test_authorization_middleware_failure() { provider_retry_max_delay_ms: 2000, provider_max_failovers: 3, repository_storage_type: RepositoryStorageType::InMemory, + reset_storage_on_start: false, }); let app = test::init_service( From 0caa42ca7ed3e706eaf927ba66c0d9a1c4101a41 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 14 Jul 2025 11:04:21 +0200 Subject: [PATCH 03/59] chore: fixes --- src/bootstrap/config_processor.rs | 36 +++++++------------ src/bootstrap/initialize_app_state.rs | 4 +-- .../notification/notification_in_memory.rs | 1 - .../notification/notification_redis.rs | 1 - src/repositories/relayer/relayer_in_memory.rs | 1 - src/repositories/relayer/relayer_redis.rs | 1 - 6 files changed, 14 insertions(+), 30 deletions(-) diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index 37080a158..a919fdae2 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -411,7 +411,6 @@ where /// This function checks if any of the main repository list keys exist in Redis. /// If they exist, it means Redis already contains data from a previous configuration load. async fn is_redis_populated( - _server_config: &ServerConfig, app_state: &ThinDataAppState, ) -> Result where @@ -472,8 +471,7 @@ where let should_process_config_file = match server_config.repository_storage_type { RepositoryStorageType::InMemory => true, RepositoryStorageType::Redis => { - server_config.reset_storage_on_start - || !is_redis_populated(&server_config, app_state).await? + server_config.reset_storage_on_start || !is_redis_populated(app_state).await? } }; @@ -1364,7 +1362,6 @@ mod tests { async fn test_is_redis_populated_empty_repositories() -> Result<()> { // Create fresh app state with all empty repositories let app_state = ThinData(create_test_app_state()); - let server_config = create_test_server_config(RepositoryStorageType::InMemory); // All repositories should be empty assert!(!app_state.relayer_repository.has_entries().await?); @@ -1374,7 +1371,7 @@ mod tests { assert!(!app_state.network_repository.has_entries().await?); // is_redis_populated should return false when all repositories are empty - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!(!result, "Expected false when all repositories are empty"); Ok(()) @@ -1383,7 +1380,6 @@ mod tests { #[tokio::test] async fn test_is_redis_populated_relayer_repository_has_entries() -> Result<()> { let app_state = ThinData(create_test_app_state()); - let server_config = create_test_server_config(RepositoryStorageType::InMemory); // Add a relayer to the repository let relayer = create_mock_relayer("test-relayer".to_string(), false); @@ -1393,7 +1389,7 @@ mod tests { assert!(app_state.relayer_repository.has_entries().await?); // is_redis_populated should return true - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!(result, "Expected true when relayer repository has entries"); Ok(()) @@ -1402,7 +1398,6 @@ mod tests { #[tokio::test] async fn test_is_redis_populated_transaction_repository_has_entries() -> Result<()> { let app_state = ThinData(create_test_app_state()); - let server_config = create_test_server_config(RepositoryStorageType::InMemory); // Add a transaction to the repository let transaction = TransactionRepoModel::default(); @@ -1412,7 +1407,7 @@ mod tests { assert!(app_state.transaction_repository.has_entries().await?); // is_redis_populated should return true - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!( result, "Expected true when transaction repository has entries" @@ -1424,7 +1419,6 @@ mod tests { #[tokio::test] async fn test_is_redis_populated_signer_repository_has_entries() -> Result<()> { let app_state = ThinData(create_test_app_state()); - let server_config = create_test_server_config(RepositoryStorageType::InMemory); // Add a signer to the repository let signer = create_mock_signer(); @@ -1434,7 +1428,7 @@ mod tests { assert!(app_state.signer_repository.has_entries().await?); // is_redis_populated should return true - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!(result, "Expected true when signer repository has entries"); Ok(()) @@ -1443,7 +1437,6 @@ mod tests { #[tokio::test] async fn test_is_redis_populated_notification_repository_has_entries() -> Result<()> { let app_state = ThinData(create_test_app_state()); - let server_config = create_test_server_config(RepositoryStorageType::InMemory); // Add a notification to the repository let notification = create_mock_notification("test-notification".to_string()); @@ -1456,7 +1449,7 @@ mod tests { assert!(app_state.notification_repository.has_entries().await?); // is_redis_populated should return true - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!( result, "Expected true when notification repository has entries" @@ -1468,7 +1461,6 @@ mod tests { #[tokio::test] async fn test_is_redis_populated_network_repository_has_entries() -> Result<()> { let app_state = ThinData(create_test_app_state()); - let server_config = create_test_server_config(RepositoryStorageType::InMemory); // Add a network to the repository let network = create_mock_network(); @@ -1478,7 +1470,7 @@ mod tests { assert!(app_state.network_repository.has_entries().await?); // is_redis_populated should return true - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!(result, "Expected true when network repository has entries"); Ok(()) @@ -1487,7 +1479,6 @@ mod tests { #[tokio::test] async fn test_is_redis_populated_multiple_repositories_have_entries() -> Result<()> { let app_state = ThinData(create_test_app_state()); - let server_config = create_test_server_config(RepositoryStorageType::InMemory); // Add entries to multiple repositories let relayer = create_mock_relayer("test-relayer".to_string(), false); @@ -1510,7 +1501,7 @@ mod tests { assert!(app_state.network_repository.has_entries().await?); // is_redis_populated should return true - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!( result, "Expected true when multiple repositories have entries" @@ -1522,27 +1513,26 @@ mod tests { #[tokio::test] async fn test_is_redis_populated_comprehensive_scenario() -> Result<()> { let app_state = ThinData(create_test_app_state()); - let server_config = create_test_server_config(RepositoryStorageType::InMemory); // Test 1: Start with all empty repositories - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!(!result, "Expected false when all repositories are empty"); // Test 2: Add entry to one repository let relayer = create_mock_relayer("test-relayer".to_string(), false); app_state.relayer_repository.create(relayer).await?; - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!(result, "Expected true after adding one entry"); // Test 3: Clear all repositories app_state.relayer_repository.drop_all_entries().await?; - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!(!result, "Expected false after clearing all repositories"); // Test 4: Add entries to different repositories and verify each time let signer = create_mock_signer(); app_state.signer_repository.create(signer).await?; - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!(result, "Expected true after adding signer"); let notification = create_mock_notification("test-notification".to_string()); @@ -1550,7 +1540,7 @@ mod tests { .notification_repository .create(notification) .await?; - let result = is_redis_populated(&server_config, &app_state).await?; + let result = is_redis_populated(&app_state).await?; assert!(result, "Expected true after adding notification"); Ok(()) diff --git a/src/bootstrap/initialize_app_state.rs b/src/bootstrap/initialize_app_state.rs index 2febfafab..b6b2d3830 100644 --- a/src/bootstrap/initialize_app_state.rs +++ b/src/bootstrap/initialize_app_state.rs @@ -48,9 +48,7 @@ pub async fn initialize_repositories(config: &ServerConfig) -> eyre::Result { warn!("Redis repository storage support is experimental"); - let redis_connection_manager = initialize_redis_connection(config).await?; - - let connection_manager = redis_connection_manager.clone(); + let connection_manager = initialize_redis_connection(config).await?; RepositoryCollection { relayer: Arc::new(RelayerRepositoryStorage::new_redis( diff --git a/src/repositories/notification/notification_in_memory.rs b/src/repositories/notification/notification_in_memory.rs index 6a69e9d7b..d29e52e57 100644 --- a/src/repositories/notification/notification_in_memory.rs +++ b/src/repositories/notification/notification_in_memory.rs @@ -247,7 +247,6 @@ mod tests { assert!(repo.has_entries().await.unwrap()); } - // test drop_all_entries #[actix_web::test] async fn test_drop_all_entries() { let repo = InMemoryNotificationRepository::new(); diff --git a/src/repositories/notification/notification_redis.rs b/src/repositories/notification/notification_redis.rs index b98fe1c31..0fda45930 100644 --- a/src/repositories/notification/notification_redis.rs +++ b/src/repositories/notification/notification_redis.rs @@ -821,7 +821,6 @@ mod tests { assert!(repo.has_entries().await.unwrap()); } - // test drop_all_entries #[tokio::test] #[ignore = "Requires active Redis instance"] async fn test_drop_all_entries() { diff --git a/src/repositories/relayer/relayer_in_memory.rs b/src/repositories/relayer/relayer_in_memory.rs index 9f283759b..946fab28c 100644 --- a/src/repositories/relayer/relayer_in_memory.rs +++ b/src/repositories/relayer/relayer_in_memory.rs @@ -448,7 +448,6 @@ mod tests { assert!(repo.has_entries().await.unwrap()); } - // test drop_all_entries #[actix_web::test] async fn test_drop_all_entries() { let repo = InMemoryRelayerRepository::new(); diff --git a/src/repositories/relayer/relayer_redis.rs b/src/repositories/relayer/relayer_redis.rs index 205899b12..598ade6f9 100644 --- a/src/repositories/relayer/relayer_redis.rs +++ b/src/repositories/relayer/relayer_redis.rs @@ -943,7 +943,6 @@ mod tests { assert!(repo.has_entries().await.unwrap()); } - // test drop_all_entries #[tokio::test] #[ignore = "Requires active Redis instance"] async fn test_drop_all_entries() { From 6623d2b6989fa5ccae5b6d8dee72c6b3959162d0 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 14 Jul 2025 11:59:11 +0200 Subject: [PATCH 04/59] chore: imprve env sync_nonce and add service info log --- src/bootstrap/initialize_app_state.rs | 2 +- src/domain/relayer/evm/evm_relayer.rs | 98 +++++++++++++++++++++++++-- src/main.rs | 3 + src/repositories/transaction/mod.rs | 4 +- src/utils/mod.rs | 3 + src/utils/service_info_log.rs | 43 ++++++++++++ 6 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 src/utils/service_info_log.rs diff --git a/src/bootstrap/initialize_app_state.rs b/src/bootstrap/initialize_app_state.rs index b6b2d3830..f2333e9df 100644 --- a/src/bootstrap/initialize_app_state.rs +++ b/src/bootstrap/initialize_app_state.rs @@ -47,7 +47,7 @@ pub async fn initialize_repositories(config: &ServerConfig) -> eyre::Result { - warn!("Redis repository storage support is experimental"); + warn!("⚠️ Redis repository storage support is experimental. Use with caution."); let connection_manager = initialize_redis_connection(config).await?; RepositoryCollection { diff --git a/src/domain/relayer/evm/evm_relayer.rs b/src/domain/relayer/evm/evm_relayer.rs index 1bec95621..50da2b70f 100644 --- a/src/domain/relayer/evm/evm_relayer.rs +++ b/src/domain/relayer/evm/evm_relayer.rs @@ -47,7 +47,7 @@ use crate::{ }; use async_trait::async_trait; use eyre::Result; -use log::{info, warn}; +use log::{debug, info, warn}; use super::{ create_error_response, create_success_response, map_provider_error, EvmTransactionValidator, @@ -137,12 +137,23 @@ where .await .map_err(|e| RelayerError::ProviderError(e.to_string()))?; - info!( - "Setting nonce: {} for relayer: {}", - on_chain_nonce, self.relayer.id + let transaction_counter_nonce = self + .transaction_counter_service + .get() + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + + let nonce = std::cmp::max(on_chain_nonce, transaction_counter_nonce); + + debug!( + "Relayer: {} - On-chain nonce: {}, Transaction counter nonce: {}", + self.relayer.id, on_chain_nonce, transaction_counter_nonce ); - self.transaction_counter_service.set(on_chain_nonce).await?; + info!("Setting nonce: {} for relayer: {}", nonce, self.relayer.id); + + self.transaction_counter_service.set(nonce).await?; Ok(()) } @@ -806,6 +817,10 @@ mod tests { .expect_set() .returning(|_nonce| Box::pin(ready(Ok(())))); + counter + .expect_get() + .returning(|| Box::pin(ready(Ok(Some(42u64))))); + let relayer = EvmRelayer::new( relayer_model, signer, @@ -823,6 +838,79 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_sync_nonce_lower_on_chain_nonce() { + let (mut provider, relayer_repo, network_repo, tx_repo, job_producer, signer, mut counter) = + setup_mocks(); + let relayer_model = create_test_relayer(); + + provider + .expect_get_transaction_count() + .returning(|_| Box::pin(ready(Ok(40u64)))); + + counter + .expect_set() + .with(eq(42u64)) + .returning(|_nonce| Box::pin(ready(Ok(())))); + + counter + .expect_get() + .returning(|| Box::pin(ready(Ok(Some(42u64))))); + + let relayer = EvmRelayer::new( + relayer_model, + signer, + provider, + create_test_evm_network(), + Arc::new(relayer_repo), + Arc::new(network_repo), + Arc::new(tx_repo), + Arc::new(counter), + Arc::new(job_producer), + ) + .unwrap(); + + let result = relayer.sync_nonce().await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sync_nonce_lower_transaction_counter_nonce() { + let (mut provider, relayer_repo, network_repo, tx_repo, job_producer, signer, mut counter) = + setup_mocks(); + let relayer_model = create_test_relayer(); + + provider + .expect_get_transaction_count() + .returning(|_| Box::pin(ready(Ok(42u64)))); + + counter + .expect_set() + .with(eq(42u64)) + .returning(|_nonce| Box::pin(ready(Ok(())))); + + counter + .expect_get() + .returning(|| Box::pin(ready(Ok(Some(40u64))))); + + let relayer = EvmRelayer::new( + relayer_model, + signer, + provider, + create_test_evm_network(), + Arc::new(relayer_repo), + Arc::new(network_repo), + Arc::new(tx_repo), + Arc::new(counter), + Arc::new(job_producer), + ) + .unwrap(); + + let result = relayer.sync_nonce().await; + assert!(result.is_ok()); + } + + #[tokio::test] async fn test_validate_rpc() { let (mut provider, relayer_repo, network_repo, tx_repo, job_producer, signer, counter) = diff --git a/src/main.rs b/src/main.rs index 1b54b8c46..cfecebe56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,6 +69,9 @@ async fn main() -> Result<()> { dotenv().ok(); setup_logging(); + // Log service information at startup + openzeppelin_relayer::utils::log_service_info(); + // Set metrics enabled flag to false by default let metrics_enabled = env::var("METRICS_ENABLED") .map(|v| v.to_lowercase() == "true") diff --git a/src/repositories/transaction/mod.rs b/src/repositories/transaction/mod.rs index b36bc6447..8ed531d3c 100644 --- a/src/repositories/transaction/mod.rs +++ b/src/repositories/transaction/mod.rs @@ -819,7 +819,7 @@ mod tests { // Update network data let new_evm_data = EvmTransactionData { nonce: Some(42), - gas_limit: 21000, + gas_limit: Some(21000), ..Default::default() }; let new_network_data = NetworkTransactionData::Evm(new_evm_data); @@ -831,7 +831,7 @@ mod tests { assert_eq!(updated.id, "test-tx"); if let NetworkTransactionData::Evm(evm_data) = updated.network_data { assert_eq!(evm_data.nonce, Some(42)); - assert_eq!(evm_data.gas_limit, 21000); + assert_eq!(evm_data.gas_limit, Some(21000)); } else { panic!("Expected EVM network data"); } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index f51207711..30b6d659f 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -29,5 +29,8 @@ pub use secp256k::*; mod redis; pub use redis::*; +mod service_info_log; +pub use service_info_log::*; + #[cfg(test)] pub mod mocks; diff --git a/src/utils/service_info_log.rs b/src/utils/service_info_log.rs new file mode 100644 index 000000000..f7cf5eec6 --- /dev/null +++ b/src/utils/service_info_log.rs @@ -0,0 +1,43 @@ +//! This module contains the function to log service information at startup. +use log::info; +use std::env; + +/// Logs service information at startup +pub fn log_service_info() { + let service_name = env!("CARGO_PKG_NAME"); + let service_version = env!("CARGO_PKG_VERSION"); + + info!("=== OpenZeppelin Relayer Service Starting ==="); + info!("🚀 Service: {} v{}", service_name, service_version); + info!("🦀 Rust Version: {}", env!("CARGO_PKG_RUST_VERSION")); + + // Log environment information + if let Ok(profile) = env::var("CARGO_PKG_PROFILE") { + info!("🔧 Build Profile: {}", profile); + } + + // Log system information + info!("💻 Platform: {}", env::consts::OS); + info!("💻 Architecture: {}", env::consts::ARCH); + + // Log current working directory + if let Ok(cwd) = env::current_dir() { + info!("📁 Working Directory: {}", cwd.display()); + } + + // Log important environment variables if present + if let Ok(rust_log) = env::var("RUST_LOG") { + info!("🔧 Log Level: {}", rust_log); + } + + if let Ok(config_path) = env::var("CONFIG_PATH") { + info!("🔧 Config Path: {}", config_path); + } + + + // Log startup timestamp + info!("🕒 Started at: {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")); + + // log docs url + info!("ℹ️ Visit the Relayer documentation for more information https://docs.openzeppelin.com/relayer/"); +} \ No newline at end of file From f80f4d8061e922bd83d4149f5bdc453597bbed89 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 14 Jul 2025 12:11:41 +0200 Subject: [PATCH 05/59] chore: lint --- src/domain/relayer/evm/evm_relayer.rs | 1 - src/utils/service_info_log.rs | 22 ++++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/domain/relayer/evm/evm_relayer.rs b/src/domain/relayer/evm/evm_relayer.rs index 50da2b70f..ef32d3141 100644 --- a/src/domain/relayer/evm/evm_relayer.rs +++ b/src/domain/relayer/evm/evm_relayer.rs @@ -910,7 +910,6 @@ mod tests { assert!(result.is_ok()); } - #[tokio::test] async fn test_validate_rpc() { let (mut provider, relayer_repo, network_repo, tx_repo, job_producer, signer, counter) = diff --git a/src/utils/service_info_log.rs b/src/utils/service_info_log.rs index f7cf5eec6..20aecb27d 100644 --- a/src/utils/service_info_log.rs +++ b/src/utils/service_info_log.rs @@ -6,38 +6,40 @@ use std::env; pub fn log_service_info() { let service_name = env!("CARGO_PKG_NAME"); let service_version = env!("CARGO_PKG_VERSION"); - + info!("=== OpenZeppelin Relayer Service Starting ==="); info!("🚀 Service: {} v{}", service_name, service_version); info!("🦀 Rust Version: {}", env!("CARGO_PKG_RUST_VERSION")); - + // Log environment information if let Ok(profile) = env::var("CARGO_PKG_PROFILE") { info!("🔧 Build Profile: {}", profile); } - + // Log system information info!("💻 Platform: {}", env::consts::OS); info!("💻 Architecture: {}", env::consts::ARCH); - + // Log current working directory if let Ok(cwd) = env::current_dir() { info!("📁 Working Directory: {}", cwd.display()); } - + // Log important environment variables if present if let Ok(rust_log) = env::var("RUST_LOG") { info!("🔧 Log Level: {}", rust_log); } - + if let Ok(config_path) = env::var("CONFIG_PATH") { info!("🔧 Config Path: {}", config_path); } - - + // Log startup timestamp - info!("🕒 Started at: {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")); + info!( + "🕒 Started at: {}", + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC") + ); // log docs url info!("ℹ️ Visit the Relayer documentation for more information https://docs.openzeppelin.com/relayer/"); -} \ No newline at end of file +} From 90facb59d17dded94bd7edc958a960748aeb163c Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 14 Jul 2025 15:11:04 +0200 Subject: [PATCH 06/59] chore: add notifications crud endpoints --- src/api/controllers/mod.rs | 1 + src/api/controllers/notifications.rs | 151 +++++++++++ src/api/routes/docs/mod.rs | 1 + src/api/routes/docs/notification_docs.rs | 313 +++++++++++++++++++++++ src/api/routes/mod.rs | 6 +- src/api/routes/networks.rs | 1 + src/api/routes/notifications.rs | 67 +++++ src/api/routes/signers.rs | 1 + src/models/notification/mod.rs | 6 + src/models/notification/repository.rs | 3 +- src/models/notification/request.rs | 163 ++++++++++++ src/models/notification/response.rs | 64 +++++ 12 files changed, 775 insertions(+), 2 deletions(-) create mode 100644 src/api/controllers/notifications.rs create mode 100644 src/api/routes/docs/notification_docs.rs create mode 100644 src/api/routes/networks.rs create mode 100644 src/api/routes/notifications.rs create mode 100644 src/api/routes/signers.rs create mode 100644 src/models/notification/request.rs create mode 100644 src/models/notification/response.rs diff --git a/src/api/controllers/mod.rs b/src/api/controllers/mod.rs index 174551326..0639f5215 100644 --- a/src/api/controllers/mod.rs +++ b/src/api/controllers/mod.rs @@ -7,5 +7,6 @@ //! * `relayer` - Transaction and relayer management endpoints //! * `plugin` - Plugin endpoints +pub mod notifications; pub mod plugin; pub mod relayer; diff --git a/src/api/controllers/notifications.rs b/src/api/controllers/notifications.rs new file mode 100644 index 000000000..4c7298723 --- /dev/null +++ b/src/api/controllers/notifications.rs @@ -0,0 +1,151 @@ +//! # Notifications Controller +//! +//! Handles HTTP endpoints for notification operations including: +//! - Listing notifications +//! - Getting notification details +//! - Creating notifications +//! - Updating notifications +//! - Deleting notifications + +use crate::{ + models::{ + ApiError, ApiResponse, DefaultAppState, NotificationCreateRequest, NotificationRepoModel, + NotificationResponse, NotificationUpdateRequest, PaginationMeta, PaginationQuery, + }, + repositories::Repository, +}; +use actix_web::{web, HttpResponse}; +use eyre::Result; + +/// Lists all notifications with pagination support. +/// +/// # Arguments +/// +/// * `query` - The pagination query parameters. +/// * `state` - The application state containing the notification repository. +/// +/// # Returns +/// +/// A paginated list of notifications. +pub async fn list_notifications( + query: PaginationQuery, + state: web::ThinData, +) -> Result { + let notifications = state.notification_repository.list_paginated(query).await?; + + let mapped_notifications: Vec = + notifications.items.into_iter().map(|n| n.into()).collect(); + + Ok(HttpResponse::Ok().json(ApiResponse::paginated( + mapped_notifications, + PaginationMeta { + total_items: notifications.total, + current_page: notifications.page, + per_page: notifications.per_page, + }, + ))) +} + +/// Retrieves details of a specific notification by ID. +/// +/// # Arguments +/// +/// * `notification_id` - The ID of the notification to retrieve. +/// * `state` - The application state containing the notification repository. +/// +/// # Returns +/// +/// The notification details or an error if not found. +pub async fn get_notification( + notification_id: String, + state: web::ThinData, +) -> Result { + let notification = state + .notification_repository + .get_by_id(notification_id) + .await?; + + let response = NotificationResponse::from(notification); + Ok(HttpResponse::Ok().json(ApiResponse::success(response))) +} + +/// Creates a new notification. +/// +/// # Arguments +/// +/// * `request` - The notification creation request. +/// * `state` - The application state containing the notification repository. +/// +/// # Returns +/// +/// The created notification or an error if creation fails. +pub async fn create_notification( + request: NotificationCreateRequest, + state: web::ThinData, +) -> Result { + let notification_model = NotificationRepoModel::from(request); + let created_notification = state + .notification_repository + .create(notification_model) + .await?; + + let response = NotificationResponse::from(created_notification); + Ok(HttpResponse::Created().json(ApiResponse::success(response))) +} + +/// Updates an existing notification. +/// +/// # Arguments +/// +/// * `notification_id` - The ID of the notification to update. +/// * `request` - The notification update request. +/// * `state` - The application state containing the notification repository. +/// +/// # Returns +/// +/// The updated notification or an error if update fails. +pub async fn update_notification( + notification_id: String, + request: NotificationUpdateRequest, + state: web::ThinData, +) -> Result { + // Get the existing notification + let existing_notification = state + .notification_repository + .get_by_id(notification_id.clone()) + .await?; + + // Apply the update to the existing notification + let updated_notification = request.apply_to(existing_notification); + + // Save the updated notification + let saved_notification = state + .notification_repository + .update(notification_id, updated_notification) + .await?; + + let response = NotificationResponse::from(saved_notification); + Ok(HttpResponse::Ok().json(ApiResponse::success(response))) +} + +/// Deletes a notification by ID. +/// +/// # Arguments +/// +/// * `notification_id` - The ID of the notification to delete. +/// * `state` - The application state containing the notification repository. +/// +/// # Returns +/// +/// A success response or an error if deletion fails. +pub async fn delete_notification( + notification_id: String, + state: web::ThinData, +) -> Result { + state + .notification_repository + .delete_by_id(notification_id) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::success("Notification deleted successfully"))) +} diff --git a/src/api/routes/docs/mod.rs b/src/api/routes/docs/mod.rs index c4bcbcf86..4cd713eae 100644 --- a/src/api/routes/docs/mod.rs +++ b/src/api/routes/docs/mod.rs @@ -1,2 +1,3 @@ +pub mod notification_docs; pub mod plugin_docs; pub mod relayer_docs; diff --git a/src/api/routes/docs/notification_docs.rs b/src/api/routes/docs/notification_docs.rs new file mode 100644 index 000000000..2744eefa3 --- /dev/null +++ b/src/api/routes/docs/notification_docs.rs @@ -0,0 +1,313 @@ +use crate::models::{ + ApiResponse, NotificationCreateRequest, NotificationResponse, NotificationUpdateRequest, +}; +use serde_json::json; + +/// Notification routes implementation +/// +/// Note: OpenAPI documentation for these endpoints can be found in the `openapi.rs` file +/// +/// Lists all notifications with pagination support. +#[utoipa::path( + get, + path = "/api/v1/notifications", + tag = "Notifications", + operation_id = "listNotifications", + security( + ("bearer_auth" = []) + ), + params( + ("page" = Option, Query, description = "Page number for pagination (starts at 1)"), + ("per_page" = Option, Query, description = "Number of items per page (default: 10)") + ), + responses( + ( + status = 200, + description = "Notification list retrieved successfully", + body = ApiResponse> + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +fn doc_list_notifications() {} + +/// Retrieves details of a specific notification by ID. +#[utoipa::path( + get, + path = "/api/v1/notifications/{notification_id}", + tag = "Notifications", + operation_id = "getNotification", + security( + ("bearer_auth" = []) + ), + params( + ("notification_id" = String, Path, description = "Notification ID") + ), + responses( + ( + status = 200, + description = "Notification retrieved successfully", + body = ApiResponse + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 404, + description = "Notification not found", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Notification not found", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +fn doc_get_notification() {} + +/// Creates a new notification. +#[utoipa::path( + post, + path = "/api/v1/notifications", + tag = "Notifications", + operation_id = "createNotification", + security( + ("bearer_auth" = []) + ), + request_body = NotificationCreateRequest, + responses( + ( + status = 201, + description = "Notification created successfully", + body = ApiResponse + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 409, + description = "Notification with this ID already exists", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Notification with this ID already exists", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +fn doc_create_notification() {} + +/// Updates an existing notification. +#[utoipa::path( + patch, + path = "/api/v1/notifications/{notification_id}", + tag = "Notifications", + operation_id = "updateNotification", + security( + ("bearer_auth" = []) + ), + params( + ("notification_id" = String, Path, description = "Notification ID") + ), + request_body = NotificationUpdateRequest, + responses( + ( + status = 200, + description = "Notification updated successfully", + body = ApiResponse + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 404, + description = "Notification not found", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Notification not found", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +fn doc_update_notification() {} + +/// Deletes a notification by ID. +#[utoipa::path( + delete, + path = "/api/v1/notifications/{notification_id}", + tag = "Notifications", + operation_id = "deleteNotification", + security( + ("bearer_auth" = []) + ), + params( + ("notification_id" = String, Path, description = "Notification ID") + ), + responses( + ( + status = 200, + description = "Notification deleted successfully", + body = ApiResponse, + example = json!({ + "success": true, + "message": "Notification deleted successfully", + "data": "Notification deleted successfully" + }) + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 404, + description = "Notification not found", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Notification not found", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +fn doc_delete_notification() {} diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs index 1f697e56b..7b126b5ac 100644 --- a/src/api/routes/mod.rs +++ b/src/api/routes/mod.rs @@ -10,13 +10,17 @@ pub mod docs; pub mod health; pub mod metrics; +pub mod networks; +pub mod notifications; pub mod plugin; pub mod relayer; +pub mod signers; use actix_web::web; pub fn configure_routes(cfg: &mut web::ServiceConfig) { cfg.configure(health::init) .configure(relayer::init) .configure(plugin::init) - .configure(metrics::init); + .configure(metrics::init) + .configure(notifications::init); } diff --git a/src/api/routes/networks.rs b/src/api/routes/networks.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/api/routes/networks.rs @@ -0,0 +1 @@ + diff --git a/src/api/routes/notifications.rs b/src/api/routes/notifications.rs new file mode 100644 index 000000000..86755590c --- /dev/null +++ b/src/api/routes/notifications.rs @@ -0,0 +1,67 @@ +//! This module defines the HTTP routes for notification operations. +//! It includes handlers for listing, retrieving, creating, updating, and deleting notifications. +//! The routes are integrated with the Actix-web framework and interact with the notification controller. + +use crate::{ + api::controllers::notifications, + models::{ + DefaultAppState, NotificationCreateRequest, NotificationUpdateRequest, PaginationQuery, + }, +}; +use actix_web::{delete, get, patch, post, web, Responder}; + +/// Lists all notifications with pagination support. +#[get("/notifications")] +async fn list_notifications( + query: web::Query, + data: web::ThinData, +) -> impl Responder { + notifications::list_notifications(query.into_inner(), data).await +} + +/// Retrieves details of a specific notification by ID. +#[get("/notifications/{notification_id}")] +async fn get_notification( + notification_id: web::Path, + data: web::ThinData, +) -> impl Responder { + notifications::get_notification(notification_id.into_inner(), data).await +} + +/// Creates a new notification. +#[post("/notifications")] +async fn create_notification( + request: web::Json, + data: web::ThinData, +) -> impl Responder { + notifications::create_notification(request.into_inner(), data).await +} + +/// Updates an existing notification. +#[patch("/notifications/{notification_id}")] +async fn update_notification( + notification_id: web::Path, + request: web::Json, + data: web::ThinData, +) -> impl Responder { + notifications::update_notification(notification_id.into_inner(), request.into_inner(), data) + .await +} + +/// Deletes a notification by ID. +#[delete("/notifications/{notification_id}")] +async fn delete_notification( + notification_id: web::Path, + data: web::ThinData, +) -> impl Responder { + notifications::delete_notification(notification_id.into_inner(), data).await +} + +/// Configures the notification routes. +pub fn init(cfg: &mut web::ServiceConfig) { + cfg.service(list_notifications) + .service(get_notification) + .service(create_notification) + .service(update_notification) + .service(delete_notification); +} diff --git a/src/api/routes/signers.rs b/src/api/routes/signers.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/api/routes/signers.rs @@ -0,0 +1 @@ + diff --git a/src/models/notification/mod.rs b/src/models/notification/mod.rs index 7efd6b577..fcc4395f5 100644 --- a/src/models/notification/mod.rs +++ b/src/models/notification/mod.rs @@ -3,3 +3,9 @@ pub use webhook_notification::*; mod repository; pub use repository::*; + +mod response; +pub use response::*; + +mod request; +pub use request::*; diff --git a/src/models/notification/repository.rs b/src/models/notification/repository.rs index 052574834..656537648 100644 --- a/src/models/notification/repository.rs +++ b/src/models/notification/repository.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use crate::models::SecretString; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] #[serde(rename_all = "lowercase")] pub enum NotificationType { Webhook, diff --git a/src/models/notification/request.rs b/src/models/notification/request.rs new file mode 100644 index 000000000..9b50a986a --- /dev/null +++ b/src/models/notification/request.rs @@ -0,0 +1,163 @@ +use crate::models::{NotificationRepoModel, NotificationType, SecretString}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Request structure for creating a new notification +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +pub struct NotificationCreateRequest { + pub id: String, + pub r#type: NotificationType, + pub url: String, + /// Optional signing key for securing webhook notifications + pub signing_key: Option, +} + +/// Request structure for updating an existing notification +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +pub struct NotificationUpdateRequest { + pub r#type: Option, + pub url: Option, + /// Optional signing key for securing webhook notifications. + /// - None: don't change the existing signing key + /// - Some(""): remove the signing key + /// - Some("key"): set the signing key to the provided value + pub signing_key: Option, +} + +impl From for NotificationRepoModel { + fn from(request: NotificationCreateRequest) -> Self { + Self { + id: request.id, + notification_type: request.r#type, + url: request.url, + signing_key: request.signing_key.map(|s| SecretString::new(&s)), + } + } +} + +impl NotificationUpdateRequest { + /// Applies the update request to an existing notification model + pub fn apply_to(&self, mut model: NotificationRepoModel) -> NotificationRepoModel { + if let Some(notification_type) = &self.r#type { + model.notification_type = notification_type.clone(); + } + if let Some(url) = &self.url { + model.url = url.clone(); + } + if let Some(signing_key) = &self.signing_key { + if signing_key.is_empty() { + // Empty string means remove the signing key + model.signing_key = None; + } else { + // Non-empty string means update the signing key + model.signing_key = Some(SecretString::new(signing_key)); + } + } + model + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_notification_create_request() { + let request = NotificationCreateRequest { + id: "test-id".to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some("secret-key".to_string()), + }; + + let model = NotificationRepoModel::from(request); + + assert_eq!(model.id, "test-id"); + assert_eq!(model.notification_type, NotificationType::Webhook); + assert_eq!(model.url, "https://example.com/webhook"); + assert!(model.signing_key.is_some()); + } + + #[test] + fn test_from_notification_create_request_without_signing_key() { + let request = NotificationCreateRequest { + id: "test-id".to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: None, + }; + + let model = NotificationRepoModel::from(request); + + assert_eq!(model.id, "test-id"); + assert_eq!(model.notification_type, NotificationType::Webhook); + assert_eq!(model.url, "https://example.com/webhook"); + assert!(model.signing_key.is_none()); + } + + #[test] + fn test_notification_update_request_apply_to() { + let original_model = NotificationRepoModel { + id: "test-id".to_string(), + notification_type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some(SecretString::new("old-key")), + }; + + let update_request = NotificationUpdateRequest { + r#type: None, + url: Some("https://new-example.com/webhook".to_string()), + signing_key: Some("new-key".to_string()), + }; + + let updated_model = update_request.apply_to(original_model); + + assert_eq!(updated_model.id, "test-id"); + assert_eq!(updated_model.notification_type, NotificationType::Webhook); + assert_eq!(updated_model.url, "https://new-example.com/webhook"); + assert!(updated_model.signing_key.is_some()); + } + + #[test] + fn test_notification_update_request_partial_update() { + let original_model = NotificationRepoModel { + id: "test-id".to_string(), + notification_type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some(SecretString::new("old-key")), + }; + + let update_request = NotificationUpdateRequest { + r#type: None, + url: Some("https://new-example.com/webhook".to_string()), + signing_key: None, + }; + + let updated_model = update_request.apply_to(original_model); + + assert_eq!(updated_model.id, "test-id"); + assert_eq!(updated_model.notification_type, NotificationType::Webhook); + assert_eq!(updated_model.url, "https://new-example.com/webhook"); + assert!(updated_model.signing_key.is_some()); // Should remain unchanged + } + + #[test] + fn test_notification_update_request_remove_signing_key() { + let mut model = NotificationRepoModel { + id: "test-id".to_string(), + notification_type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some(SecretString::new("existing-key")), + }; + + let update_request = NotificationUpdateRequest { + r#type: None, + url: None, + signing_key: Some("".to_string()), // Empty string to remove + }; + + model = update_request.apply_to(model); + + assert_eq!(model.signing_key, None); + } +} diff --git a/src/models/notification/response.rs b/src/models/notification/response.rs new file mode 100644 index 000000000..71e92e293 --- /dev/null +++ b/src/models/notification/response.rs @@ -0,0 +1,64 @@ +use crate::models::{NotificationRepoModel, NotificationType}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Response structure for notification API endpoints +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +pub struct NotificationResponse { + pub id: String, + pub r#type: NotificationType, + pub url: String, + /// Signing key is hidden in responses for security + pub has_signing_key: bool, +} + +impl From for NotificationResponse { + fn from(model: NotificationRepoModel) -> Self { + Self { + id: model.id, + r#type: model.notification_type, + url: model.url, + has_signing_key: model.signing_key.is_some(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::SecretString; + + #[test] + fn test_from_notification_repo_model() { + let model = NotificationRepoModel { + id: "test-id".to_string(), + notification_type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some(SecretString::new("secret-key")), + }; + + let response = NotificationResponse::from(model); + + assert_eq!(response.id, "test-id"); + assert_eq!(response.r#type, NotificationType::Webhook); + assert_eq!(response.url, "https://example.com/webhook"); + assert_eq!(response.has_signing_key, true); + } + + #[test] + fn test_from_notification_repo_model_without_signing_key() { + let model = NotificationRepoModel { + id: "test-id".to_string(), + notification_type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: None, + }; + + let response = NotificationResponse::from(model); + + assert_eq!(response.id, "test-id"); + assert_eq!(response.r#type, NotificationType::Webhook); + assert_eq!(response.url, "https://example.com/webhook"); + assert_eq!(response.has_signing_key, false); + } +} From 835801dcfd35d9f1fc8cae395802b8cd16acc8cc Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 14 Jul 2025 23:35:34 +0200 Subject: [PATCH 07/59] chore: add notification request validations --- src/api/controllers/notifications.rs | 454 +++++++++++++++++++++++++-- src/constants/validation.rs | 9 + src/models/notification/request.rs | 174 +++++++++- 3 files changed, 605 insertions(+), 32 deletions(-) diff --git a/src/api/controllers/notifications.rs b/src/api/controllers/notifications.rs index 4c7298723..50bfc0fe2 100644 --- a/src/api/controllers/notifications.rs +++ b/src/api/controllers/notifications.rs @@ -8,13 +8,18 @@ //! - Deleting notifications use crate::{ + jobs::JobProducerTrait, models::{ - ApiError, ApiResponse, DefaultAppState, NotificationCreateRequest, NotificationRepoModel, + ApiError, ApiResponse, NetworkRepoModel, NotificationCreateRequest, NotificationRepoModel, NotificationResponse, NotificationUpdateRequest, PaginationMeta, PaginationQuery, + RelayerRepoModel, SignerRepoModel, ThinDataAppState, TransactionRepoModel, + }, + repositories::{ + NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, + TransactionCounterTrait, TransactionRepository, }, - repositories::Repository, }; -use actix_web::{web, HttpResponse}; +use actix_web::HttpResponse; use eyre::Result; /// Lists all notifications with pagination support. @@ -27,10 +32,20 @@ use eyre::Result; /// # Returns /// /// A paginated list of notifications. -pub async fn list_notifications( +pub async fn list_notifications( query: PaginationQuery, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let notifications = state.notification_repository.list_paginated(query).await?; let mapped_notifications: Vec = @@ -56,10 +71,20 @@ pub async fn list_notifications( /// # Returns /// /// The notification details or an error if not found. -pub async fn get_notification( +pub async fn get_notification( notification_id: String, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let notification = state .notification_repository .get_by_id(notification_id) @@ -79,10 +104,23 @@ pub async fn get_notification( /// # Returns /// /// The created notification or an error if creation fails. -pub async fn create_notification( +pub async fn create_notification( request: NotificationCreateRequest, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ + // Validate the request + request.validate()?; + let notification_model = NotificationRepoModel::from(request); let created_notification = state .notification_repository @@ -104,11 +142,24 @@ pub async fn create_notification( /// # Returns /// /// The updated notification or an error if update fails. -pub async fn update_notification( +pub async fn update_notification( notification_id: String, request: NotificationUpdateRequest, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ + // Validate the request + request.validate()?; + // Get the existing notification let existing_notification = state .notification_repository @@ -138,10 +189,20 @@ pub async fn update_notification( /// # Returns /// /// A success response or an error if deletion fails. -pub async fn delete_notification( +pub async fn delete_notification( notification_id: String, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ state .notification_repository .delete_by_id(notification_id) @@ -149,3 +210,360 @@ pub async fn delete_notification( Ok(HttpResponse::Ok().json(ApiResponse::success("Notification deleted successfully"))) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + models::{ + ApiError, NotificationType, SecretString, + }, + utils::mocks::mockutils::create_mock_app_state, + }; + use actix_web::web::ThinData; + + /// Helper function to create a test notification model + fn create_test_notification_model(id: &str) -> NotificationRepoModel { + NotificationRepoModel { + id: id.to_string(), + notification_type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some(SecretString::new("a".repeat(32).as_str())), // 32 chars minimum + } + } + + /// Helper function to create a test notification create request + fn create_test_notification_create_request(id: &str) -> NotificationCreateRequest { + NotificationCreateRequest { + id: id.to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some("a".repeat(32)), // 32 chars minimum + } + } + + /// Helper function to create a test notification update request + fn create_test_notification_update_request() -> NotificationUpdateRequest { + NotificationUpdateRequest { + r#type: Some(NotificationType::Webhook), + url: Some("https://updated.example.com/webhook".to_string()), + signing_key: Some("b".repeat(32)), // 32 chars minimum + } + } + + #[actix_web::test] + async fn test_list_notifications_empty() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = list_notifications(query, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_list_notifications_with_data() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create test notifications + let notification1 = create_test_notification_model("test-1"); + let notification2 = create_test_notification_model("test-2"); + + app_state.notification_repository.create(notification1).await.unwrap(); + app_state.notification_repository.create(notification2).await.unwrap(); + + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = list_notifications(query, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_list_notifications_pagination() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create multiple test notifications + for i in 1..=5 { + let notification = create_test_notification_model(&format!("test-{}", i)); + app_state.notification_repository.create(notification).await.unwrap(); + } + + let query = PaginationQuery { + page: 2, + per_page: 2, + }; + + let result = list_notifications(query, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_get_notification_success() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test notification + let notification = create_test_notification_model("test-notification"); + app_state.notification_repository.create(notification.clone()).await.unwrap(); + + let result = get_notification("test-notification".to_string(), ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_get_notification_not_found() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let result = get_notification("non-existent".to_string(), ThinData(app_state)).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(matches!(error, ApiError::NotFound(_))); + } + + #[actix_web::test] + async fn test_create_notification_success() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = create_test_notification_create_request("new-notification"); + + let result = create_notification(request, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + } + + #[actix_web::test] + async fn test_create_notification_without_signing_key() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = NotificationCreateRequest { + id: "new-notification".to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: None, + }; + + let result = create_notification(request, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + } + + #[actix_web::test] + async fn test_update_notification_not_supported() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test notification + let notification = create_test_notification_model("test-notification"); + app_state.notification_repository.create(notification).await.unwrap(); + + let update_request = create_test_notification_update_request(); + + let result = update_notification( + "test-notification".to_string(), + update_request, + ThinData(app_state), + ).await; + + // In-memory repository doesn't support update operations + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(matches!(error, ApiError::InternalError(_))); + } + + #[actix_web::test] + async fn test_update_notification_not_found() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let update_request = create_test_notification_update_request(); + + let result = update_notification( + "non-existent".to_string(), + update_request, + ThinData(app_state), + ).await; + + // Even for non-existent items, in-memory repo returns NotSupported for update + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(matches!(error, ApiError::NotFound(_))); + } + + #[actix_web::test] + async fn test_delete_notification_not_supported() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test notification + let notification = create_test_notification_model("test-notification"); + app_state.notification_repository.create(notification).await.unwrap(); + + let result = delete_notification("test-notification".to_string(), ThinData(app_state)).await; + + // In-memory repository doesn't support delete operations + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(matches!(error, ApiError::InternalError(_))); + } + + #[actix_web::test] + async fn test_delete_notification_not_found() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let result = delete_notification("non-existent".to_string(), ThinData(app_state)).await; + + // Even for non-existent items, in-memory repo returns NotSupported for delete + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(matches!(error, ApiError::InternalError(_))); + } + + #[actix_web::test] + async fn test_notification_response_conversion() { + let notification_model = NotificationRepoModel { + id: "test-id".to_string(), + notification_type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some(SecretString::new("secret-key")), + }; + + let response = NotificationResponse::from(notification_model); + + assert_eq!(response.id, "test-id"); + assert_eq!(response.r#type, NotificationType::Webhook); + assert_eq!(response.url, "https://example.com/webhook"); + assert!(response.has_signing_key); + } + + #[actix_web::test] + async fn test_notification_response_conversion_without_signing_key() { + let notification_model = NotificationRepoModel { + id: "test-id".to_string(), + notification_type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: None, + }; + + let response = NotificationResponse::from(notification_model); + + assert_eq!(response.id, "test-id"); + assert_eq!(response.r#type, NotificationType::Webhook); + assert_eq!(response.url, "https://example.com/webhook"); + assert!(!response.has_signing_key); + } + + #[actix_web::test] + async fn test_create_notification_validates_repository_creation() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = create_test_notification_create_request("new-notification"); + let result = create_notification(request, ThinData(app_state)).await; + + assert!(result.is_ok()); + } + + #[actix_web::test] + async fn test_update_notification_validates_repository_update() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test notification + let notification = create_test_notification_model("test-notification"); + app_state.notification_repository.create(notification).await.unwrap(); + + let update_request = create_test_notification_update_request(); + let result = update_notification( + "test-notification".to_string(), + update_request, + ThinData(app_state), + ).await; + + // In-memory repository doesn't support update operations + assert!(result.is_err()); + } + + #[actix_web::test] + async fn test_delete_notification_validates_repository_deletion() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test notification + let notification = create_test_notification_model("test-notification"); + app_state.notification_repository.create(notification).await.unwrap(); + + let result = delete_notification("test-notification".to_string(), ThinData(app_state)).await; + + // In-memory repository doesn't support delete operations + assert!(result.is_err()); + } + + #[actix_web::test] + async fn test_create_notification_validation_error() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a request with invalid data + let request = NotificationCreateRequest { + id: "invalid@id".to_string(), // Invalid characters + r#type: NotificationType::Webhook, + url: "not-a-url".to_string(), // Invalid URL + signing_key: Some("short".to_string()), // Too short + }; + + let result = create_notification(request, ThinData(app_state)).await; + + // Should fail with validation error + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("ID must contain only letters, numbers, dashes and underscores")); + assert!(msg.contains("Invalid URL format")); + } else { + panic!("Expected BadRequest error with validation messages"); + } + } + + #[actix_web::test] + async fn test_update_notification_validation_error() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test notification + let notification = create_test_notification_model("test-notification"); + app_state.notification_repository.create(notification).await.unwrap(); + + // Create an update request with invalid data + let update_request = NotificationUpdateRequest { + r#type: Some(NotificationType::Webhook), + url: Some("not-a-url".to_string()), // Invalid URL + signing_key: Some("short".to_string()), // Too short + }; + + let result = update_notification( + "test-notification".to_string(), + update_request, + ThinData(app_state), + ).await; + + // Should fail with validation error + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Invalid URL format")); + } else { + panic!("Expected BadRequest error with validation messages"); + } + } +} diff --git a/src/constants/validation.rs b/src/constants/validation.rs index ec8e8fa4f..ec01fd884 100644 --- a/src/constants/validation.rs +++ b/src/constants/validation.rs @@ -1 +1,10 @@ +use lazy_static::lazy_static; +use regex::Regex; + pub const MINIMUM_SECRET_VALUE_LENGTH: usize = 32; + + +// Regex for validating notification IDs +lazy_static! { + pub static ref ID_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9-_]+$").unwrap(); +} \ No newline at end of file diff --git a/src/models/notification/request.rs b/src/models/notification/request.rs index 9b50a986a..15b3dc074 100644 --- a/src/models/notification/request.rs +++ b/src/models/notification/request.rs @@ -1,43 +1,96 @@ -use crate::models::{NotificationRepoModel, NotificationType, SecretString}; +use crate::{ + constants::{ID_REGEX, MINIMUM_SECRET_VALUE_LENGTH}, + models::{ApiError, NotificationRepoModel, NotificationType, SecretString}, +}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; - +use validator::{Validate, ValidationError}; /// Request structure for creating a new notification -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Validate)] pub struct NotificationCreateRequest { + #[validate( + length(min = 1, max = 36, message = "ID must be between 1 and 36 characters"), + regex( + path = "*ID_REGEX", + message = "ID must contain only letters, numbers, dashes and underscores" + ) + )] pub id: String, pub r#type: NotificationType, + #[validate(url(message = "Invalid URL format"))] pub url: String, /// Optional signing key for securing webhook notifications + #[validate(custom(function = "validate_signing_key"))] pub signing_key: Option, } /// Request structure for updating an existing notification -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Validate)] pub struct NotificationUpdateRequest { pub r#type: Option, + #[validate(url(message = "Invalid URL format"))] pub url: Option, /// Optional signing key for securing webhook notifications. /// - None: don't change the existing signing key /// - Some(""): remove the signing key /// - Some("key"): set the signing key to the provided value + #[validate(custom(function = "validate_optional_signing_key"))] pub signing_key: Option, } +/// Custom validator for signing key in create requests +fn validate_signing_key(signing_key: &String) -> Result<(), ValidationError> { + if !signing_key.is_empty() && signing_key.len() < MINIMUM_SECRET_VALUE_LENGTH { + return Err(ValidationError::new("signing_key_too_short")); + } + Ok(()) +} -impl From for NotificationRepoModel { - fn from(request: NotificationCreateRequest) -> Self { - Self { - id: request.id, - notification_type: request.r#type, - url: request.url, - signing_key: request.signing_key.map(|s| SecretString::new(&s)), - } +/// Custom validator for optional signing key in update requests +fn validate_optional_signing_key(signing_key: &String) -> Result<(), ValidationError> { + // Allow empty string (means remove the key) + if !signing_key.is_empty() && signing_key.len() < MINIMUM_SECRET_VALUE_LENGTH { + return Err(ValidationError::new("signing_key_too_short")); + } + Ok(()) +} + +impl NotificationCreateRequest { + /// Validates the create request + pub fn validate(&self) -> Result<(), ApiError> { + Validate::validate(self).map_err(|e| { + let error_messages: Vec = e + .field_errors() + .iter() + .flat_map(|(field, errors)| { + errors.iter().map(move |error| { + format!("{}: {}", field, error.message.as_ref().unwrap_or(&"Invalid value".into())) + }) + }) + .collect(); + ApiError::BadRequest(error_messages.join(", ")) + }) } } impl NotificationUpdateRequest { - /// Applies the update request to an existing notification model - pub fn apply_to(&self, mut model: NotificationRepoModel) -> NotificationRepoModel { + /// Validates the update request + pub fn validate(&self) -> Result<(), ApiError> { + Validate::validate(self).map_err(|e| { + let error_messages: Vec = e + .field_errors() + .iter() + .flat_map(|(field, errors)| { + errors.iter().map(move |error| { + format!("{}: {}", field, error.message.as_ref().unwrap_or(&"Invalid value".into())) + }) + }) + .collect(); + ApiError::BadRequest(error_messages.join(", ")) + }) + } + + /// Applies the update request to an existing notification model + pub fn apply_to(&self, mut model: NotificationRepoModel) -> NotificationRepoModel { if let Some(notification_type) = &self.r#type { model.notification_type = notification_type.clone(); } @@ -57,10 +110,103 @@ impl NotificationUpdateRequest { } } +impl From for NotificationRepoModel { + fn from(request: NotificationCreateRequest) -> Self { + Self { + id: request.id, + notification_type: request.r#type, + url: request.url, + signing_key: request.signing_key.map(|s| SecretString::new(&s)), + } + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_valid_create_request() { + let request = NotificationCreateRequest { + id: "test-notification".to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some("a".repeat(32)), // Minimum length + }; + + assert!(request.validate().is_ok()); + } + + #[test] + fn test_invalid_id_too_long() { + let request = NotificationCreateRequest { + id: "a".repeat(37), // Too long + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: None, + }; + + assert!(request.validate().is_err()); + } + + #[test] + fn test_invalid_id_format() { + let request = NotificationCreateRequest { + id: "invalid@id".to_string(), // Invalid characters + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: None, + }; + + assert!(request.validate().is_err()); + } + + #[test] + fn test_invalid_url_format() { + let request = NotificationCreateRequest { + id: "test-notification".to_string(), + r#type: NotificationType::Webhook, + url: "not-a-url".to_string(), + signing_key: None, + }; + + assert!(request.validate().is_err()); + } + + #[test] + fn test_signing_key_too_short() { + let request = NotificationCreateRequest { + id: "test-notification".to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some("short".to_string()), // Too short + }; + + assert!(request.validate().is_err()); + } + + #[test] + fn test_valid_update_request() { + let request = NotificationUpdateRequest { + r#type: Some(NotificationType::Webhook), + url: Some("https://updated.example.com/webhook".to_string()), + signing_key: Some("a".repeat(32)), // Minimum length + }; + + assert!(request.validate().is_ok()); + } + + #[test] + fn test_update_request_empty_signing_key() { + let request = NotificationUpdateRequest { + r#type: None, + url: None, + signing_key: Some("".to_string()), // Empty string to remove key + }; + + assert!(request.validate().is_ok()); + } + #[test] fn test_from_notification_create_request() { let request = NotificationCreateRequest { From d474cba7daa6e834dff4ff9115890110cfe12c20 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 15 Jul 2025 11:28:50 +0200 Subject: [PATCH 08/59] chore: initial work for notificaitons crud --- src/api/controllers/notifications.rs | 317 ++++++++++++---- src/api/routes/docs/notification_docs.rs | 1 - src/bootstrap/config_processor.rs | 25 +- src/config/config_file/mod.rs | 30 +- src/config/config_file/notification.rs | 349 ----------------- src/constants/validation.rs | 5 +- src/models/api_response.rs | 6 +- src/models/notification/config.rs | 350 +++++++++++++++++ src/models/notification/core.rs | 359 ++++++++++++++++++ src/models/notification/mod.rs | 21 +- src/models/notification/repository.rs | 92 ++++- src/models/notification/request.rs | 337 ++++++---------- src/models/secret_string.rs | 23 ++ src/repositories/notification/mod.rs | 95 ++--- .../notification/notification_in_memory.rs | 176 +++++++-- 15 files changed, 1403 insertions(+), 783 deletions(-) delete mode 100644 src/config/config_file/notification.rs create mode 100644 src/models/notification/config.rs create mode 100644 src/models/notification/core.rs diff --git a/src/api/controllers/notifications.rs b/src/api/controllers/notifications.rs index 50bfc0fe2..b502b577b 100644 --- a/src/api/controllers/notifications.rs +++ b/src/api/controllers/notifications.rs @@ -10,9 +10,9 @@ use crate::{ jobs::JobProducerTrait, models::{ - ApiError, ApiResponse, NetworkRepoModel, NotificationCreateRequest, NotificationRepoModel, - NotificationResponse, NotificationUpdateRequest, PaginationMeta, PaginationQuery, - RelayerRepoModel, SignerRepoModel, ThinDataAppState, TransactionRepoModel, + ApiError, ApiResponse, NetworkRepoModel, Notification, NotificationCreateRequest, + NotificationRepoModel, NotificationResponse, NotificationUpdateRequest, PaginationMeta, + PaginationQuery, RelayerRepoModel, SignerRepoModel, ThinDataAppState, TransactionRepoModel, }, repositories::{ NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, @@ -118,10 +118,11 @@ where TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, { - // Validate the request - request.validate()?; + // Convert request to core notification (validates automatically) + let notification = Notification::try_from(request)?; - let notification_model = NotificationRepoModel::from(request); + // Convert to repository model + let notification_model = NotificationRepoModel::from(notification); let created_notification = state .notification_repository .create(notification_model) @@ -157,22 +158,23 @@ where TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, { - // Validate the request - request.validate()?; - - // Get the existing notification - let existing_notification = state + // Get the existing notification from repository + let existing_repo_model = state .notification_repository .get_by_id(notification_id.clone()) .await?; - // Apply the update to the existing notification - let updated_notification = request.apply_to(existing_notification); + // Convert to core domain model + let existing_core = Notification::from(existing_repo_model); + + // Apply update using domain-first approach (with validation) + let updated_core = existing_core.apply_update(&request)?; - // Save the updated notification + // Convert back to repo model and save + let updated_repo_model = NotificationRepoModel::from(updated_core); let saved_notification = state .notification_repository - .update(notification_id, updated_notification) + .update(notification_id, updated_repo_model) .await?; let response = NotificationResponse::from(saved_notification); @@ -215,9 +217,7 @@ where mod tests { use super::*; use crate::{ - models::{ - ApiError, NotificationType, SecretString, - }, + models::{ApiError, NotificationType, SecretString}, utils::mocks::mockutils::create_mock_app_state, }; use actix_web::web::ThinData; @@ -264,18 +264,36 @@ mod tests { assert!(result.is_ok()); let response = result.unwrap(); assert_eq!(response.status(), 200); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse> = + serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.len(), 0); } #[actix_web::test] async fn test_list_notifications_with_data() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + // Create test notifications let notification1 = create_test_notification_model("test-1"); let notification2 = create_test_notification_model("test-2"); - - app_state.notification_repository.create(notification1).await.unwrap(); - app_state.notification_repository.create(notification2).await.unwrap(); + + app_state + .notification_repository + .create(notification1) + .await + .unwrap(); + app_state + .notification_repository + .create(notification2) + .await + .unwrap(); let query = PaginationQuery { page: 1, @@ -287,16 +305,35 @@ mod tests { assert!(result.is_ok()); let response = result.unwrap(); assert_eq!(response.status(), 200); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse> = + serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.len(), 2); + + // Check that both notifications are present (order not guaranteed) + let ids: Vec<&String> = data.iter().map(|n| &n.id).collect(); + assert!(ids.contains(&&"test-1".to_string())); + assert!(ids.contains(&&"test-2".to_string())); } #[actix_web::test] async fn test_list_notifications_pagination() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + // Create multiple test notifications for i in 1..=5 { let notification = create_test_notification_model(&format!("test-{}", i)); - app_state.notification_repository.create(notification).await.unwrap(); + app_state + .notification_repository + .create(notification) + .await + .unwrap(); } let query = PaginationQuery { @@ -309,21 +346,52 @@ mod tests { assert!(result.is_ok()); let response = result.unwrap(); assert_eq!(response.status(), 200); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse> = + serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.len(), 2); // Should return 2 items for page 2 with per_page=2 + + // Verify the items are properly sorted (newest first) + assert!(data.iter().all(|n| n.r#type == NotificationType::Webhook)); + assert!(data.iter().all(|n| n.url == "https://example.com/webhook")); } #[actix_web::test] async fn test_get_notification_success() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + // Create a test notification let notification = create_test_notification_model("test-notification"); - app_state.notification_repository.create(notification.clone()).await.unwrap(); + app_state + .notification_repository + .create(notification.clone()) + .await + .unwrap(); let result = get_notification("test-notification".to_string(), ThinData(app_state)).await; assert!(result.is_ok()); let response = result.unwrap(); assert_eq!(response.status(), 200); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = + serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "test-notification"); + assert_eq!(data.r#type, NotificationType::Webhook); + assert_eq!(data.url, "https://example.com/webhook"); + assert!(data.has_signing_key); // Should have signing key (32 chars) } #[actix_web::test] @@ -340,7 +408,7 @@ mod tests { #[actix_web::test] async fn test_create_notification_success() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + let request = create_test_notification_create_request("new-notification"); let result = create_notification(request, ThinData(app_state)).await; @@ -348,12 +416,25 @@ mod tests { assert!(result.is_ok()); let response = result.unwrap(); assert_eq!(response.status(), 201); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = + serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "new-notification"); + assert_eq!(data.r#type, NotificationType::Webhook); + assert_eq!(data.url, "https://example.com/webhook"); + assert!(data.has_signing_key); // Should have signing key (32 chars) } #[actix_web::test] async fn test_create_notification_without_signing_key() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + let request = NotificationCreateRequest { id: "new-notification".to_string(), r#type: NotificationType::Webhook, @@ -366,15 +447,32 @@ mod tests { assert!(result.is_ok()); let response = result.unwrap(); assert_eq!(response.status(), 201); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = + serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "new-notification"); + assert_eq!(data.r#type, NotificationType::Webhook); + assert_eq!(data.url, "https://example.com/webhook"); + assert!(!data.has_signing_key); // Should not have signing key } #[actix_web::test] - async fn test_update_notification_not_supported() { + async fn test_update_notification_success() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + // Create a test notification let notification = create_test_notification_model("test-notification"); - app_state.notification_repository.create(notification).await.unwrap(); + app_state + .notification_repository + .create(notification) + .await + .unwrap(); let update_request = create_test_notification_update_request(); @@ -382,46 +480,73 @@ mod tests { "test-notification".to_string(), update_request, ThinData(app_state), - ).await; + ) + .await; - // In-memory repository doesn't support update operations - assert!(result.is_err()); - let error = result.unwrap_err(); - assert!(matches!(error, ApiError::InternalError(_))); + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = + serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "test-notification"); + assert_eq!(data.url, "https://updated.example.com/webhook"); + assert!(data.has_signing_key); // Should have updated signing key } #[actix_web::test] async fn test_update_notification_not_found() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + let update_request = create_test_notification_update_request(); let result = update_notification( "non-existent".to_string(), update_request, ThinData(app_state), - ).await; + ) + .await; - // Even for non-existent items, in-memory repo returns NotSupported for update assert!(result.is_err()); let error = result.unwrap_err(); assert!(matches!(error, ApiError::NotFound(_))); } #[actix_web::test] - async fn test_delete_notification_not_supported() { + async fn test_delete_notification_success() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + // Create a test notification let notification = create_test_notification_model("test-notification"); - app_state.notification_repository.create(notification).await.unwrap(); + app_state + .notification_repository + .create(notification) + .await + .unwrap(); - let result = delete_notification("test-notification".to_string(), ThinData(app_state)).await; + let result = + delete_notification("test-notification".to_string(), ThinData(app_state)).await; - // In-memory repository doesn't support delete operations - assert!(result.is_err()); - let error = result.unwrap_err(); - assert!(matches!(error, ApiError::InternalError(_))); + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse<&str> = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + assert_eq!( + api_response.data.unwrap(), + "Notification deleted successfully" + ); } #[actix_web::test] @@ -430,10 +555,9 @@ mod tests { let result = delete_notification("non-existent".to_string(), ThinData(app_state)).await; - // Even for non-existent items, in-memory repo returns NotSupported for delete assert!(result.is_err()); let error = result.unwrap_err(); - assert!(matches!(error, ApiError::InternalError(_))); + assert!(matches!(error, ApiError::NotFound(_))); } #[actix_web::test] @@ -473,56 +597,91 @@ mod tests { #[actix_web::test] async fn test_create_notification_validates_repository_creation() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + let app_state_2 = create_mock_app_state(None, None, None, None, None).await; + let request = create_test_notification_create_request("new-notification"); let result = create_notification(request, ThinData(app_state)).await; assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = + serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "new-notification"); + assert_eq!(data.r#type, NotificationType::Webhook); + assert_eq!(data.url, "https://example.com/webhook"); + assert!(data.has_signing_key); + + let request_2 = create_test_notification_create_request("new-notification"); + let result_2 = create_notification(request_2, ThinData(app_state_2)).await; + + assert!(result_2.is_ok()); + let response_2 = result_2.unwrap(); + assert_eq!(response_2.status(), 201); } #[actix_web::test] - async fn test_update_notification_validates_repository_update() { + async fn test_update_notification_repository_integration() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + // Create a test notification let notification = create_test_notification_model("test-notification"); - app_state.notification_repository.create(notification).await.unwrap(); + app_state + .notification_repository + .create(notification) + .await + .unwrap(); let update_request = create_test_notification_update_request(); let result = update_notification( "test-notification".to_string(), update_request, ThinData(app_state), - ).await; + ) + .await; - // In-memory repository doesn't support update operations - assert!(result.is_err()); + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); } #[actix_web::test] - async fn test_delete_notification_validates_repository_deletion() { + async fn test_delete_notification_repository_integration() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + // Create a test notification let notification = create_test_notification_model("test-notification"); - app_state.notification_repository.create(notification).await.unwrap(); + app_state + .notification_repository + .create(notification) + .await + .unwrap(); - let result = delete_notification("test-notification".to_string(), ThinData(app_state)).await; + let result = + delete_notification("test-notification".to_string(), ThinData(app_state)).await; - // In-memory repository doesn't support delete operations - assert!(result.is_err()); + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); } #[actix_web::test] async fn test_create_notification_validation_error() { let app_state = create_mock_app_state(None, None, None, None, None).await; - - // Create a request with invalid data + + // Create a request with only invalid ID to make test deterministic let request = NotificationCreateRequest { id: "invalid@id".to_string(), // Invalid characters r#type: NotificationType::Webhook, - url: "not-a-url".to_string(), // Invalid URL - signing_key: Some("short".to_string()), // Too short + url: "https://valid.example.com/webhook".to_string(), // Valid URL + signing_key: Some("a".repeat(32)), // Valid signing key }; let result = create_notification(request, ThinData(app_state)).await; @@ -530,8 +689,9 @@ mod tests { // Should fail with validation error assert!(result.is_err()); if let Err(ApiError::BadRequest(msg)) = result { + // The validator returns the first validation error it encounters + // In this case, ID validation fails first assert!(msg.contains("ID must contain only letters, numbers, dashes and underscores")); - assert!(msg.contains("Invalid URL format")); } else { panic!("Expected BadRequest error with validation messages"); } @@ -540,28 +700,37 @@ mod tests { #[actix_web::test] async fn test_update_notification_validation_error() { let app_state = create_mock_app_state(None, None, None, None, None).await; - + // Create a test notification let notification = create_test_notification_model("test-notification"); - app_state.notification_repository.create(notification).await.unwrap(); + app_state + .notification_repository + .create(notification) + .await + .unwrap(); - // Create an update request with invalid data + // Create an update request with invalid signing key but valid URL let update_request = NotificationUpdateRequest { r#type: Some(NotificationType::Webhook), - url: Some("not-a-url".to_string()), // Invalid URL - signing_key: Some("short".to_string()), // Too short + url: Some("https://valid.example.com/webhook".to_string()), // Valid URL + signing_key: Some("short".to_string()), // Too short }; let result = update_notification( "test-notification".to_string(), update_request, ThinData(app_state), - ).await; + ) + .await; // Should fail with validation error assert!(result.is_err()); if let Err(ApiError::BadRequest(msg)) = result { - assert!(msg.contains("Invalid URL format")); + // The validator returns the first error it encounters + // In this case, signing key validation fails first + assert!( + msg.contains("Signing key must be at least") && msg.contains("characters long") + ); } else { panic!("Expected BadRequest error with validation messages"); } diff --git a/src/api/routes/docs/notification_docs.rs b/src/api/routes/docs/notification_docs.rs index 2744eefa3..909388974 100644 --- a/src/api/routes/docs/notification_docs.rs +++ b/src/api/routes/docs/notification_docs.rs @@ -1,7 +1,6 @@ use crate::models::{ ApiResponse, NotificationCreateRequest, NotificationResponse, NotificationUpdateRequest, }; -use serde_json::json; /// Notification routes implementation /// diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index fdfeb9e5d..7cca7add3 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -511,13 +511,16 @@ mod tests { use crate::{ config::{ AwsKmsSignerFileConfig, ConfigFileNetworkType, GoogleCloudKmsSignerFileConfig, - KmsKeyConfig, NetworksFileConfig, NotificationFileConfig, PluginFileConfig, - RelayerFileConfig, ServiceAccountConfig, TestSignerFileConfig, VaultSignerFileConfig, + KmsKeyConfig, NetworksFileConfig, PluginFileConfig, RelayerFileConfig, + ServiceAccountConfig, TestSignerFileConfig, VaultSignerFileConfig, VaultTransitSignerFileConfig, }, constants::DEFAULT_PLUGIN_TIMEOUT_SECONDS, jobs::MockJobProducerTrait, - models::{AppState, NetworkType, PlainOrEnvValue, SecretString}, + models::{ + AppState, NetworkType, NotificationConfig, NotificationType, PlainOrEnvValue, + SecretString, + }, repositories::{ InMemoryNetworkRepository, InMemoryNotificationRepository, InMemoryPluginRepository, InMemorySignerRepository, InMemoryTransactionCounter, InMemoryTransactionRepository, @@ -832,15 +835,15 @@ mod tests { async fn test_process_notifications() -> Result<()> { // Create test notifications let notifications = vec![ - NotificationFileConfig { + NotificationConfig { id: "test-notification-1".to_string(), - r#type: crate::config::NotificationFileConfigType::Webhook, + r#type: NotificationType::Webhook, url: "https://hooks.slack.com/test1".to_string(), signing_key: None, }, - NotificationFileConfig { + NotificationConfig { id: "test-notification-2".to_string(), - r#type: crate::config::NotificationFileConfigType::Webhook, + r#type: NotificationType::Webhook, url: "https://hooks.slack.com/test2".to_string(), signing_key: None, }, @@ -1230,9 +1233,9 @@ mod tests { custom_rpc_urls: None, }]; - let notifications = vec![NotificationFileConfig { + let notifications = vec![NotificationConfig { id: "test-notification-1".to_string(), - r#type: crate::config::NotificationFileConfigType::Webhook, + r#type: NotificationType::Webhook, url: "https://hooks.slack.com/test1".to_string(), signing_key: None, }]; @@ -1581,9 +1584,9 @@ mod tests { notification_id: None, custom_rpc_urls: None, }], - notifications: vec![NotificationFileConfig { + notifications: vec![NotificationConfig { id: "test-notification-1".to_string(), - r#type: crate::config::NotificationFileConfigType::Webhook, + r#type: NotificationType::Webhook, url: "https://hooks.slack.com/test1".to_string(), signing_key: None, }], diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index 101d580bb..8dff6f0e2 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -21,7 +21,10 @@ //! To use this module, load a configuration file using `load_config`, which will parse //! the file and validate its contents. If the configuration is valid, it can be used //! to initialize the application components. -use crate::config::ConfigFileError; +use crate::{ + config::ConfigFileError, + models::{NotificationConfig, NotificationConfigs}, +}; use serde::{Deserialize, Serialize}; use std::{ collections::HashSet, @@ -34,9 +37,6 @@ pub use relayer::*; mod signer; pub use signer::*; -mod notification; -pub use notification::*; - mod plugin; pub use plugin::*; @@ -58,7 +58,7 @@ pub enum ConfigFileNetworkType { pub struct Config { pub relayers: Vec, pub signers: Vec, - pub notifications: Vec, + pub notifications: Vec, pub networks: NetworksFileConfig, pub plugins: Option>, } @@ -184,7 +184,7 @@ impl Config { /// Validates that all notifications are valid and have unique IDs. fn validate_notifications(&self) -> Result<(), ConfigFileError> { - NotificationsFileConfig::new(self.notifications.clone()).validate() + NotificationConfigs::new(self.notifications.clone()).validate() } /// Validates that all networks are valid and have unique IDs. @@ -226,7 +226,7 @@ pub fn load_config(config_file_path: &str) -> Result { #[cfg(test)] mod tests { - use crate::models::{PlainOrEnvValue, SecretString}; + use crate::models::{NotificationType, PlainOrEnvValue, SecretString}; use std::path::Path; use super::*; @@ -259,9 +259,9 @@ mod tests { config: SignerFileConfigEnum::Test(TestSignerFileConfig {}), }, ], - notifications: vec![NotificationFileConfig { + notifications: vec![NotificationConfig { id: "test-1".to_string(), - r#type: NotificationFileConfigType::Webhook, + r#type: NotificationType::Webhook, url: "https://api.example.com/notifications".to_string(), signing_key: None, }], @@ -1482,9 +1482,9 @@ mod tests { let config = Config { relayers: vec![], signers: vec![], - notifications: vec![NotificationFileConfig { + notifications: vec![NotificationConfig { id: "test-notification".to_string(), - r#type: NotificationFileConfigType::Webhook, + r#type: NotificationType::Webhook, url: "https://api.example.com/notifications".to_string(), signing_key: None, }], @@ -1660,10 +1660,10 @@ mod tests { let result = config.validate(); assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConfigFileError::MissingField(_) - )); + + // With validator-based validation, empty ID now triggers InvalidFormat error + let error = result.unwrap_err(); + assert!(matches!(error, ConfigFileError::InvalidFormat(_))); } #[test] diff --git a/src/config/config_file/notification.rs b/src/config/config_file/notification.rs deleted file mode 100644 index 782f8fdd0..000000000 --- a/src/config/config_file/notification.rs +++ /dev/null @@ -1,349 +0,0 @@ -//! This module defines the configuration structures and validation logic for notifications. -//! -//! It includes: -//! - `NotificationFileConfigType`: An enum representing the type of notification configuration. -//! - `SigningKeyConfig`: An enum for specifying signing key configurations, either from an -//! environment variable or a plain value. -//! - `NotificationFileConfig`: A struct representing a single notification configuration, with -//! methods for validation and signing key retrieval. -//! - `NotificationsFileConfig`: A struct for managing a collection of notification configurations, -//! with validation to ensure uniqueness and completeness. -use crate::{ - constants::MINIMUM_SECRET_VALUE_LENGTH, - models::{PlainOrEnvValue, SecretString}, -}; - -use super::ConfigFileError; -use reqwest::Url; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum NotificationFileConfigType { - Webhook, -} - -/// Represents the type of notification configuration. -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct NotificationFileConfig { - pub id: String, - pub r#type: NotificationFileConfigType, - pub url: String, - pub signing_key: Option, -} - -impl NotificationFileConfig { - fn validate_signing_key(&self) -> Result<(), ConfigFileError> { - match &self.signing_key { - Some(signing_key) => { - match signing_key { - PlainOrEnvValue::Env { value } => { - if value.is_empty() { - return Err(ConfigFileError::MissingField( - "Signing key environment variable name cannot be empty".into(), - )); - } - - match std::env::var(value) { - Ok(key_value) => { - // Validate the key length - if key_value.len() < MINIMUM_SECRET_VALUE_LENGTH { - return Err(ConfigFileError::InvalidFormat( - format!("Signing key must be at least {} characters long (found {})", - MINIMUM_SECRET_VALUE_LENGTH, key_value.len()), - )); - } - } - Err(e) => { - return Err(ConfigFileError::MissingEnvVar(format!( - "Environment variable '{}' not found: {}", - value, e - ))); - } - } - } - PlainOrEnvValue::Plain { value } => { - if value.is_empty() { - return Err(ConfigFileError::InvalidFormat( - "Signing key value cannot be empty".into(), - )); - } - - if !value.has_minimum_length(MINIMUM_SECRET_VALUE_LENGTH) { - return Err(ConfigFileError::InvalidFormat( - format!("Security error: Signing key value must be at least {} characters long", MINIMUM_SECRET_VALUE_LENGTH) - )); - } - } - } - } - None => return Ok(()), - } - - Ok(()) - } - - pub fn get_signing_key(&self) -> Option { - self.signing_key - .as_ref() - .and_then(|key| key.get_value().ok()) - } - - pub fn validate(&self) -> Result<(), ConfigFileError> { - if self.id.is_empty() { - return Err(ConfigFileError::MissingField("notification id".into())); - } - - match &self.r#type { - NotificationFileConfigType::Webhook => { - if self.url.is_empty() { - return Err(ConfigFileError::MissingField( - "Webhook URL is required".into(), - )); - } - Url::parse(&self.url) - .map_err(|_| ConfigFileError::InvalidFormat("Invalid Webhook URL".into()))?; - } - } - - self.validate_signing_key()?; - - Ok(()) - } -} - -/// Manages a collection of notification configurations. -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct NotificationsFileConfig { - pub notifications: Vec, -} - -impl NotificationsFileConfig { - /// Creates a new `NotificationsFileConfig` with the given notifications. - pub fn new(notifications: Vec) -> Self { - Self { notifications } - } - - /// Validates the collection of notification configurations. - /// - /// Ensures that each notification is valid and that there are no duplicate IDs. - pub fn validate(&self) -> Result<(), ConfigFileError> { - if self.notifications.is_empty() { - return Err(ConfigFileError::MissingField("notifications".into())); - } - - let mut ids = HashSet::new(); - for notification in &self.notifications { - notification.validate()?; - if !ids.insert(notification.id.clone()) { - return Err(ConfigFileError::DuplicateId(notification.id.clone())); - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::sync::Mutex; - - static ENV_MUTEX: Mutex<()> = Mutex::new(()); - - #[test] - fn test_valid_webhook_notification() { - let config = json!({ - "id": "notification-test", - "type": "webhook", - "url": "https://api.example.com/notifications" - }); - - let notification: NotificationFileConfig = serde_json::from_value(config).unwrap(); - assert!(notification.validate().is_ok()); - assert_eq!(notification.id, "notification-test"); - assert_eq!(notification.r#type, NotificationFileConfigType::Webhook); - } - - #[test] - #[should_panic(expected = "missing field `url`")] - fn test_missing_webhook_url() { - let config = json!({ - "id": "notification-test", - "type": "webhook" - }); - - let _notification: NotificationFileConfig = serde_json::from_value(config).unwrap(); - } - - #[test] - fn test_invalid_webhook_url() { - let config = json!({ - "id": "notification-test", - "type": "webhook", - "url": "invalid-url" - }); - - let notification: NotificationFileConfig = serde_json::from_value(config).unwrap(); - - assert!(matches!( - notification.validate(), - Err(ConfigFileError::InvalidFormat(_)) - )); - } - - #[test] - fn test_duplicate_notification_ids() { - let config = json!({ - "notifications": [ - { - "id": "notification-test", - "type": "webhook", - "url": "https://api.example.com/notifications" - }, - { - "id": "notification-test", - "type": "webhook", - "url": "https://api.example.com/notifications" - } - ] - }); - - let notifications_config: NotificationsFileConfig = serde_json::from_value(config).unwrap(); - assert!(matches!( - notifications_config.validate(), - Err(ConfigFileError::DuplicateId(_)) - )); - } - - #[test] - fn test_empty_notification_id() { - let config = json!({ - "notifications": [ - { - "id": "", - "type": "webhook", - "url": "https://api.example.com/notifications" - } - ] - }); - - let notifications_config: NotificationsFileConfig = serde_json::from_value(config).unwrap(); - assert!(matches!( - notifications_config.validate(), - Err(ConfigFileError::MissingField(_)) - )); - } - - #[test] - fn test_valid_webhook_signing_notification_configuration() { - let config = json!({ - "id": "notification-test", - "type": "webhook", - "url": "https://api.example.com/notifications", - "signing_key": { - "type": "plain", - "value": "C6D72367-EB3A-4D34-8900-DFF794A633F9" - } - }); - - let notification: NotificationFileConfig = serde_json::from_value(config).unwrap(); - assert!(notification.validate().is_ok()); - assert_eq!(notification.id, "notification-test"); - assert_eq!(notification.r#type, NotificationFileConfigType::Webhook); - } - - #[test] - fn test_invalid_webhook_signing_notification_configuration() { - let config = json!({ - "id": "notification-test", - "type": "webhook", - "url": "https://api.example.com/notifications", - "signing_key": { - "type": "plain", - "value": "insufficient_length" - } - }); - - let notification: NotificationFileConfig = serde_json::from_value(config).unwrap(); - - let validation_result = notification.validate(); - assert!(validation_result.is_err()); - - if let Err(ConfigFileError::InvalidFormat(message)) = validation_result { - assert!(message.contains("32 characters long")); - } else { - panic!("Expected InvalidFormat error about key length"); - } - } - - #[test] - fn test_webhook_signing_key_from_env() { - use std::env; - - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - - let env_var_name = "TEST_WEBHOOK_SIGNING_KEY"; - let valid_key = "C6D72367-EB3A-4D34-8900-DFF794A633F9"; // noboost - env::set_var(env_var_name, valid_key); - - let config = json!({ - "id": "notification-test", - "type": "webhook", - "url": "https://api.example.com/notifications", - "signing_key": { - "type": "env", - "value": env_var_name - } - }); - - let notification: NotificationFileConfig = serde_json::from_value(config).unwrap(); - - assert!(notification.validate().is_ok()); - - let signing_key = notification.get_signing_key(); - assert!(signing_key.is_some()); - - env::remove_var(env_var_name); - } - - #[test] - fn test_webhook_signing_key_from_env_insufficient_length() { - use std::env; - - let _guard = ENV_MUTEX - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - - let env_var_name = "TEST_WEBHOOK_SIGNING_KEY"; - let valid_key = "insufficient_length"; - env::set_var(env_var_name, valid_key); - - let config = json!({ - "id": "notification-test", - "type": "webhook", - "url": "https://api.example.com/notifications", - "signing_key": { - "type": "env", - "value": env_var_name - } - }); - - let notification: NotificationFileConfig = serde_json::from_value(config).unwrap(); - - let validation_result = notification.validate(); - - assert!(validation_result.is_err()); - - if let Err(ConfigFileError::InvalidFormat(message)) = validation_result { - assert!(message.contains("32 characters long")); - } else { - panic!("Expected InvalidFormat error about key length"); - } - } -} diff --git a/src/constants/validation.rs b/src/constants/validation.rs index ec01fd884..a22844b15 100644 --- a/src/constants/validation.rs +++ b/src/constants/validation.rs @@ -3,8 +3,7 @@ use regex::Regex; pub const MINIMUM_SECRET_VALUE_LENGTH: usize = 32; - // Regex for validating notification IDs lazy_static! { - pub static ref ID_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9-_]+$").unwrap(); -} \ No newline at end of file + pub static ref ID_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9-_]+$").unwrap(); +} diff --git a/src/models/api_response.rs b/src/models/api_response.rs index 2716c4ac3..638491acf 100644 --- a/src/models/api_response.rs +++ b/src/models/api_response.rs @@ -1,14 +1,14 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -#[derive(Debug, Serialize, PartialEq, Clone, ToSchema)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)] pub struct PaginationMeta { pub current_page: u32, pub per_page: u32, pub total_items: u64, } -#[derive(Serialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema)] pub struct ApiResponse { pub success: bool, pub data: Option, diff --git a/src/models/notification/config.rs b/src/models/notification/config.rs new file mode 100644 index 000000000..4890b8efe --- /dev/null +++ b/src/models/notification/config.rs @@ -0,0 +1,350 @@ +use crate::{ + config::ConfigFileError, + models::{notification::core::*, PlainOrEnvValue, SecretString}, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +/// Configuration file representation of a notification +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct NotificationConfig { + pub id: String, + pub r#type: NotificationType, + pub url: String, + pub signing_key: Option, +} + +impl TryFrom for Notification { + type Error = ConfigFileError; + + fn try_from(config: NotificationConfig) -> Result { + let signing_key = config.get_signing_key()?; + + // Create core notification + let notification = Notification::new(config.id, config.r#type, config.url, signing_key); + + // Validate using core validation logic + notification.validate().map_err(|e| match e { + NotificationValidationError::EmptyId => { + ConfigFileError::MissingField("notification id".into()) + } + NotificationValidationError::IdTooLong => { + ConfigFileError::InvalidFormat("Notification ID too long".into()) + } + NotificationValidationError::InvalidIdFormat => { + ConfigFileError::InvalidFormat("Invalid notification ID format".into()) + } + NotificationValidationError::EmptyUrl => { + ConfigFileError::MissingField("Webhook URL is required".into()) + } + NotificationValidationError::InvalidUrl => { + ConfigFileError::InvalidFormat("Invalid Webhook URL".into()) + } + NotificationValidationError::SigningKeyTooShort(min_len) => { + ConfigFileError::InvalidFormat(format!( + "Signing key must be at least {} characters long", + min_len + )) + } + })?; + + Ok(notification) + } +} + +impl NotificationConfig { + /// Validates the notification configuration by converting to core model + pub fn validate(&self) -> Result<(), ConfigFileError> { + let _notification = Notification::try_from(self.clone())?; + Ok(()) + } + + /// Converts to core notification model + pub fn to_core_notification(&self) -> Result { + Notification::try_from(self.clone()) + } + + /// Gets the resolved signing key with config-specific error handling + pub fn get_signing_key(&self) -> Result, ConfigFileError> { + match &self.signing_key { + Some(signing_key) => match signing_key { + PlainOrEnvValue::Env { value } => { + if value.is_empty() { + return Err(ConfigFileError::MissingField( + "Signing key environment variable name cannot be empty".into(), + )); + } + + match std::env::var(value) { + Ok(key_value) => { + let secret = SecretString::new(&key_value); + Ok(Some(secret)) + } + Err(e) => Err(ConfigFileError::MissingEnvVar(format!( + "Environment variable '{}' not found: {}", + value, e + ))), + } + } + PlainOrEnvValue::Plain { value } => { + let is_empty = value.as_str(|s| s.is_empty()); + if is_empty { + return Err(ConfigFileError::InvalidFormat( + "Signing key value cannot be empty".into(), + )); + } + Ok(Some(value.clone())) + } + }, + None => Ok(None), + } + } +} + +/// Collection of notification configurations +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct NotificationConfigs { + pub notifications: Vec, +} + +impl NotificationConfigs { + /// Creates a new collection of notification configurations + pub fn new(notifications: Vec) -> Self { + Self { notifications } + } + + /// Validates all notification configurations + pub fn validate(&self) -> Result<(), ConfigFileError> { + if self.notifications.is_empty() { + return Err(ConfigFileError::MissingField("notifications".into())); + } + + let mut ids = HashSet::new(); + for notification in &self.notifications { + // Validate each notification using core validation + notification.validate()?; + + // Check for duplicate IDs + if !ids.insert(notification.id.clone()) { + return Err(ConfigFileError::DuplicateId(notification.id.clone())); + } + } + Ok(()) + } + + /// Converts all configurations to core notification models + pub fn to_core_notifications(&self) -> Result, ConfigFileError> { + self.notifications + .iter() + .map(|config| Notification::try_from(config.clone())) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_notification_config_conversion() { + let config = NotificationConfig { + id: "test-webhook".to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some(PlainOrEnvValue::Plain { + value: SecretString::new(&"a".repeat(32)), + }), + }; + + let result = Notification::try_from(config); + assert!(result.is_ok()); + + let notification = result.unwrap(); + assert_eq!(notification.id, "test-webhook"); + assert_eq!(notification.notification_type, NotificationType::Webhook); + assert_eq!(notification.url, "https://example.com/webhook"); + assert!(notification.signing_key.is_some()); + } + + #[test] + fn test_invalid_notification_config_conversion() { + let config = NotificationConfig { + id: "invalid@id".to_string(), // Invalid ID format + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: None, + }; + + let result = Notification::try_from(config); + assert!(result.is_err()); + + if let Err(ConfigFileError::InvalidFormat(msg)) = result { + assert!(msg.contains("Invalid notification ID format")); + } else { + panic!("Expected InvalidFormat error"); + } + } + + #[test] + fn test_to_core_notification() { + let config = NotificationConfig { + id: "test-webhook".to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some(PlainOrEnvValue::Plain { + value: SecretString::new(&"a".repeat(32)), + }), + }; + + let core = config.to_core_notification().unwrap(); + assert_eq!(core.id, "test-webhook"); + assert_eq!(core.notification_type, NotificationType::Webhook); + assert_eq!(core.url, "https://example.com/webhook"); + assert!(core.signing_key.is_some()); + } + + #[test] + fn test_notification_configs_validation() { + let configs = NotificationConfigs::new(vec![ + NotificationConfig { + id: "webhook1".to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook1".to_string(), + signing_key: None, + }, + NotificationConfig { + id: "webhook2".to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook2".to_string(), + signing_key: None, + }, + ]); + + assert!(configs.validate().is_ok()); + } + + #[test] + fn test_duplicate_ids() { + let configs = NotificationConfigs::new(vec![ + NotificationConfig { + id: "webhook1".to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook1".to_string(), + signing_key: None, + }, + NotificationConfig { + id: "webhook1".to_string(), // Duplicate ID + r#type: NotificationType::Webhook, + url: "https://example.com/webhook2".to_string(), + signing_key: None, + }, + ]); + + assert!(matches!( + configs.validate(), + Err(ConfigFileError::DuplicateId(_)) + )); + } + + #[test] + fn test_config_with_short_signing_key() { + let config = NotificationConfig { + id: "test-webhook".to_string(), + r#type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some(PlainOrEnvValue::Plain { + value: SecretString::new("short"), // Too short + }), + }; + + let result = Notification::try_from(config); + assert!(result.is_err()); + + if let Err(ConfigFileError::InvalidFormat(msg)) = result { + assert!(msg.contains("Signing key must be at least")); + } else { + panic!("Expected InvalidFormat error for short key"); + } + } + + // Additional tests for JSON deserialization and environment handling + #[test] + fn test_valid_webhook_notification_json() { + use serde_json::json; + + let config = json!({ + "id": "notification-test", + "type": "webhook", + "url": "https://api.example.com/notifications" + }); + + let notification: NotificationConfig = serde_json::from_value(config).unwrap(); + assert!(notification.validate().is_ok()); + assert_eq!(notification.id, "notification-test"); + assert_eq!(notification.r#type, NotificationType::Webhook); + } + + #[test] + fn test_invalid_webhook_url_json() { + use serde_json::json; + + let config = json!({ + "id": "notification-test", + "type": "webhook", + "url": "invalid-url" + }); + + let notification: NotificationConfig = serde_json::from_value(config).unwrap(); + assert!(notification.validate().is_err()); + } + + #[test] + fn test_webhook_notification_with_signing_key_json() { + use serde_json::json; + + let config = json!({ + "id": "notification-test", + "type": "webhook", + "url": "https://api.example.com/notifications", + "signing_key": { + "type": "plain", + "value": "a".repeat(32) + } + }); + + let notification: NotificationConfig = serde_json::from_value(config).unwrap(); + assert!(notification.validate().is_ok()); + assert!(notification.get_signing_key().unwrap().is_some()); + } + + #[test] + fn test_webhook_notification_with_env_signing_key_json() { + use serde_json::json; + use std::sync::Mutex; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + let _lock = ENV_MUTEX.lock().unwrap(); + + // Set environment variable + std::env::set_var("TEST_SIGNING_KEY", "a".repeat(32)); + + let config = json!({ + "id": "notification-test", + "type": "webhook", + "url": "https://api.example.com/notifications", + "signing_key": { + "type": "env", + "value": "TEST_SIGNING_KEY" + } + }); + + let notification: NotificationConfig = serde_json::from_value(config).unwrap(); + assert!(notification.validate().is_ok()); + assert!(notification.get_signing_key().unwrap().is_some()); + + // Clean up + std::env::remove_var("TEST_SIGNING_KEY"); + } +} diff --git a/src/models/notification/core.rs b/src/models/notification/core.rs new file mode 100644 index 000000000..a0e03d01a --- /dev/null +++ b/src/models/notification/core.rs @@ -0,0 +1,359 @@ +use crate::{ + constants::{ID_REGEX, MINIMUM_SECRET_VALUE_LENGTH}, + models::SecretString, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use validator::{Validate, ValidationError}; + +/// Core notification type enum used by both config file and API +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum NotificationType { + Webhook, +} + +/// Request structure for updating an existing notification +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +pub struct NotificationUpdateRequest { + pub r#type: Option, + pub url: Option, + /// Optional signing key for securing webhook notifications. + /// - None: don't change the existing signing key + /// - Some(""): remove the signing key + /// - Some("key"): set the signing key to the provided value + pub signing_key: Option, +} + +/// Core notification model used by both config file and API +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct Notification { + #[validate( + length(min = 1, max = 36, message = "ID must be between 1 and 36 characters"), + regex( + path = "*ID_REGEX", + message = "ID must contain only letters, numbers, dashes and underscores" + ) + )] + pub id: String, + pub notification_type: NotificationType, + #[validate(url(message = "Invalid URL format"))] + pub url: String, + #[validate(custom(function = "validate_signing_key"))] + pub signing_key: Option, +} + +/// Custom validator for signing key - validator handles Option automatically +fn validate_signing_key(signing_key: &SecretString) -> Result<(), ValidationError> { + let is_valid = signing_key.as_str(|key_str| key_str.len() >= MINIMUM_SECRET_VALUE_LENGTH); + if !is_valid { + return Err(ValidationError::new("signing_key_too_short")); + } + Ok(()) +} + +impl Notification { + /// Creates a new notification + pub fn new( + id: String, + notification_type: NotificationType, + url: String, + signing_key: Option, + ) -> Self { + Self { + id, + notification_type, + url, + signing_key, + } + } + + /// Validates the notification using the validator crate + pub fn validate(&self) -> Result<(), NotificationValidationError> { + Validate::validate(self).map_err(|validation_errors| { + // Convert validator errors to our custom error type + // Return the first error for simplicity + for (field, errors) in validation_errors.field_errors() { + if let Some(error) = errors.first() { + let field_str = field.as_ref(); + return match (field_str, error.code.as_ref()) { + ("id", "length") => NotificationValidationError::IdTooLong, + ("id", "regex") => NotificationValidationError::InvalidIdFormat, + ("url", _) => NotificationValidationError::InvalidUrl, + ("signing_key", "signing_key_too_short") => { + NotificationValidationError::signing_key_too_short() + } + _ => NotificationValidationError::InvalidIdFormat, // fallback + }; + } + } + // Fallback error + NotificationValidationError::InvalidIdFormat + }) + } + + /// Applies an update request to create a new validated notification + /// + /// This method provides a domain-first approach where the core model handles + /// its own business rules and validation rather than having update logic + /// scattered across request models. + /// + /// # Arguments + /// * `request` - The update request containing partial data to apply + /// + /// # Returns + /// * `Ok(Notification)` - A new validated notification with updates applied + /// * `Err(NotificationValidationError)` - If the resulting notification would be invalid + pub fn apply_update( + &self, + request: &NotificationUpdateRequest, + ) -> Result { + let mut updated = self.clone(); + + // Apply updates from request + if let Some(notification_type) = &request.r#type { + updated.notification_type = notification_type.clone(); + } + + if let Some(url) = &request.url { + updated.url = url.clone(); + } + + if let Some(signing_key) = &request.signing_key { + updated.signing_key = if signing_key.is_empty() { + // Empty string means remove the signing key + None + } else { + // Non-empty string means update the signing key + Some(SecretString::new(signing_key)) + }; + } + + // Validate the complete updated model + updated.validate()?; + + Ok(updated) + } +} + +/// Common validation errors for notifications +#[derive(Debug, thiserror::Error)] +pub enum NotificationValidationError { + #[error("Notification ID cannot be empty")] + EmptyId, + #[error("Notification ID must be at most 36 characters long")] + IdTooLong, + #[error("Notification ID must contain only letters, numbers, dashes and underscores")] + InvalidIdFormat, + #[error("Notification URL cannot be empty")] + EmptyUrl, + #[error("Invalid notification URL format")] + InvalidUrl, + #[error("Signing key must be at least {0} characters long")] + SigningKeyTooShort(usize), +} + +impl NotificationValidationError { + pub fn signing_key_too_short() -> Self { + Self::SigningKeyTooShort(MINIMUM_SECRET_VALUE_LENGTH) + } +} + +/// Centralized conversion from NotificationValidationError to ApiError +impl From for crate::models::ApiError { + fn from(error: NotificationValidationError) -> Self { + use crate::models::ApiError; + + ApiError::BadRequest(match error { + NotificationValidationError::EmptyId => "ID cannot be empty".to_string(), + NotificationValidationError::IdTooLong => { + "ID must be at most 36 characters long".to_string() + } + NotificationValidationError::InvalidIdFormat => { + "ID must contain only letters, numbers, dashes and underscores".to_string() + } + NotificationValidationError::EmptyUrl => "URL cannot be empty".to_string(), + NotificationValidationError::InvalidUrl => "Invalid URL format".to_string(), + NotificationValidationError::SigningKeyTooShort(min_len) => { + format!("Signing key must be at least {} characters long", min_len) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_notification() { + let notification = Notification::new( + "valid-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + Some(SecretString::new(&"a".repeat(32))), + ); + + assert!(notification.validate().is_ok()); + } + + #[test] + fn test_empty_id() { + let notification = Notification::new( + "".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + None, + ); + + // With validator, empty string will trigger length validation error + assert!(matches!( + notification.validate(), + Err(NotificationValidationError::IdTooLong) // validator maps length errors to this + )); + } + + #[test] + fn test_id_too_long() { + let notification = Notification::new( + "a".repeat(37), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + None, + ); + + assert!(matches!( + notification.validate(), + Err(NotificationValidationError::IdTooLong) + )); + } + + #[test] + fn test_invalid_id_format() { + let notification = Notification::new( + "invalid@id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + None, + ); + + assert!(matches!( + notification.validate(), + Err(NotificationValidationError::InvalidIdFormat) + )); + } + + #[test] + fn test_invalid_url() { + let notification = Notification::new( + "valid-id".to_string(), + NotificationType::Webhook, + "not-a-url".to_string(), + None, + ); + + assert!(matches!( + notification.validate(), + Err(NotificationValidationError::InvalidUrl) + )); + } + + #[test] + fn test_signing_key_too_short() { + let notification = Notification::new( + "valid-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + Some(SecretString::new("short")), + ); + + assert!(matches!( + notification.validate(), + Err(NotificationValidationError::SigningKeyTooShort(_)) + )); + } + + #[test] + fn test_apply_update_success() { + let original = Notification::new( + "test-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + Some(SecretString::new(&"a".repeat(32))), + ); + + let update_request = NotificationUpdateRequest { + r#type: None, // Keep existing type + url: Some("https://updated.example.com/webhook".to_string()), + signing_key: Some("b".repeat(32)), // Update signing key + }; + + let result = original.apply_update(&update_request); + assert!(result.is_ok()); + + let updated = result.unwrap(); + assert_eq!(updated.id, "test-id"); // ID should remain unchanged + assert_eq!(updated.notification_type, NotificationType::Webhook); // Type unchanged + assert_eq!(updated.url, "https://updated.example.com/webhook"); // URL updated + assert!(updated.signing_key.is_some()); // Signing key updated + } + + #[test] + fn test_apply_update_remove_signing_key() { + let original = Notification::new( + "test-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + Some(SecretString::new(&"a".repeat(32))), + ); + + let update_request = NotificationUpdateRequest { + r#type: None, + url: None, + signing_key: Some("".to_string()), // Empty string removes signing key + }; + + let result = original.apply_update(&update_request); + assert!(result.is_ok()); + + let updated = result.unwrap(); + assert_eq!(updated.id, "test-id"); + assert_eq!(updated.url, "https://example.com/webhook"); // URL unchanged + assert!(updated.signing_key.is_none()); // Signing key removed + } + + #[test] + fn test_apply_update_validation_failure() { + let original = Notification::new( + "test-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + None, + ); + + let update_request = NotificationUpdateRequest { + r#type: None, + url: Some("not-a-valid-url".to_string()), // Invalid URL + signing_key: None, + }; + + let result = original.apply_update(&update_request); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + NotificationValidationError::InvalidUrl + )); + } + + #[test] + fn test_error_conversion_to_api_error() { + let error = NotificationValidationError::InvalidUrl; + let api_error: crate::models::ApiError = error.into(); + + if let crate::models::ApiError::BadRequest(msg) = api_error { + assert_eq!(msg, "Invalid URL format"); + } else { + panic!("Expected BadRequest error"); + } + } +} diff --git a/src/models/notification/mod.rs b/src/models/notification/mod.rs index fcc4395f5..4e5f3520f 100644 --- a/src/models/notification/mod.rs +++ b/src/models/notification/mod.rs @@ -1,11 +1,20 @@ -mod webhook_notification; -pub use webhook_notification::*; +mod core; +pub use core::*; -mod repository; -pub use repository::*; +mod config; +pub use config::*; + +mod request; +pub use request::*; mod response; pub use response::*; -mod request; -pub use request::*; +mod repository; +pub use repository::NotificationRepoModel; + +mod webhook_notification; +pub use webhook_notification::*; + +// Legacy re-exports for backward compatibility +pub use core::NotificationType; diff --git a/src/models/notification/repository.rs b/src/models/notification/repository.rs index 656537648..03b8df4c0 100644 --- a/src/models/notification/repository.rs +++ b/src/models/notification/repository.rs @@ -1,18 +1,92 @@ +use crate::models::notification::core::*; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::models::SecretString; - +// Repository model is now just an alias to the core model with additional traits #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum NotificationType { - Webhook, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotificationRepoModel { pub id: String, pub notification_type: NotificationType, pub url: String, - pub signing_key: Option, + pub signing_key: Option, +} + +impl From for NotificationRepoModel { + fn from(notification: Notification) -> Self { + Self { + id: notification.id, + notification_type: notification.notification_type, + url: notification.url, + signing_key: notification.signing_key, + } + } +} + +impl From for Notification { + fn from(repo_model: NotificationRepoModel) -> Self { + Self { + id: repo_model.id, + notification_type: repo_model.notification_type, + url: repo_model.url, + signing_key: repo_model.signing_key, + } + } +} + +impl NotificationRepoModel { + /// Validates the repository model using core validation logic + pub fn validate(&self) -> Result<(), NotificationValidationError> { + let core_notification = Notification::from(self.clone()); + core_notification.validate() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::SecretString; + + #[test] + fn test_from_core_notification() { + let core = Notification::new( + "test-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + Some(SecretString::new("test-key")), + ); + + let repo_model = NotificationRepoModel::from(core); + assert_eq!(repo_model.id, "test-id"); + assert_eq!(repo_model.notification_type, NotificationType::Webhook); + assert_eq!(repo_model.url, "https://example.com/webhook"); + assert!(repo_model.signing_key.is_some()); + } + + #[test] + fn test_to_core_notification() { + let repo_model = NotificationRepoModel { + id: "test-id".to_string(), + notification_type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some(SecretString::new("test-key")), + }; + + let core = Notification::from(repo_model); + assert_eq!(core.id, "test-id"); + assert_eq!(core.notification_type, NotificationType::Webhook); + assert_eq!(core.url, "https://example.com/webhook"); + assert!(core.signing_key.is_some()); + } + + #[test] + fn test_validation() { + let repo_model = NotificationRepoModel { + id: "test-id".to_string(), + notification_type: NotificationType::Webhook, + url: "https://example.com/webhook".to_string(), + signing_key: Some(SecretString::new(&"a".repeat(32))), + }; + + assert!(repo_model.validate().is_ok()); + } } diff --git a/src/models/notification/request.rs b/src/models/notification/request.rs index 15b3dc074..7a4ac2afe 100644 --- a/src/models/notification/request.rs +++ b/src/models/notification/request.rs @@ -1,132 +1,60 @@ -use crate::{ - constants::{ID_REGEX, MINIMUM_SECRET_VALUE_LENGTH}, - models::{ApiError, NotificationRepoModel, NotificationType, SecretString}, -}; +use crate::models::{notification::core::*, ApiError, NotificationType, SecretString}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use validator::{Validate, ValidationError}; + /// Request structure for creating a new notification -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Validate)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] pub struct NotificationCreateRequest { - #[validate( - length(min = 1, max = 36, message = "ID must be between 1 and 36 characters"), - regex( - path = "*ID_REGEX", - message = "ID must contain only letters, numbers, dashes and underscores" - ) - )] pub id: String, pub r#type: NotificationType, - #[validate(url(message = "Invalid URL format"))] pub url: String, /// Optional signing key for securing webhook notifications - #[validate(custom(function = "validate_signing_key"))] pub signing_key: Option, } -/// Request structure for updating an existing notification -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Validate)] -pub struct NotificationUpdateRequest { - pub r#type: Option, - #[validate(url(message = "Invalid URL format"))] - pub url: Option, - /// Optional signing key for securing webhook notifications. - /// - None: don't change the existing signing key - /// - Some(""): remove the signing key - /// - Some("key"): set the signing key to the provided value - #[validate(custom(function = "validate_optional_signing_key"))] - pub signing_key: Option, -} -/// Custom validator for signing key in create requests -fn validate_signing_key(signing_key: &String) -> Result<(), ValidationError> { - if !signing_key.is_empty() && signing_key.len() < MINIMUM_SECRET_VALUE_LENGTH { - return Err(ValidationError::new("signing_key_too_short")); - } - Ok(()) -} - -/// Custom validator for optional signing key in update requests -fn validate_optional_signing_key(signing_key: &String) -> Result<(), ValidationError> { - // Allow empty string (means remove the key) - if !signing_key.is_empty() && signing_key.len() < MINIMUM_SECRET_VALUE_LENGTH { - return Err(ValidationError::new("signing_key_too_short")); +impl TryFrom for Notification { + type Error = ApiError; + + fn try_from(request: NotificationCreateRequest) -> Result { + let signing_key = request.signing_key.map(|s| SecretString::new(&s)); + + let notification = Notification::new(request.id, request.r#type, request.url, signing_key); + + // Validate using core validation logic + notification.validate().map_err(|e| { + ApiError::BadRequest(match e { + NotificationValidationError::EmptyId => "ID cannot be empty".to_string(), + NotificationValidationError::IdTooLong => { + "ID must be at most 36 characters long".to_string() + } + NotificationValidationError::InvalidIdFormat => { + "ID must contain only letters, numbers, dashes and underscores".to_string() + } + NotificationValidationError::EmptyUrl => "URL cannot be empty".to_string(), + NotificationValidationError::InvalidUrl => "Invalid URL format".to_string(), + NotificationValidationError::SigningKeyTooShort(min_len) => { + format!("Signing key must be at least {} characters long", min_len) + } + }) + })?; + + Ok(notification) } - Ok(()) } -impl NotificationCreateRequest { - /// Validates the create request - pub fn validate(&self) -> Result<(), ApiError> { - Validate::validate(self).map_err(|e| { - let error_messages: Vec = e - .field_errors() - .iter() - .flat_map(|(field, errors)| { - errors.iter().map(move |error| { - format!("{}: {}", field, error.message.as_ref().unwrap_or(&"Invalid value".into())) - }) - }) - .collect(); - ApiError::BadRequest(error_messages.join(", ")) - }) - } -} - -impl NotificationUpdateRequest { - /// Validates the update request - pub fn validate(&self) -> Result<(), ApiError> { - Validate::validate(self).map_err(|e| { - let error_messages: Vec = e - .field_errors() - .iter() - .flat_map(|(field, errors)| { - errors.iter().map(move |error| { - format!("{}: {}", field, error.message.as_ref().unwrap_or(&"Invalid value".into())) - }) - }) - .collect(); - ApiError::BadRequest(error_messages.join(", ")) - }) - } - - /// Applies the update request to an existing notification model - pub fn apply_to(&self, mut model: NotificationRepoModel) -> NotificationRepoModel { - if let Some(notification_type) = &self.r#type { - model.notification_type = notification_type.clone(); - } - if let Some(url) = &self.url { - model.url = url.clone(); - } - if let Some(signing_key) = &self.signing_key { - if signing_key.is_empty() { - // Empty string means remove the signing key - model.signing_key = None; - } else { - // Non-empty string means update the signing key - model.signing_key = Some(SecretString::new(signing_key)); - } - } - model - } -} +// NotificationUpdateRequest is now a pure data structure without business logic +// Business logic has been moved to the core Notification::apply_update method -impl From for NotificationRepoModel { - fn from(request: NotificationCreateRequest) -> Self { - Self { - id: request.id, - notification_type: request.r#type, - url: request.url, - signing_key: request.signing_key.map(|s| SecretString::new(&s)), - } - } -} +// Note: From for NotificationRepoModel is implemented in repository.rs #[cfg(test)] mod tests { + use crate::models::NotificationRepoModel; + use super::*; #[test] - fn test_valid_create_request() { + fn test_valid_create_request_conversion() { let request = NotificationCreateRequest { id: "test-notification".to_string(), r#type: NotificationType::Webhook, @@ -134,23 +62,18 @@ mod tests { signing_key: Some("a".repeat(32)), // Minimum length }; - assert!(request.validate().is_ok()); - } - - #[test] - fn test_invalid_id_too_long() { - let request = NotificationCreateRequest { - id: "a".repeat(37), // Too long - r#type: NotificationType::Webhook, - url: "https://example.com/webhook".to_string(), - signing_key: None, - }; + let result = Notification::try_from(request); + assert!(result.is_ok()); - assert!(request.validate().is_err()); + let notification = result.unwrap(); + assert_eq!(notification.id, "test-notification"); + assert_eq!(notification.notification_type, NotificationType::Webhook); + assert_eq!(notification.url, "https://example.com/webhook"); + assert!(notification.signing_key.is_some()); } #[test] - fn test_invalid_id_format() { + fn test_invalid_create_request_conversion() { let request = NotificationCreateRequest { id: "invalid@id".to_string(), // Invalid characters r#type: NotificationType::Webhook, @@ -158,19 +81,14 @@ mod tests { signing_key: None, }; - assert!(request.validate().is_err()); - } + let result = Notification::try_from(request); + assert!(result.is_err()); - #[test] - fn test_invalid_url_format() { - let request = NotificationCreateRequest { - id: "test-notification".to_string(), - r#type: NotificationType::Webhook, - url: "not-a-url".to_string(), - signing_key: None, - }; - - assert!(request.validate().is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("ID must contain only letters, numbers, dashes and underscores")); + } else { + panic!("Expected BadRequest error"); + } } #[test] @@ -182,128 +100,101 @@ mod tests { signing_key: Some("short".to_string()), // Too short }; - assert!(request.validate().is_err()); - } + let result = Notification::try_from(request); + assert!(result.is_err()); - #[test] - fn test_valid_update_request() { - let request = NotificationUpdateRequest { - r#type: Some(NotificationType::Webhook), - url: Some("https://updated.example.com/webhook".to_string()), - signing_key: Some("a".repeat(32)), // Minimum length - }; - - assert!(request.validate().is_ok()); - } - - #[test] - fn test_update_request_empty_signing_key() { - let request = NotificationUpdateRequest { - r#type: None, - url: None, - signing_key: Some("".to_string()), // Empty string to remove key - }; - - assert!(request.validate().is_ok()); - } - - #[test] - fn test_from_notification_create_request() { - let request = NotificationCreateRequest { - id: "test-id".to_string(), - r#type: NotificationType::Webhook, - url: "https://example.com/webhook".to_string(), - signing_key: Some("secret-key".to_string()), - }; - - let model = NotificationRepoModel::from(request); - - assert_eq!(model.id, "test-id"); - assert_eq!(model.notification_type, NotificationType::Webhook); - assert_eq!(model.url, "https://example.com/webhook"); - assert!(model.signing_key.is_some()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Signing key must be at least")); + } else { + panic!("Expected BadRequest error"); + } } #[test] - fn test_from_notification_create_request_without_signing_key() { + fn test_invalid_url() { let request = NotificationCreateRequest { - id: "test-id".to_string(), + id: "test-notification".to_string(), r#type: NotificationType::Webhook, - url: "https://example.com/webhook".to_string(), + url: "not-a-url".to_string(), signing_key: None, }; - let model = NotificationRepoModel::from(request); + let result = Notification::try_from(request); + assert!(result.is_err()); - assert_eq!(model.id, "test-id"); - assert_eq!(model.notification_type, NotificationType::Webhook); - assert_eq!(model.url, "https://example.com/webhook"); - assert!(model.signing_key.is_none()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Invalid URL format")); + } else { + panic!("Expected BadRequest error"); + } } #[test] - fn test_notification_update_request_apply_to() { - let original_model = NotificationRepoModel { - id: "test-id".to_string(), - notification_type: NotificationType::Webhook, - url: "https://example.com/webhook".to_string(), - signing_key: Some(SecretString::new("old-key")), - }; + fn test_update_request_validation_domain_first() { + // Create existing core notification + let existing_core = Notification::new( + "test-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + Some(SecretString::new("existing-key")), + ); let update_request = NotificationUpdateRequest { r#type: None, url: Some("https://new-example.com/webhook".to_string()), - signing_key: Some("new-key".to_string()), + signing_key: Some("a".repeat(32)), // Valid length }; - let updated_model = update_request.apply_to(original_model); + let result = existing_core.apply_update(&update_request); + assert!(result.is_ok()); - assert_eq!(updated_model.id, "test-id"); - assert_eq!(updated_model.notification_type, NotificationType::Webhook); - assert_eq!(updated_model.url, "https://new-example.com/webhook"); - assert!(updated_model.signing_key.is_some()); + let updated = result.unwrap(); + assert_eq!(updated.id, "test-id"); // ID should remain unchanged + assert_eq!(updated.url, "https://new-example.com/webhook"); // URL should be updated + assert!(updated.signing_key.is_some()); // Signing key should be updated } #[test] - fn test_notification_update_request_partial_update() { - let original_model = NotificationRepoModel { - id: "test-id".to_string(), - notification_type: NotificationType::Webhook, - url: "https://example.com/webhook".to_string(), - signing_key: Some(SecretString::new("old-key")), - }; + fn test_update_request_invalid_url_domain_first() { + // Create existing core notification + let existing_core = Notification::new( + "test-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + None, + ); let update_request = NotificationUpdateRequest { r#type: None, - url: Some("https://new-example.com/webhook".to_string()), + url: Some("not-a-url".to_string()), // Invalid URL signing_key: None, }; - let updated_model = update_request.apply_to(original_model); + let result = existing_core.apply_update(&update_request); + assert!(result.is_err()); - assert_eq!(updated_model.id, "test-id"); - assert_eq!(updated_model.notification_type, NotificationType::Webhook); - assert_eq!(updated_model.url, "https://new-example.com/webhook"); - assert!(updated_model.signing_key.is_some()); // Should remain unchanged + // Test the From conversion to ApiError + let api_error: ApiError = result.unwrap_err().into(); + if let ApiError::BadRequest(msg) = api_error { + assert!(msg.contains("Invalid URL format")); + } else { + panic!("Expected BadRequest error"); + } } #[test] - fn test_notification_update_request_remove_signing_key() { - let mut model = NotificationRepoModel { - id: "test-id".to_string(), - notification_type: NotificationType::Webhook, - url: "https://example.com/webhook".to_string(), - signing_key: Some(SecretString::new("existing-key")), - }; - - let update_request = NotificationUpdateRequest { - r#type: None, - url: None, - signing_key: Some("".to_string()), // Empty string to remove - }; - - model = update_request.apply_to(model); - - assert_eq!(model.signing_key, None); + fn test_notification_to_repo_model() { + let notification = Notification::new( + "test-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + Some(SecretString::new("test-key")), + ); + + let repo_model = NotificationRepoModel::from(notification); + assert_eq!(repo_model.id, "test-id"); + assert_eq!(repo_model.notification_type, NotificationType::Webhook); + assert_eq!(repo_model.url, "https://example.com/webhook"); + assert!(repo_model.signing_key.is_some()); } } diff --git a/src/models/secret_string.rs b/src/models/secret_string.rs index 98b097d2f..b97e026b0 100644 --- a/src/models/secret_string.rs +++ b/src/models/secret_string.rs @@ -11,6 +11,7 @@ use std::{fmt, sync::Mutex}; use secrets::SecretVec; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use zeroize::Zeroizing; pub struct SecretString(Mutex>); @@ -134,6 +135,28 @@ impl fmt::Debug for SecretString { } } +impl ToSchema for SecretString { + fn name() -> std::borrow::Cow<'static, str> { + "SecretString".into() + } +} + +impl utoipa::PartialSchema for SecretString { + fn schema() -> utoipa::openapi::RefOr { + use utoipa::openapi::*; + + RefOr::T(Schema::Object( + ObjectBuilder::new() + .schema_type(schema::Type::String) + .format(Some(schema::SchemaFormat::KnownFormat( + schema::KnownFormat::Password, + ))) + .description(Some("A secret string value (content is protected)")) + .build(), + )) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/repositories/notification/mod.rs b/src/repositories/notification/mod.rs index 2ac538d96..77c7937e5 100644 --- a/src/repositories/notification/mod.rs +++ b/src/repositories/notification/mod.rs @@ -274,7 +274,6 @@ mod tests { // Create notification first storage.create(notification.clone()).await?; - // Update it - should return NotSupported error let mut updated_notification = notification.clone(); updated_notification.url = "https://updated.webhook.com".to_string(); @@ -284,13 +283,13 @@ mod tests { updated_notification.clone(), ) .await; - assert!(result.is_err()); - match result.unwrap_err() { - RepositoryError::NotSupported(_) => { - // Expected error - } - _ => panic!("Expected NotSupported error"), - } + assert!(result.is_ok()); + let updated = result.unwrap(); + assert_eq!(updated.url, "https://updated.webhook.com"); + + // Verify the update persisted + let retrieved = storage.get_by_id("test-notification".to_string()).await?; + assert_eq!(retrieved.url, "https://updated.webhook.com"); Ok(()) } @@ -320,15 +319,13 @@ mod tests { let retrieved = storage.get_by_id("test-notification".to_string()).await?; assert_eq!(retrieved.id, "test-notification"); - // Delete it - should return NotSupported error + // Delete it - should now succeed let result = storage.delete_by_id("test-notification".to_string()).await; - assert!(result.is_err()); - match result.unwrap_err() { - RepositoryError::NotSupported(_) => { - // Expected error - } - _ => panic!("Expected NotSupported error"), - } + assert!(result.is_ok()); + + // Verify it's gone + let get_result = storage.get_by_id("test-notification".to_string()).await; + assert!(matches!(get_result, Err(RepositoryError::NotFound(_)))); Ok(()) } @@ -363,19 +360,13 @@ mod tests { let count_after_two = storage.count().await?; assert_eq!(count_after_two, 2); - // Try to delete one - should return NotSupported error + // Delete one - should now succeed let delete_result = storage.delete_by_id("notification-1".to_string()).await; - assert!(delete_result.is_err()); - match delete_result.unwrap_err() { - RepositoryError::NotSupported(_) => { - // Expected error - } - _ => panic!("Expected NotSupported error"), - } + assert!(delete_result.is_ok()); - // Count should remain the same since delete is not supported - let count_after_delete_attempt = storage.count().await?; - assert_eq!(count_after_delete_attempt, 2); + // Count should decrease after successful delete + let count_after_delete = storage.count().await?; + assert_eq!(count_after_delete, 1); Ok(()) } @@ -395,19 +386,13 @@ mod tests { let has_entries_after_create = storage.has_entries().await?; assert!(has_entries_after_create); - // Try to delete notification - should return NotSupported error + // Delete notification - should now succeed let delete_result = storage.delete_by_id("test-notification".to_string()).await; - assert!(delete_result.is_err()); - match delete_result.unwrap_err() { - RepositoryError::NotSupported(_) => { - // Expected error - } - _ => panic!("Expected NotSupported error"), - } + assert!(delete_result.is_ok()); - // Should still have entries since delete is not supported - let has_entries_after_delete_attempt = storage.has_entries().await?; - assert!(has_entries_after_delete_attempt); + // Should no longer have entries after successful delete + let has_entries_after_delete = storage.has_entries().await?; + assert!(!has_entries_after_delete); Ok(()) } @@ -481,34 +466,28 @@ mod tests { let retrieved = storage.get_by_id("workflow-test".to_string()).await?; assert_eq!(retrieved.id, "workflow-test"); - // 5. Try to update it - should return NotSupported error + // 5. Update it - should now succeed let mut updated = retrieved.clone(); updated.url = "https://updated.example.com".to_string(); let update_result = storage.update("workflow-test".to_string(), updated).await; - assert!(update_result.is_err()); - match update_result.unwrap_err() { - RepositoryError::NotSupported(_) => { - // Expected error - } - _ => panic!("Expected NotSupported error"), - } + assert!(update_result.is_ok()); + let updated_notification = update_result.unwrap(); + assert_eq!(updated_notification.url, "https://updated.example.com"); + + // 6. Verify the update persisted + let after_update = storage.get_by_id("workflow-test".to_string()).await?; + assert_eq!(after_update.url, "https://updated.example.com"); - // 6. Try to delete it - should return NotSupported error + // 7. Delete it - should now succeed let delete_result = storage.delete_by_id("workflow-test".to_string()).await; - assert!(delete_result.is_err()); - match delete_result.unwrap_err() { - RepositoryError::NotSupported(_) => { - // Expected error - } - _ => panic!("Expected NotSupported error"), - } + assert!(delete_result.is_ok()); - // 7. Verify it still exists since delete is not supported - assert!(storage.has_entries().await?); - assert_eq!(storage.count().await?, 1); + // 8. Verify it's gone + assert!(!storage.has_entries().await?); + assert_eq!(storage.count().await?, 0); let result = storage.get_by_id("workflow-test".to_string()).await; - assert!(result.is_ok()); + assert!(matches!(result, Err(RepositoryError::NotFound(_)))); Ok(()) } diff --git a/src/repositories/notification/notification_in_memory.rs b/src/repositories/notification/notification_in_memory.rs index d29e52e57..81db160c0 100644 --- a/src/repositories/notification/notification_in_memory.rs +++ b/src/repositories/notification/notification_in_memory.rs @@ -1,13 +1,10 @@ //! This module defines an in-memory notification repository for managing -//! notifications. It provides functionality to create, retrieve, and list -//! notifications, while update and delete operations are not supported. -//! The repository is implemented using a `Mutex`-protected `HashMap` to -//! ensure thread safety in asynchronous contexts. Additionally, it includes -//! conversion implementations for `NotificationFileConfig` to `NotificationRepoModel`. +//! notifications. It provides full CRUD functionality including create, retrieve, +//! update, delete, and list operations. The repository is implemented using a +//! `Mutex`-protected `HashMap` to ensure thread safety in asynchronous contexts. use crate::{ - config::{NotificationFileConfig, NotificationFileConfigType}, - models::{NotificationRepoModel, NotificationType as ModelNotificationType, RepositoryError}, + models::{NotificationConfig, NotificationRepoModel, RepositoryError}, repositories::*, }; use async_trait::async_trait; @@ -84,14 +81,40 @@ impl Repository for InMemoryNotificationRepositor #[allow(clippy::map_entry)] async fn update( &self, - _id: String, - _relayer: NotificationRepoModel, + id: String, + notification: NotificationRepoModel, ) -> Result { - Err(RepositoryError::NotSupported("Not supported".to_string())) + let mut store = Self::acquire_lock(&self.store).await?; + + // Check if notification exists + if !store.contains_key(&id) { + return Err(RepositoryError::NotFound(format!( + "Notification with ID {} not found", + id + ))); + } + + if id != notification.id { + return Err(RepositoryError::InvalidData(format!( + "ID mismatch: URL parameter '{}' does not match entity ID '{}'", + id, notification.id + ))); + } + + store.insert(id, notification.clone()); + Ok(notification) } - async fn delete_by_id(&self, _id: String) -> Result<(), RepositoryError> { - Err(RepositoryError::NotSupported("Not supported".to_string())) + async fn delete_by_id(&self, id: String) -> Result<(), RepositoryError> { + let mut store = Self::acquire_lock(&self.store).await?; + + match store.remove(&id) { + Some(_) => Ok(()), + None => Err(RepositoryError::NotFound(format!( + "Notification with ID {} not found", + id + ))), + } } async fn list_all(&self) -> Result, RepositoryError> { @@ -142,38 +165,33 @@ impl Repository for InMemoryNotificationRepositor } } -impl TryFrom for NotificationRepoModel { +impl TryFrom for NotificationRepoModel { type Error = ConversionError; - fn try_from(config: NotificationFileConfig) -> Result { + fn try_from(config: NotificationConfig) -> Result { + let signing_key = config.get_signing_key().map_err(|e| { + ConversionError::InvalidConfig(format!("Failed to get signing key: {}", e)) + })?; + Ok(NotificationRepoModel { id: config.id.clone(), url: config.url.clone(), - notification_type: ModelNotificationType::try_from(&config.r#type)?, - signing_key: config.get_signing_key(), + notification_type: config.r#type, + signing_key, }) } } - -impl TryFrom<&NotificationFileConfigType> for ModelNotificationType { - type Error = ConversionError; - - fn try_from(config: &NotificationFileConfigType) -> Result { - match config { - NotificationFileConfigType::Webhook => Ok(ModelNotificationType::Webhook), - } - } -} - #[cfg(test)] mod tests { + use crate::models::NotificationType; + use super::*; fn create_test_notification(id: String) -> NotificationRepoModel { NotificationRepoModel { id: id.clone(), url: "http://localhost".to_string(), - notification_type: ModelNotificationType::Webhook, + notification_type: NotificationType::Webhook, signing_key: None, } } @@ -201,8 +219,25 @@ mod tests { let repo = InMemoryNotificationRepository::new(); let notification = create_test_notification("test".to_string()); - let result = repo.update("test".to_string(), notification).await; - assert!(matches!(result, Err(RepositoryError::NotSupported(_)))); + // First create the notification + repo.create(notification.clone()).await.unwrap(); + + // Update the notification + let mut updated_notification = notification.clone(); + updated_notification.url = "http://updated.example.com".to_string(); + + let result = repo + .update("test".to_string(), updated_notification.clone()) + .await; + assert!(result.is_ok()); + + let updated = result.unwrap(); + assert_eq!(updated.id, "test"); + assert_eq!(updated.url, "http://updated.example.com"); + + // Verify the update persisted + let stored = repo.get_by_id("test".to_string()).await.unwrap(); + assert_eq!(stored.url, "http://updated.example.com"); } #[actix_web::test] @@ -224,7 +259,7 @@ mod tests { let notification = create_test_notification("test".to_string()); let result = repo.update("test2".to_string(), notification).await; - assert!(matches!(result, Err(RepositoryError::NotSupported(_)))); + assert!(matches!(result, Err(RepositoryError::NotFound(_)))); } #[actix_web::test] @@ -258,4 +293,83 @@ mod tests { repo.drop_all_entries().await.unwrap(); assert!(!repo.has_entries().await.unwrap()); } + + #[actix_web::test] + async fn test_delete_notification() { + let repo = InMemoryNotificationRepository::new(); + let notification = create_test_notification("test".to_string()); + + // Create the notification first + repo.create(notification.clone()).await.unwrap(); + assert_eq!(repo.count().await.unwrap(), 1); + + // Delete the notification + let result = repo.delete_by_id("test".to_string()).await; + assert!(result.is_ok()); + + // Verify it's gone + assert_eq!(repo.count().await.unwrap(), 0); + let get_result = repo.get_by_id("test".to_string()).await; + assert!(matches!(get_result, Err(RepositoryError::NotFound(_)))); + } + + #[actix_web::test] + async fn test_delete_nonexistent_notification() { + let repo = InMemoryNotificationRepository::new(); + + let result = repo.delete_by_id("nonexistent".to_string()).await; + assert!(matches!(result, Err(RepositoryError::NotFound(_)))); + } + + #[actix_web::test] + async fn test_update_with_id_mismatch() { + let repo = InMemoryNotificationRepository::new(); + let notification = create_test_notification("test".to_string()); + + // Create the notification first + repo.create(notification.clone()).await.unwrap(); + + // Try to update with mismatched ID + let mut updated_notification = notification.clone(); + updated_notification.id = "different-id".to_string(); + + let result = repo.update("test".to_string(), updated_notification).await; + assert!(matches!(result, Err(RepositoryError::InvalidData(_)))); + } + + #[actix_web::test] + async fn test_update_delete_integration() { + let repo = InMemoryNotificationRepository::new(); + let notification1 = create_test_notification("test1".to_string()); + let notification2 = create_test_notification("test2".to_string()); + + // Create two notifications + repo.create(notification1.clone()).await.unwrap(); + repo.create(notification2.clone()).await.unwrap(); + assert_eq!(repo.count().await.unwrap(), 2); + + // Update the first notification + let mut updated_notification1 = notification1.clone(); + updated_notification1.url = "http://updated.example.com".to_string(); + + let update_result = repo + .update("test1".to_string(), updated_notification1) + .await; + assert!(update_result.is_ok()); + + // Verify the update + let stored = repo.get_by_id("test1".to_string()).await.unwrap(); + assert_eq!(stored.url, "http://updated.example.com"); + + // Delete the second notification + let delete_result = repo.delete_by_id("test2".to_string()).await; + assert!(delete_result.is_ok()); + + // Verify final state + assert_eq!(repo.count().await.unwrap(), 1); + let remaining = repo.list_all().await.unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, "test1"); + assert_eq!(remaining[0].url, "http://updated.example.com"); + } } From 186e70f25292d4d971caa8895e1e344ff8aead0e Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 15 Jul 2025 12:51:29 +0200 Subject: [PATCH 09/59] chore: fix openapi generation --- src/api/routes/docs/notification_docs.rs | 5 +++++ src/openapi.rs | 9 +++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/api/routes/docs/notification_docs.rs b/src/api/routes/docs/notification_docs.rs index 909388974..59ba10566 100644 --- a/src/api/routes/docs/notification_docs.rs +++ b/src/api/routes/docs/notification_docs.rs @@ -57,6 +57,7 @@ use crate::models::{ ) ) )] +#[allow(dead_code)] fn doc_list_notifications() {} /// Retrieves details of a specific notification by ID. @@ -119,6 +120,7 @@ fn doc_list_notifications() {} ) ) )] +#[allow(dead_code)] fn doc_get_notification() {} /// Creates a new notification. @@ -179,6 +181,7 @@ fn doc_get_notification() {} ) ) )] +#[allow(dead_code)] fn doc_create_notification() {} /// Updates an existing notification. @@ -242,6 +245,7 @@ fn doc_create_notification() {} ) ) )] +#[allow(dead_code)] fn doc_update_notification() {} /// Deletes a notification by ID. @@ -309,4 +313,5 @@ fn doc_update_notification() {} ) ) )] +#[allow(dead_code)] fn doc_delete_notification() {} diff --git a/src/openapi.rs b/src/openapi.rs index e33abaf93..d385b00de 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -1,6 +1,6 @@ use crate::{ api::routes::{ - docs::{plugin_docs, relayer_docs}, + docs::{notification_docs, plugin_docs, relayer_docs}, health, metrics, }, domain, models, @@ -63,7 +63,12 @@ impl Modify for SecurityAddon { metrics::list_metrics, metrics::metric_detail, metrics::scrape_metrics, - plugin_docs::doc_call_plugin + plugin_docs::doc_call_plugin, + notification_docs::doc_list_notifications, + notification_docs::doc_get_notification, + notification_docs::doc_create_notification, + notification_docs::doc_update_notification, + notification_docs::doc_delete_notification, ), components(schemas( models::RelayerResponse, From 034007f07b11f47a492971cd4d983d4368b52ae7 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 15 Jul 2025 13:17:12 +0200 Subject: [PATCH 10/59] chore: improvements --- src/api/controllers/notifications.rs | 62 ++-------------------- src/api/routes/notifications.rs | 78 ++++++++++++++++++++++++++++ src/repositories/notification/mod.rs | 1 - 3 files changed, 82 insertions(+), 59 deletions(-) diff --git a/src/api/controllers/notifications.rs b/src/api/controllers/notifications.rs index b502b577b..3e4e9027d 100644 --- a/src/api/controllers/notifications.rs +++ b/src/api/controllers/notifications.rs @@ -164,17 +164,12 @@ where .get_by_id(notification_id.clone()) .await?; - // Convert to core domain model - let existing_core = Notification::from(existing_repo_model); + // Apply update (with validation) + let updated = Notification::from(existing_repo_model).apply_update(&request)?; - // Apply update using domain-first approach (with validation) - let updated_core = existing_core.apply_update(&request)?; - - // Convert back to repo model and save - let updated_repo_model = NotificationRepoModel::from(updated_core); let saved_notification = state .notification_repository - .update(notification_id, updated_repo_model) + .update(notification_id, NotificationRepoModel::from(updated)) .await?; let response = NotificationResponse::from(saved_notification); @@ -355,11 +350,7 @@ mod tests { assert!(api_response.success); let data = api_response.data.unwrap(); - assert_eq!(data.len(), 2); // Should return 2 items for page 2 with per_page=2 - - // Verify the items are properly sorted (newest first) - assert!(data.iter().all(|n| n.r#type == NotificationType::Webhook)); - assert!(data.iter().all(|n| n.url == "https://example.com/webhook")); + assert_eq!(data.len(), 2); } #[actix_web::test] @@ -627,51 +618,6 @@ mod tests { assert_eq!(response_2.status(), 201); } - #[actix_web::test] - async fn test_update_notification_repository_integration() { - let app_state = create_mock_app_state(None, None, None, None, None).await; - - // Create a test notification - let notification = create_test_notification_model("test-notification"); - app_state - .notification_repository - .create(notification) - .await - .unwrap(); - - let update_request = create_test_notification_update_request(); - let result = update_notification( - "test-notification".to_string(), - update_request, - ThinData(app_state), - ) - .await; - - assert!(result.is_ok()); - let response = result.unwrap(); - assert_eq!(response.status(), 200); - } - - #[actix_web::test] - async fn test_delete_notification_repository_integration() { - let app_state = create_mock_app_state(None, None, None, None, None).await; - - // Create a test notification - let notification = create_test_notification_model("test-notification"); - app_state - .notification_repository - .create(notification) - .await - .unwrap(); - - let result = - delete_notification("test-notification".to_string(), ThinData(app_state)).await; - - assert!(result.is_ok()); - let response = result.unwrap(); - assert_eq!(response.status(), 200); - } - #[actix_web::test] async fn test_create_notification_validation_error() { let app_state = create_mock_app_state(None, None, None, None, None).await; diff --git a/src/api/routes/notifications.rs b/src/api/routes/notifications.rs index 86755590c..ccb6971e3 100644 --- a/src/api/routes/notifications.rs +++ b/src/api/routes/notifications.rs @@ -65,3 +65,81 @@ pub fn init(cfg: &mut web::ServiceConfig) { .service(update_notification) .service(delete_notification); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::mocks::mockutils::create_mock_app_state; + use actix_web::{http::StatusCode, test, web, App}; + + #[actix_web::test] + async fn test_notification_routes_are_registered() { + // Arrange - Create app with notification routes + let app_state = create_mock_app_state(None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test GET /notifications - should not return 404 (route exists) + let req = test::TestRequest::get().uri("/notifications").to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /notifications route not registered" + ); + + // Test GET /notifications/{id} - should not return 404 + let req = test::TestRequest::get() + .uri("/notifications/test-id") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /notifications/{{id}} route not registered" + ); + + // Test POST /notifications - should not return 404 + let req = test::TestRequest::post() + .uri("/notifications") + .set_json(serde_json::json!({ + "id": "test", + "type": "webhook", + "url": "https://example.com" + })) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "POST /notifications route not registered" + ); + + // Test PATCH /notifications/{id} - should not return 404 + let req = test::TestRequest::patch() + .uri("/notifications/test-id") + .set_json(serde_json::json!({"url": "https://updated.com"})) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "PATCH /notifications/{{id}} route not registered" + ); + + // Test DELETE /notifications/{id} - should not return 404 + let req = test::TestRequest::delete() + .uri("/notifications/test-id") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "DELETE /notifications/{{id}} route not registered" + ); + } +} diff --git a/src/repositories/notification/mod.rs b/src/repositories/notification/mod.rs index 77c7937e5..e5212124f 100644 --- a/src/repositories/notification/mod.rs +++ b/src/repositories/notification/mod.rs @@ -319,7 +319,6 @@ mod tests { let retrieved = storage.get_by_id("test-notification".to_string()).await?; assert_eq!(retrieved.id, "test-notification"); - // Delete it - should now succeed let result = storage.delete_by_id("test-notification".to_string()).await; assert!(result.is_ok()); From f1f21cbf915993aeeb492691a403d4feb7f1d2b1 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 15 Jul 2025 15:08:52 +0200 Subject: [PATCH 11/59] chore: improvements --- src/api/controllers/notifications.rs | 6 +-- src/config/config_file/mod.rs | 1 - src/models/notification/config.rs | 10 ++-- src/models/notification/mod.rs | 6 +-- .../notification/{core.rs => notification.rs} | 48 +++++++++---------- src/models/notification/repository.rs | 3 +- src/models/notification/request.rs | 37 ++++++-------- 7 files changed, 50 insertions(+), 61 deletions(-) rename src/models/notification/{core.rs => notification.rs} (90%) diff --git a/src/api/controllers/notifications.rs b/src/api/controllers/notifications.rs index 3e4e9027d..07c6d0a59 100644 --- a/src/api/controllers/notifications.rs +++ b/src/api/controllers/notifications.rs @@ -10,15 +10,15 @@ use crate::{ jobs::JobProducerTrait, models::{ - ApiError, ApiResponse, NetworkRepoModel, Notification, NotificationCreateRequest, - NotificationRepoModel, NotificationResponse, NotificationUpdateRequest, PaginationMeta, - PaginationQuery, RelayerRepoModel, SignerRepoModel, ThinDataAppState, TransactionRepoModel, + ApiError, ApiResponse, NetworkRepoModel, Notification, NotificationCreateRequest, NotificationUpdateRequest, NotificationResponse, + NotificationRepoModel, PaginationMeta, PaginationQuery, RelayerRepoModel, SignerRepoModel, ThinDataAppState, TransactionRepoModel, }, repositories::{ NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, TransactionCounterTrait, TransactionRepository, }, }; + use actix_web::HttpResponse; use eyre::Result; diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index 8dff6f0e2..56f058512 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -1661,7 +1661,6 @@ mod tests { let result = config.validate(); assert!(result.is_err()); - // With validator-based validation, empty ID now triggers InvalidFormat error let error = result.unwrap_err(); assert!(matches!(error, ConfigFileError::InvalidFormat(_))); } diff --git a/src/models/notification/config.rs b/src/models/notification/config.rs index 4890b8efe..1482a2e28 100644 --- a/src/models/notification/config.rs +++ b/src/models/notification/config.rs @@ -1,6 +1,11 @@ +//! This module contains the configuration file representation of a notification. +//! It also contains the validation logic for the notification configuration. +//! It also contains the conversion logic to and from the core notification model. +//! It also contains the collection of notification configurations. + use crate::{ config::ConfigFileError, - models::{notification::core::*, PlainOrEnvValue, SecretString}, + models::{notification::Notification, NotificationType, PlainOrEnvValue, SecretString, NotificationValidationError}, }; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -29,9 +34,6 @@ impl TryFrom for Notification { NotificationValidationError::EmptyId => { ConfigFileError::MissingField("notification id".into()) } - NotificationValidationError::IdTooLong => { - ConfigFileError::InvalidFormat("Notification ID too long".into()) - } NotificationValidationError::InvalidIdFormat => { ConfigFileError::InvalidFormat("Invalid notification ID format".into()) } diff --git a/src/models/notification/mod.rs b/src/models/notification/mod.rs index 4e5f3520f..6e0353800 100644 --- a/src/models/notification/mod.rs +++ b/src/models/notification/mod.rs @@ -1,5 +1,5 @@ -mod core; -pub use core::*; +mod notification; +pub use notification::*; mod config; pub use config::*; @@ -16,5 +16,3 @@ pub use repository::NotificationRepoModel; mod webhook_notification; pub use webhook_notification::*; -// Legacy re-exports for backward compatibility -pub use core::NotificationType; diff --git a/src/models/notification/core.rs b/src/models/notification/notification.rs similarity index 90% rename from src/models/notification/core.rs rename to src/models/notification/notification.rs index a0e03d01a..6dbd3d95c 100644 --- a/src/models/notification/core.rs +++ b/src/models/notification/notification.rs @@ -1,31 +1,30 @@ +//! Notification domain model and business logic. +//! +//! This module provides the central `Notification` type that represents notifications +//! throughout the relayer system, including: +//! +//! - **Domain Model**: Core `Notification` struct with validation +//! - **Business Logic**: Update operations and validation rules +//! - **Error Handling**: Comprehensive validation error types +//! - **Interoperability**: Conversions between API, config, and repository representations +//! +//! The notification model supports webhook-based notifications with optional message signing. use crate::{ constants::{ID_REGEX, MINIMUM_SECRET_VALUE_LENGTH}, - models::SecretString, + models::{NotificationUpdateRequest, SecretString}, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use validator::{Validate, ValidationError}; -/// Core notification type enum used by both config file and API +/// Notification type enum used by both config file and API #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] #[serde(rename_all = "lowercase")] pub enum NotificationType { Webhook, } -/// Request structure for updating an existing notification -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -pub struct NotificationUpdateRequest { - pub r#type: Option, - pub url: Option, - /// Optional signing key for securing webhook notifications. - /// - None: don't change the existing signing key - /// - Some(""): remove the signing key - /// - Some("key"): set the signing key to the provided value - pub signing_key: Option, -} - -/// Core notification model used by both config file and API +/// Notification model used by both config file and API #[derive(Debug, Clone, Serialize, Deserialize, Validate)] pub struct Notification { #[validate( @@ -76,8 +75,11 @@ impl Notification { for (field, errors) in validation_errors.field_errors() { if let Some(error) = errors.first() { let field_str = field.as_ref(); + println!("field_str: {}", field_str); + println!("error.code: {}", error.code); + println!("error.message: {:?}", error.message); return match (field_str, error.code.as_ref()) { - ("id", "length") => NotificationValidationError::IdTooLong, + ("id", "length") => NotificationValidationError::InvalidIdFormat, ("id", "regex") => NotificationValidationError::InvalidIdFormat, ("url", _) => NotificationValidationError::InvalidUrl, ("signing_key", "signing_key_too_short") => { @@ -141,9 +143,7 @@ impl Notification { pub enum NotificationValidationError { #[error("Notification ID cannot be empty")] EmptyId, - #[error("Notification ID must be at most 36 characters long")] - IdTooLong, - #[error("Notification ID must contain only letters, numbers, dashes and underscores")] + #[error("Notification ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long")] InvalidIdFormat, #[error("Notification URL cannot be empty")] EmptyUrl, @@ -166,11 +166,8 @@ impl From for crate::models::ApiError { ApiError::BadRequest(match error { NotificationValidationError::EmptyId => "ID cannot be empty".to_string(), - NotificationValidationError::IdTooLong => { - "ID must be at most 36 characters long".to_string() - } NotificationValidationError::InvalidIdFormat => { - "ID must contain only letters, numbers, dashes and underscores".to_string() + "ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long".to_string() } NotificationValidationError::EmptyUrl => "URL cannot be empty".to_string(), NotificationValidationError::InvalidUrl => "Invalid URL format".to_string(), @@ -206,10 +203,9 @@ mod tests { None, ); - // With validator, empty string will trigger length validation error assert!(matches!( notification.validate(), - Err(NotificationValidationError::IdTooLong) // validator maps length errors to this + Err(NotificationValidationError::InvalidIdFormat) )); } @@ -224,7 +220,7 @@ mod tests { assert!(matches!( notification.validate(), - Err(NotificationValidationError::IdTooLong) + Err(NotificationValidationError::InvalidIdFormat) )); } diff --git a/src/models/notification/repository.rs b/src/models/notification/repository.rs index 03b8df4c0..ded64aaf6 100644 --- a/src/models/notification/repository.rs +++ b/src/models/notification/repository.rs @@ -1,6 +1,7 @@ -use crate::models::notification::core::*; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::models::{notification::Notification, NotificationType, NotificationValidationError}; + // Repository model is now just an alias to the core model with additional traits #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] diff --git a/src/models/notification/request.rs b/src/models/notification/request.rs index 7a4ac2afe..508f66509 100644 --- a/src/models/notification/request.rs +++ b/src/models/notification/request.rs @@ -1,4 +1,4 @@ -use crate::models::{notification::core::*, ApiError, NotificationType, SecretString}; +use crate::models::{ ApiError, notification::Notification, NotificationType, SecretString}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -12,6 +12,19 @@ pub struct NotificationCreateRequest { pub signing_key: Option, } +/// Request structure for updating an existing notification +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +pub struct NotificationUpdateRequest { + pub r#type: Option, + pub url: Option, + /// Optional signing key for securing webhook notifications. + /// - None: don't change the existing signing key + /// - Some(""): remove the signing key + /// - Some("key"): set the signing key to the provided value + pub signing_key: Option, +} + + impl TryFrom for Notification { type Error = ApiError; @@ -21,32 +34,12 @@ impl TryFrom for Notification { let notification = Notification::new(request.id, request.r#type, request.url, signing_key); // Validate using core validation logic - notification.validate().map_err(|e| { - ApiError::BadRequest(match e { - NotificationValidationError::EmptyId => "ID cannot be empty".to_string(), - NotificationValidationError::IdTooLong => { - "ID must be at most 36 characters long".to_string() - } - NotificationValidationError::InvalidIdFormat => { - "ID must contain only letters, numbers, dashes and underscores".to_string() - } - NotificationValidationError::EmptyUrl => "URL cannot be empty".to_string(), - NotificationValidationError::InvalidUrl => "Invalid URL format".to_string(), - NotificationValidationError::SigningKeyTooShort(min_len) => { - format!("Signing key must be at least {} characters long", min_len) - } - }) - })?; + notification.validate().map_err(ApiError::from)?; Ok(notification) } } -// NotificationUpdateRequest is now a pure data structure without business logic -// Business logic has been moved to the core Notification::apply_update method - -// Note: From for NotificationRepoModel is implemented in repository.rs - #[cfg(test)] mod tests { use crate::models::NotificationRepoModel; From 7bf8cded15352bd29b9f4d676aba5cad95e27ddc Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 15 Jul 2025 15:16:22 +0200 Subject: [PATCH 12/59] chore: improvements --- src/models/notification/config.rs | 15 ++++++++++----- src/models/notification/notification.rs | 3 --- src/models/notification/repository.rs | 13 +++++++++++-- src/models/notification/request.rs | 11 +++++++++++ src/models/notification/response.rs | 8 ++++++++ 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/models/notification/config.rs b/src/models/notification/config.rs index 1482a2e28..3a8bec646 100644 --- a/src/models/notification/config.rs +++ b/src/models/notification/config.rs @@ -1,8 +1,13 @@ -//! This module contains the configuration file representation of a notification. -//! It also contains the validation logic for the notification configuration. -//! It also contains the conversion logic to and from the core notification model. -//! It also contains the collection of notification configurations. - +//! Configuration file representation and parsing for notifications. +//! +//! This module handles the configuration file format for notifications, providing: +//! +//! - **Config Models**: Structures that match the configuration file schema +//! - **Validation**: Config-specific validation rules and constraints +//! - **Conversions**: Bidirectional mapping between config and domain models +//! - **Collections**: Container types for managing multiple notification configurations +//! +//! Used primarily during application startup to parse notification settings from config files. use crate::{ config::ConfigFileError, models::{notification::Notification, NotificationType, PlainOrEnvValue, SecretString, NotificationValidationError}, diff --git a/src/models/notification/notification.rs b/src/models/notification/notification.rs index 6dbd3d95c..bb5dc80ec 100644 --- a/src/models/notification/notification.rs +++ b/src/models/notification/notification.rs @@ -75,9 +75,6 @@ impl Notification { for (field, errors) in validation_errors.field_errors() { if let Some(error) = errors.first() { let field_str = field.as_ref(); - println!("field_str: {}", field_str); - println!("error.code: {}", error.code); - println!("error.message: {:?}", error.message); return match (field_str, error.code.as_ref()) { ("id", "length") => NotificationValidationError::InvalidIdFormat, ("id", "regex") => NotificationValidationError::InvalidIdFormat, diff --git a/src/models/notification/repository.rs b/src/models/notification/repository.rs index ded64aaf6..047363671 100644 --- a/src/models/notification/repository.rs +++ b/src/models/notification/repository.rs @@ -1,9 +1,18 @@ +//! Repository layer models and data persistence for notifications. +//! +//! This module provides the data layer representation of notifications, including: +//! +//! - **Repository Models**: Data structures optimized for storage and retrieval +//! - **Data Conversions**: Mapping between domain objects and repository representations +//! - **Persistence Logic**: Storage-specific validation and constraints +//! +//! Acts as the bridge between the domain layer and actual data storage implementations +//! (in-memory, Redis, etc.), ensuring consistent data representation across repositories. + use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::models::{notification::Notification, NotificationType, NotificationValidationError}; - -// Repository model is now just an alias to the core model with additional traits #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] pub struct NotificationRepoModel { pub id: String, diff --git a/src/models/notification/request.rs b/src/models/notification/request.rs index 508f66509..51bbacaa9 100644 --- a/src/models/notification/request.rs +++ b/src/models/notification/request.rs @@ -1,3 +1,14 @@ +//! API request models and validation for notification endpoints. +//! +//! This module handles incoming HTTP requests for notification operations, providing: +//! +//! - **Request Models**: Structures for creating and updating notifications via API +//! - **Input Validation**: Sanitization and validation of user-provided data +//! - **Domain Conversion**: Transformation from API requests to domain objects +//! +//! Serves as the entry point for notification data from external clients, ensuring +//! all input is properly validated before reaching the core business logic. + use crate::models::{ ApiError, notification::Notification, NotificationType, SecretString}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; diff --git a/src/models/notification/response.rs b/src/models/notification/response.rs index 71e92e293..ab3a14b22 100644 --- a/src/models/notification/response.rs +++ b/src/models/notification/response.rs @@ -1,3 +1,11 @@ +//! This module handles outgoing HTTP responses for notification operations, providing: +//! +//! - **Response Models**: Structures for representing notification data in API responses +//! - **Security Handling**: Obfuscation of sensitive data (e.g., signing keys) +//! - **Serialization**: Conversion to JSON format for HTTP responses +//! +//! Serves as the output format for notification data to external clients, ensuring +//! all sensitive information is properly masked and formatted correctly. use crate::models::{NotificationRepoModel, NotificationType}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; From e834a5de214a4cf08465f92dcd688219a728d414 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 15 Jul 2025 15:57:23 +0200 Subject: [PATCH 13/59] chore: impr --- src/api/controllers/notifications.rs | 17 ++++++++-------- src/models/notification/config.rs | 5 ++++- src/models/notification/mod.rs | 1 - src/models/notification/repository.rs | 2 +- src/models/notification/request.rs | 29 +++++++++++++++------------ 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/api/controllers/notifications.rs b/src/api/controllers/notifications.rs index 07c6d0a59..29f749c47 100644 --- a/src/api/controllers/notifications.rs +++ b/src/api/controllers/notifications.rs @@ -10,8 +10,9 @@ use crate::{ jobs::JobProducerTrait, models::{ - ApiError, ApiResponse, NetworkRepoModel, Notification, NotificationCreateRequest, NotificationUpdateRequest, NotificationResponse, - NotificationRepoModel, PaginationMeta, PaginationQuery, RelayerRepoModel, SignerRepoModel, ThinDataAppState, TransactionRepoModel, + ApiError, ApiResponse, NetworkRepoModel, Notification, NotificationCreateRequest, + NotificationRepoModel, NotificationResponse, NotificationUpdateRequest, PaginationMeta, + PaginationQuery, RelayerRepoModel, SignerRepoModel, ThinDataAppState, TransactionRepoModel, }, repositories::{ NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, @@ -230,8 +231,8 @@ mod tests { /// Helper function to create a test notification create request fn create_test_notification_create_request(id: &str) -> NotificationCreateRequest { NotificationCreateRequest { - id: id.to_string(), - r#type: NotificationType::Webhook, + id: Some(id.to_string()), + r#type: Some(NotificationType::Webhook), url: "https://example.com/webhook".to_string(), signing_key: Some("a".repeat(32)), // 32 chars minimum } @@ -427,8 +428,8 @@ mod tests { let app_state = create_mock_app_state(None, None, None, None, None).await; let request = NotificationCreateRequest { - id: "new-notification".to_string(), - r#type: NotificationType::Webhook, + id: Some("new-notification".to_string()), + r#type: Some(NotificationType::Webhook), url: "https://example.com/webhook".to_string(), signing_key: None, }; @@ -624,8 +625,8 @@ mod tests { // Create a request with only invalid ID to make test deterministic let request = NotificationCreateRequest { - id: "invalid@id".to_string(), // Invalid characters - r#type: NotificationType::Webhook, + id: Some("invalid@id".to_string()), // Invalid characters + r#type: Some(NotificationType::Webhook), url: "https://valid.example.com/webhook".to_string(), // Valid URL signing_key: Some("a".repeat(32)), // Valid signing key }; diff --git a/src/models/notification/config.rs b/src/models/notification/config.rs index 3a8bec646..b49598bb7 100644 --- a/src/models/notification/config.rs +++ b/src/models/notification/config.rs @@ -10,7 +10,10 @@ //! Used primarily during application startup to parse notification settings from config files. use crate::{ config::ConfigFileError, - models::{notification::Notification, NotificationType, PlainOrEnvValue, SecretString, NotificationValidationError}, + models::{ + notification::Notification, NotificationType, NotificationValidationError, PlainOrEnvValue, + SecretString, + }, }; use serde::{Deserialize, Serialize}; use std::collections::HashSet; diff --git a/src/models/notification/mod.rs b/src/models/notification/mod.rs index 6e0353800..b1285b356 100644 --- a/src/models/notification/mod.rs +++ b/src/models/notification/mod.rs @@ -15,4 +15,3 @@ pub use repository::NotificationRepoModel; mod webhook_notification; pub use webhook_notification::*; - diff --git a/src/models/notification/repository.rs b/src/models/notification/repository.rs index 047363671..3cbdd6f34 100644 --- a/src/models/notification/repository.rs +++ b/src/models/notification/repository.rs @@ -9,9 +9,9 @@ //! Acts as the bridge between the domain layer and actual data storage implementations //! (in-memory, Redis, etc.), ensuring consistent data representation across repositories. +use crate::models::{notification::Notification, NotificationType, NotificationValidationError}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::models::{notification::Notification, NotificationType, NotificationValidationError}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] pub struct NotificationRepoModel { diff --git a/src/models/notification/request.rs b/src/models/notification/request.rs index 51bbacaa9..badf1eb38 100644 --- a/src/models/notification/request.rs +++ b/src/models/notification/request.rs @@ -9,15 +9,15 @@ //! Serves as the entry point for notification data from external clients, ensuring //! all input is properly validated before reaching the core business logic. -use crate::models::{ ApiError, notification::Notification, NotificationType, SecretString}; +use crate::models::{notification::Notification, ApiError, NotificationType, SecretString}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; /// Request structure for creating a new notification #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] pub struct NotificationCreateRequest { - pub id: String, - pub r#type: NotificationType, + pub id: Option, + pub r#type: Option, pub url: String, /// Optional signing key for securing webhook notifications pub signing_key: Option, @@ -35,14 +35,17 @@ pub struct NotificationUpdateRequest { pub signing_key: Option, } - impl TryFrom for Notification { type Error = ApiError; fn try_from(request: NotificationCreateRequest) -> Result { let signing_key = request.signing_key.map(|s| SecretString::new(&s)); + let id = request + .id + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let notification_type = request.r#type.unwrap_or(NotificationType::Webhook); - let notification = Notification::new(request.id, request.r#type, request.url, signing_key); + let notification = Notification::new(id, notification_type, request.url, signing_key); // Validate using core validation logic notification.validate().map_err(ApiError::from)?; @@ -60,8 +63,8 @@ mod tests { #[test] fn test_valid_create_request_conversion() { let request = NotificationCreateRequest { - id: "test-notification".to_string(), - r#type: NotificationType::Webhook, + id: Some("test-notification".to_string()), + r#type: Some(NotificationType::Webhook), url: "https://example.com/webhook".to_string(), signing_key: Some("a".repeat(32)), // Minimum length }; @@ -79,8 +82,8 @@ mod tests { #[test] fn test_invalid_create_request_conversion() { let request = NotificationCreateRequest { - id: "invalid@id".to_string(), // Invalid characters - r#type: NotificationType::Webhook, + id: Some("invalid@id".to_string()), // Invalid characters + r#type: Some(NotificationType::Webhook), url: "https://example.com/webhook".to_string(), signing_key: None, }; @@ -98,8 +101,8 @@ mod tests { #[test] fn test_signing_key_too_short() { let request = NotificationCreateRequest { - id: "test-notification".to_string(), - r#type: NotificationType::Webhook, + id: Some("test-notification".to_string()), + r#type: Some(NotificationType::Webhook), url: "https://example.com/webhook".to_string(), signing_key: Some("short".to_string()), // Too short }; @@ -117,8 +120,8 @@ mod tests { #[test] fn test_invalid_url() { let request = NotificationCreateRequest { - id: "test-notification".to_string(), - r#type: NotificationType::Webhook, + id: Some("test-notification".to_string()), + r#type: Some(NotificationType::Webhook), url: "not-a-url".to_string(), signing_key: None, }; From c506b553908d98fd82f32083f2069442bf681753 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 16 Jul 2025 18:02:28 +0200 Subject: [PATCH 14/59] chore: intial signers crud impl --- src/api/controllers/mod.rs | 3 + src/api/controllers/signers.rs | 566 +++++++++++ src/api/routes/mod.rs | 5 +- src/api/routes/relayer.rs | 2 +- src/api/routes/signers.rs | 250 +++++ src/bootstrap/config_processor.rs | 259 ++--- src/config/config_file/mod.rs | 59 +- src/config/config_file/signer/aws_kms.rs | 114 --- .../config_file/signer/google_cloud_kms.rs | 422 -------- src/config/config_file/signer/local.rs | 382 -------- src/config/config_file/signer/mod.rs | 879 ----------------- src/config/config_file/signer/turnkey.rs | 298 ------ src/config/config_file/signer/vault.rs | 296 ------ src/config/config_file/signer/vault_cloud.rs | 351 ------- .../config_file/signer/vault_transit.rs | 327 ------- src/models/mod.rs | 2 +- src/models/signer/config.rs | 598 ++++++++++++ src/models/signer/mod.rs | 28 +- src/models/signer/repository.rs | 902 ++++++++---------- src/models/signer/request.rs | 545 +++++++++++ src/models/signer/response.rs | 357 +++++++ src/models/signer/signer.rs | 779 +++++++++++++++ src/repositories/signer/mod.rs | 16 +- src/repositories/signer/signer_in_memory.rs | 2 +- src/repositories/signer/signer_redis.rs | 44 +- src/services/signer/evm/mod.rs | 103 +- src/services/signer/solana/mod.rs | 78 +- src/services/signer/stellar/mod.rs | 7 +- src/utils/mocks.rs | 2 +- 29 files changed, 3755 insertions(+), 3921 deletions(-) create mode 100644 src/api/controllers/signers.rs delete mode 100644 src/config/config_file/signer/aws_kms.rs delete mode 100644 src/config/config_file/signer/google_cloud_kms.rs delete mode 100644 src/config/config_file/signer/local.rs delete mode 100644 src/config/config_file/signer/mod.rs delete mode 100644 src/config/config_file/signer/turnkey.rs delete mode 100644 src/config/config_file/signer/vault.rs delete mode 100644 src/config/config_file/signer/vault_cloud.rs delete mode 100644 src/config/config_file/signer/vault_transit.rs create mode 100644 src/models/signer/config.rs create mode 100644 src/models/signer/request.rs create mode 100644 src/models/signer/response.rs create mode 100644 src/models/signer/signer.rs diff --git a/src/api/controllers/mod.rs b/src/api/controllers/mod.rs index 0639f5215..d762acfa0 100644 --- a/src/api/controllers/mod.rs +++ b/src/api/controllers/mod.rs @@ -6,7 +6,10 @@ //! //! * `relayer` - Transaction and relayer management endpoints //! * `plugin` - Plugin endpoints +//! * `notifications` - Notification management endpoints +//! * `signers` - Signer management endpoints pub mod notifications; pub mod plugin; pub mod relayer; +pub mod signers; diff --git a/src/api/controllers/signers.rs b/src/api/controllers/signers.rs new file mode 100644 index 000000000..c358dfa34 --- /dev/null +++ b/src/api/controllers/signers.rs @@ -0,0 +1,566 @@ +//! # Signers Controller +//! +//! Handles HTTP endpoints for signer operations including: +//! - Listing signers +//! - Getting signer details +//! - Creating signers +//! - Updating signers +//! - Deleting signers + +use crate::{ + jobs::JobProducerTrait, + models::{ + ApiError, ApiResponse, NetworkRepoModel, NotificationRepoModel, PaginationMeta, + PaginationQuery, RelayerRepoModel, Signer, SignerCreateRequest, SignerRepoModel, + SignerResponse, SignerUpdateRequest, ThinDataAppState, TransactionRepoModel, + }, + repositories::{ + NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, + TransactionCounterTrait, TransactionRepository, + }, +}; +use actix_web::HttpResponse; +use eyre::Result; + +/// Lists all signers with pagination support. +/// +/// # Arguments +/// +/// * `query` - The pagination query parameters. +/// * `state` - The application state containing the signer repository. +/// +/// # Returns +/// +/// A paginated list of signers. +pub async fn list_signers( + query: PaginationQuery, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ + let signers = state.signer_repository.list_paginated(query).await?; + + let mapped_signers: Vec = signers.items.into_iter().map(|s| s.into()).collect(); + + Ok(HttpResponse::Ok().json(ApiResponse::paginated( + mapped_signers, + PaginationMeta { + total_items: signers.total, + current_page: signers.page, + per_page: signers.per_page, + }, + ))) +} + +/// Retrieves details of a specific signer by ID. +/// +/// # Arguments +/// +/// * `signer_id` - The ID of the signer to retrieve. +/// * `state` - The application state containing the signer repository. +/// +/// # Returns +/// +/// The signer details or an error if not found. +pub async fn get_signer( + signer_id: String, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ + let signer = state.signer_repository.get_by_id(signer_id).await?; + + let response = SignerResponse::from(signer); + Ok(HttpResponse::Ok().json(ApiResponse::success(response))) +} + +/// Creates a new signer. +/// +/// # Arguments +/// +/// * `request` - The signer creation request. +/// * `state` - The application state containing the signer repository. +/// +/// # Returns +/// +/// The created signer or an error if creation fails. +/// +/// # Note +/// +/// This endpoint only creates the signer metadata. The actual signer configuration +/// (keys, credentials, etc.) should be provided through configuration files or +/// other secure channels. This is a security measure to prevent sensitive data +/// from being transmitted through API requests. +pub async fn create_signer( + request: SignerCreateRequest, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ + // Convert request to domain model (validates automatically and includes placeholder config) + let signer = Signer::try_from(request)?; + + // Convert domain model to repository model + let signer_model = SignerRepoModel::from(signer); + + let created_signer = state.signer_repository.create(signer_model).await?; + + let response = SignerResponse::from(created_signer); + Ok(HttpResponse::Created().json(ApiResponse::success(response))) +} + +/// Updates an existing signer. +/// +/// # Arguments +/// +/// * `signer_id` - The ID of the signer to update. +/// * `request` - The signer update request. +/// * `state` - The application state containing the signer repository. +/// +/// # Returns +/// +/// The updated signer or an error if update fails. +/// +/// # Note +/// +/// Only metadata fields (name, description) can be updated through this endpoint. +/// Signer configuration changes require secure configuration channels for security reasons. +pub async fn update_signer( + signer_id: String, + request: SignerUpdateRequest, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ + // Get the existing signer from repository + let existing_repo_model = state.signer_repository.get_by_id(signer_id.clone()).await?; + + // Convert to domain model and apply update (only metadata changes) + let existing_signer = Signer::from(existing_repo_model.clone()); + let updated_signer = existing_signer.apply_update(&request)?; + + // Create updated repository model (preserving original config) + let updated_repo_model = SignerRepoModel { + id: updated_signer.id, + config: existing_repo_model.config, // Keep original config unchanged + }; + + let saved_signer = state + .signer_repository + .update(signer_id, updated_repo_model) + .await?; + + let response = SignerResponse::from(saved_signer); + Ok(HttpResponse::Ok().json(ApiResponse::success(response))) +} + +/// Deletes a signer by ID. +/// +/// # Arguments +/// +/// * `signer_id` - The ID of the signer to delete. +/// * `state` - The application state containing the signer repository. +/// +/// # Returns +/// +/// A success response or an error if deletion fails. +/// +/// # Warning +/// +/// Deleting a signer will prevent any relayers or services that depend on it +/// from functioning properly. Ensure the signer is not in use before deletion. +pub async fn delete_signer( + signer_id: String, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ + state.signer_repository.delete_by_id(signer_id).await?; + + Ok(HttpResponse::Ok().json(ApiResponse::success("Signer deleted successfully"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + models::{LocalSignerConfig, SignerConfig, SignerType}, + utils::mocks::mockutils::create_mock_app_state, + }; + use secrets::SecretVec; + + /// Helper function to create a test signer model + fn create_test_signer_model(id: &str, signer_type: SignerType) -> SignerRepoModel { + let config = match signer_type { + SignerType::Local => SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), + }), + SignerType::AwsKms => SignerConfig::AwsKms(crate::models::AwsKmsSignerConfig { + region: Some("us-east-1".to_string()), + key_id: "test-key-id".to_string(), + }), + _ => SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), + }), + }; + + SignerRepoModel { + id: id.to_string(), + config, + } + } + + /// Helper function to create a test signer create request + fn create_test_signer_create_request( + id: Option, + signer_type: SignerType, + ) -> SignerCreateRequest { + use crate::models::{AwsKmsSignerRequestConfig, PlainSignerRequestConfig, SignerConfigRequest}; + + let config = match signer_type { + SignerType::Local => SignerConfigRequest::Local { + config: PlainSignerRequestConfig { key: "placeholder-key".to_string() } + }, + SignerType::AwsKms => SignerConfigRequest::AwsKms { + config: AwsKmsSignerRequestConfig { + region: "us-east-1".to_string(), + key_id: "test-key-id".to_string(), + }, + }, + _ => SignerConfigRequest::Local { + config: PlainSignerRequestConfig { key: "placeholder-key".to_string() } + }, // Use Local for other types in helper + }; + + SignerCreateRequest { + id, + config, + name: Some("Test Signer".to_string()), + description: Some("A test signer for development".to_string()), + } + } + + /// Helper function to create a test signer update request + fn create_test_signer_update_request() -> SignerUpdateRequest { + SignerUpdateRequest { + name: Some("Updated Signer Name".to_string()), + description: Some("Updated signer description".to_string()), + } + } + + #[actix_web::test] + async fn test_list_signers_empty() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = list_signers(query, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse> = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.len(), 0); + } + + #[actix_web::test] + async fn test_list_signers_with_data() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create test signers + let signer1 = create_test_signer_model("test-1", SignerType::Local); + let signer2 = create_test_signer_model("test-2", SignerType::AwsKms); + + app_state.signer_repository.create(signer1).await.unwrap(); + app_state.signer_repository.create(signer2).await.unwrap(); + + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = list_signers(query, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse> = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.len(), 2); + + // Check that both signers are present (order not guaranteed) + let ids: Vec<&String> = data.iter().map(|s| &s.id).collect(); + assert!(ids.contains(&&"test-1".to_string())); + assert!(ids.contains(&&"test-2".to_string())); + } + + #[actix_web::test] + async fn test_get_signer_success() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test signer + let signer = create_test_signer_model("test-signer", SignerType::Local); + app_state + .signer_repository + .create(signer.clone()) + .await + .unwrap(); + + let result = get_signer( + "test-signer".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "test-signer"); + assert_eq!(data.r#type, SignerType::Local); + } + + #[actix_web::test] + async fn test_get_signer_not_found() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let result = get_signer( + "non-existent".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(matches!(error, ApiError::NotFound(_))); + } + + #[actix_web::test] + async fn test_create_signer_test_type_success() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = create_test_signer_create_request( + Some("new-test-signer".to_string()), + SignerType::Local, + ); + + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "new-test-signer"); + assert_eq!(data.r#type, SignerType::Local); + } + + #[actix_web::test] + async fn test_create_signer_production_types_require_config() { + let production_types = vec![ + SignerType::Local, + SignerType::AwsKms, + SignerType::GoogleCloudKms, + SignerType::Vault, + SignerType::VaultTransit, + SignerType::Turnkey, + ]; + + for signer_type in production_types { + let app_state = create_mock_app_state(None, None, None, None, None).await; + let request = + create_test_signer_create_request(Some("test".to_string()), signer_type.clone()); + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!( + result.is_err(), + "Should fail for signer type: {:?}", + signer_type + ); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("require secure configuration setup")); + } else { + panic!( + "Expected BadRequest error for signer type: {:?}", + signer_type + ); + } + } + } + + #[actix_web::test] + async fn test_update_signer_success() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test signer + let signer = create_test_signer_model("test-signer", SignerType::Local); + app_state.signer_repository.create(signer).await.unwrap(); + + let update_request = create_test_signer_update_request(); + + let result = update_signer( + "test-signer".to_string(), + update_request, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "test-signer"); + // Note: name and description won't be updated in the response since + // the repository model doesn't store metadata currently + } + + #[actix_web::test] + async fn test_update_signer_not_found() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let update_request = create_test_signer_update_request(); + + let result = update_signer( + "non-existent".to_string(), + update_request, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(matches!(error, ApiError::NotFound(_))); + } + + #[actix_web::test] + async fn test_delete_signer_success() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test signer + let signer = create_test_signer_model("test-signer", SignerType::Local); + app_state.signer_repository.create(signer).await.unwrap(); + + let result = delete_signer( + "test-signer".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse<&str> = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + assert_eq!(api_response.data.unwrap(), "Signer deleted successfully"); + } + + #[actix_web::test] + async fn test_delete_signer_not_found() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let result = delete_signer( + "non-existent".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(matches!(error, ApiError::NotFound(_))); + } + + #[actix_web::test] + async fn test_signer_response_conversion() { + let signer_model = SignerRepoModel { + id: "test-id".to_string(), + config: SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), + }), + }; + + let response = SignerResponse::from(signer_model); + + assert_eq!(response.id, "test-id"); + assert_eq!(response.r#type, SignerType::Local); + } +} diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs index 7b126b5ac..95e267647 100644 --- a/src/api/routes/mod.rs +++ b/src/api/routes/mod.rs @@ -6,6 +6,8 @@ //! //! * `/health` - Health check endpoints //! * `/relayers` - Relayer management endpoints +//! * `/notifications` - Notification management endpoints +//! * `/signers` - Signer management endpoints pub mod docs; pub mod health; @@ -22,5 +24,6 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { .configure(relayer::init) .configure(plugin::init) .configure(metrics::init) - .configure(notifications::init); + .configure(notifications::init) + .configure(signers::init); } diff --git a/src/api/routes/relayer.rs b/src/api/routes/relayer.rs index be31e7539..b98ea0a70 100644 --- a/src/api/routes/relayer.rs +++ b/src/api/routes/relayer.rs @@ -244,7 +244,7 @@ mod tests { // Create test signer first let test_signer = crate::models::SignerRepoModel { id: "test-signer".to_string(), - config: crate::models::SignerConfig::Test(crate::models::LocalSignerConfig { + config: crate::models::SignerConfig::Local(crate::models::LocalSignerConfig { raw_key: secrets::SecretVec::new(32, |v| v.copy_from_slice(&[0u8; 32])), }), }; diff --git a/src/api/routes/signers.rs b/src/api/routes/signers.rs index 8b1378917..49d9b58f3 100644 --- a/src/api/routes/signers.rs +++ b/src/api/routes/signers.rs @@ -1 +1,251 @@ +//! This module defines the HTTP routes for signer operations. +//! It includes handlers for listing, retrieving, creating, updating, and deleting signers. +//! The routes are integrated with the Actix-web framework and interact with the signer controller. +use crate::{ + api::controllers::signers, + models::{DefaultAppState, PaginationQuery, SignerCreateRequest, SignerUpdateRequest}, +}; +use actix_web::{delete, get, patch, post, web, Responder}; + +/// Lists all signers with pagination support. +#[get("/signers")] +async fn list_signers( + query: web::Query, + data: web::ThinData, +) -> impl Responder { + signers::list_signers(query.into_inner(), data).await +} + +/// Retrieves details of a specific signer by ID. +#[get("/signers/{signer_id}")] +async fn get_signer( + signer_id: web::Path, + data: web::ThinData, +) -> impl Responder { + signers::get_signer(signer_id.into_inner(), data).await +} + +/// Creates a new signer. +#[post("/signers")] +async fn create_signer( + request: web::Json, + data: web::ThinData, +) -> impl Responder { + signers::create_signer(request.into_inner(), data).await +} + +/// Updates an existing signer. +#[patch("/signers/{signer_id}")] +async fn update_signer( + signer_id: web::Path, + request: web::Json, + data: web::ThinData, +) -> impl Responder { + signers::update_signer(signer_id.into_inner(), request.into_inner(), data).await +} + +/// Deletes a signer by ID. +#[delete("/signers/{signer_id}")] +async fn delete_signer( + signer_id: web::Path, + data: web::ThinData, +) -> impl Responder { + signers::delete_signer(signer_id.into_inner(), data).await +} + +/// Configures the signer routes. +pub fn init(cfg: &mut web::ServiceConfig) { + cfg.service(list_signers) + .service(get_signer) + .service(create_signer) + .service(update_signer) + .service(delete_signer); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::mocks::mockutils::create_mock_app_state; + use actix_web::{http::StatusCode, test, web, App}; + + #[actix_web::test] + async fn test_signer_routes_are_registered() { + // Arrange - Create app with signer routes + let app_state = create_mock_app_state(None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test GET /signers - should not return 404 (route exists) + let req = test::TestRequest::get().uri("/signers").to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /signers route not registered" + ); + + // Test GET /signers/{id} - should not return 404 + let req = test::TestRequest::get() + .uri("/signers/test-id") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /signers/{{id}} route not registered" + ); + + // Test POST /signers - should not return 404 + let req = test::TestRequest::post() + .uri("/signers") + .set_json(serde_json::json!({ + "id": "test", + "signer_type": "test", + "name": "Test Signer", + "description": "A test signer" + })) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "POST /signers route not registered" + ); + + // Test PATCH /signers/{id} - should not return 404 + let req = test::TestRequest::patch() + .uri("/signers/test-id") + .set_json(serde_json::json!({"name": "Updated Name"})) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "PATCH /signers/{{id}} route not registered" + ); + + // Test DELETE /signers/{id} - should not return 404 + let req = test::TestRequest::delete() + .uri("/signers/test-id") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "DELETE /signers/{{id}} route not registered" + ); + } + + #[actix_web::test] + async fn test_signer_id_path_parameter_extraction() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test various signer ID formats + let test_ids = vec![ + "simple-id", + "id-with-dashes", + "id_with_underscores", + "1234567890", + "mixed-id_123", + ]; + + for signer_id in test_ids { + // Test GET with various ID formats + let req = test::TestRequest::get() + .uri(&format!("/signers/{}", signer_id)) + .to_request(); + let resp = test::call_service(&app, req).await; + + // Should not be NOT_FOUND due to route configuration + // (may be other errors like signer not found, but route should exist) + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "Route not found for signer ID: {}", + signer_id + ); + + // Test DELETE with various ID formats + let req = test::TestRequest::delete() + .uri(&format!("/signers/{}", signer_id)) + .to_request(); + let resp = test::call_service(&app, req).await; + + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "DELETE route not found for signer ID: {}", + signer_id + ); + } + } + + #[actix_web::test] + async fn test_json_request_parsing() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test POST /signers with valid JSON + let create_request = serde_json::json!({ + "id": "test-signer", + "signer_type": "test", + "name": "Test Signer", + "description": "A test signer for development" + }); + + let req = test::TestRequest::post() + .uri("/signers") + .set_json(&create_request) + .to_request(); + let resp = test::call_service(&app, req).await; + + // Should not return 404 (route exists) or 400 for JSON parsing issues + assert_ne!(resp.status(), StatusCode::NOT_FOUND); + // JSON should parse correctly (business logic errors are separate) + + // Test PATCH /signers/{id} with valid JSON + let update_request = serde_json::json!({ + "name": "Updated Signer Name", + "description": "Updated description" + }); + + let req = test::TestRequest::patch() + .uri("/signers/test-id") + .set_json(&update_request) + .to_request(); + let resp = test::call_service(&app, req).await; + + assert_ne!(resp.status(), StatusCode::NOT_FOUND); + // JSON should parse correctly + + // Test POST with minimal valid JSON + let minimal_request = serde_json::json!({ + "signer_type": "test" + }); + + let req = test::TestRequest::post() + .uri("/signers") + .set_json(&minimal_request) + .to_request(); + let resp = test::call_service(&app, req).await; + + assert_ne!(resp.status(), StatusCode::NOT_FOUND); + // Should parse JSON successfully + } +} diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index 7cca7add3..6c9692aee 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -1,30 +1,23 @@ //! This module provides functionality for processing configuration files and populating //! repositories. -use std::{path::Path, sync::Arc}; +use std::sync::Arc; use crate::{ - config::{Config, RepositoryStorageType, ServerConfig, SignerFileConfig, SignerFileConfigEnum}, + config::{Config, RepositoryStorageType, ServerConfig}, jobs::JobProducerTrait, models::{ - AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, - GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, NetworkRepoModel, - NotificationRepoModel, PluginModel, RelayerRepoModel, SignerConfig, SignerRepoModel, - ThinDataAppState, TransactionRepoModel, TurnkeySignerConfig, VaultTransitSignerConfig, + signer::Signer, NetworkRepoModel, NotificationRepoModel, PluginModel, RelayerRepoModel, + SignerFileConfig, SignerRepoModel, ThinDataAppState, TransactionRepoModel, }, repositories::{ NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, TransactionCounterTrait, TransactionRepository, }, - services::{Signer, SignerFactory, VaultConfig, VaultService, VaultServiceTrait}, - utils::unsafe_generate_random_private_key, + services::{Signer as SignerService, SignerFactory}, }; use color_eyre::{eyre::WrapErr, Report, Result}; use futures::future::try_join_all; use log::info; -use oz_keystore::{HashicorpCloudClient, LocalClient}; - -use secrets::SecretVec; -use zeroize::Zeroizing; /// Process all plugins from the config file and store them in the repository. async fn process_plugins( @@ -63,175 +56,13 @@ where } /// Process a signer configuration from the config file and convert it into a `SignerRepoModel`. -/// -/// This function handles different types of signers including: -/// - Test signers with randomly generated keys -/// - Local signers with keys loaded from keystore files -/// - AWS KMS signers -/// - Vault signers that retrieve private keys from HashiCorp Vault -/// - Vault Cloud signers that retrieve private keys from HashiCorp Cloud -/// - Vault Transit signers that use HashiCorp Vault's Transit engine for signing async fn process_signer(signer: &SignerFileConfig) -> Result { - let signer_repo_model = match &signer.config { - SignerFileConfigEnum::Test(_) => SignerRepoModel { - id: signer.id.clone(), - config: SignerConfig::Test(LocalSignerConfig { - raw_key: SecretVec::new(32, |b| { - b.copy_from_slice(&unsafe_generate_random_private_key()) - }), - }), - }, - SignerFileConfigEnum::Local(local_signer) => { - let passphrase = local_signer.passphrase.get_value()?; - - let raw_key = SecretVec::new(32, |buffer| { - let loaded = LocalClient::load( - Path::new(&local_signer.path).to_path_buf(), - passphrase.to_str().as_str().to_string(), - ); - - buffer.copy_from_slice(&loaded); - }); - SignerRepoModel { - id: signer.id.clone(), - config: SignerConfig::Local(LocalSignerConfig { raw_key }), - } - } - SignerFileConfigEnum::AwsKms(aws_kms_config) => SignerRepoModel { - id: signer.id.clone(), - config: SignerConfig::AwsKms(AwsKmsSignerConfig { - region: aws_kms_config.region.clone(), - key_id: aws_kms_config.key_id.clone(), - }), - }, - SignerFileConfigEnum::Vault(vault_config) => { - let config = VaultConfig { - address: vault_config.address.clone(), - namespace: vault_config.namespace.clone(), - role_id: vault_config.role_id.get_value()?, - secret_id: vault_config.secret_id.get_value()?, - mount_path: vault_config - .mount_point - .clone() - .unwrap_or("secret".to_string()), - token_ttl: None, - }; - - let vault_service = VaultService::new(config); - - let raw_key = { - let hex_secret = Zeroizing::new( - vault_service - .retrieve_secret(&vault_config.key_name) - .await?, - ); - let decoded_bytes = hex::decode(hex_secret) - .map_err(|e| eyre::eyre!("Invalid hex in vault cloud secret: {}", e))?; - - SecretVec::new(decoded_bytes.len(), |buffer| { - buffer.copy_from_slice(&decoded_bytes); - }) - }; - - SignerRepoModel { - id: signer.id.clone(), - config: SignerConfig::Vault(LocalSignerConfig { raw_key }), - } - } - SignerFileConfigEnum::VaultCloud(vault_cloud_config) => { - let client = HashicorpCloudClient::new( - vault_cloud_config.client_id.clone(), - vault_cloud_config - .client_secret - .get_value()? - .to_str() - .to_string(), - vault_cloud_config.org_id.clone(), - vault_cloud_config.project_id.clone(), - vault_cloud_config.app_name.clone(), - ); - - let raw_key = { - let response = client.get_secret(&vault_cloud_config.key_name).await?; - let hex_secret = Zeroizing::new(response.secret.static_version.value.clone()); - - let decoded_bytes = hex::decode(hex_secret) - .map_err(|e| eyre::eyre!("Invalid hex in vault cloud secret: {}", e))?; + // Convert config to domain model (this validates and applies business logic) + let domain_signer = Signer::try_from(signer.clone()) + .wrap_err("Failed to convert signer config to domain model")?; - SecretVec::new(decoded_bytes.len(), |buffer| { - buffer.copy_from_slice(&decoded_bytes); - }) - }; - - SignerRepoModel { - id: signer.id.clone(), - config: SignerConfig::Vault(LocalSignerConfig { raw_key }), - } - } - SignerFileConfigEnum::VaultTransit(vault_transit_config) => SignerRepoModel { - id: signer.id.clone(), - config: SignerConfig::VaultTransit(VaultTransitSignerConfig { - key_name: vault_transit_config.key_name.clone(), - address: vault_transit_config.address.clone(), - namespace: vault_transit_config.namespace.clone(), - role_id: vault_transit_config.role_id.get_value()?, - secret_id: vault_transit_config.secret_id.get_value()?, - pubkey: vault_transit_config.pubkey.clone(), - mount_point: vault_transit_config.mount_point.clone(), - }), - }, - SignerFileConfigEnum::Turnkey(turnkey_config) => SignerRepoModel { - id: signer.id.clone(), - config: SignerConfig::Turnkey(TurnkeySignerConfig { - private_key_id: turnkey_config.private_key_id.clone(), - organization_id: turnkey_config.organization_id.clone(), - public_key: turnkey_config.public_key.clone(), - api_private_key: turnkey_config.api_private_key.get_value()?, - api_public_key: turnkey_config.api_public_key.clone(), - }), - }, - SignerFileConfigEnum::GoogleCloudKms(google_cloud_kms_config) => SignerRepoModel { - id: signer.id.clone(), - config: SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { - service_account: GoogleCloudKmsSignerServiceAccountConfig { - private_key: google_cloud_kms_config - .service_account - .private_key - .get_value()?, - client_email: google_cloud_kms_config - .service_account - .client_email - .get_value()?, - private_key_id: google_cloud_kms_config - .service_account - .private_key_id - .get_value()?, - client_id: google_cloud_kms_config.service_account.client_id.clone(), - project_id: google_cloud_kms_config.service_account.project_id.clone(), - auth_uri: google_cloud_kms_config.service_account.auth_uri.clone(), - token_uri: google_cloud_kms_config.service_account.token_uri.clone(), - client_x509_cert_url: google_cloud_kms_config - .service_account - .client_x509_cert_url - .clone(), - auth_provider_x509_cert_url: google_cloud_kms_config - .service_account - .auth_provider_x509_cert_url - .clone(), - universe_domain: google_cloud_kms_config - .service_account - .universe_domain - .clone(), - }, - key: GoogleCloudKmsSignerKeyConfig { - location: google_cloud_kms_config.key.location.clone(), - key_id: google_cloud_kms_config.key.key_id.clone(), - key_ring_id: google_cloud_kms_config.key.key_ring_id.clone(), - key_version: google_cloud_kms_config.key.key_version, - }, - }), - }, - }; + // Convert domain model to repository model for storage + let signer_repo_model = SignerRepoModel::from(domain_signer); Ok(signer_repo_model) } @@ -239,8 +70,8 @@ async fn process_signer(signer: &SignerFileConfig) -> Result { /// Process all signers from the config file and store them in the repository. /// /// For each signer in the config file: -/// 1. Process it using `process_signer` -/// 2. Store the resulting model in the repository +/// 1. Process it using `process_signer` (config -> domain -> repository) +/// 2. Store the resulting repository model /// /// This function processes signers in parallel using futures. async fn process_signers( @@ -509,17 +340,15 @@ where mod tests { use super::*; use crate::{ - config::{ - AwsKmsSignerFileConfig, ConfigFileNetworkType, GoogleCloudKmsSignerFileConfig, - KmsKeyConfig, NetworksFileConfig, PluginFileConfig, RelayerFileConfig, - ServiceAccountConfig, TestSignerFileConfig, VaultSignerFileConfig, - VaultTransitSignerFileConfig, - }, + config::{ConfigFileNetworkType, NetworksFileConfig, PluginFileConfig, RelayerFileConfig}, constants::DEFAULT_PLUGIN_TIMEOUT_SECONDS, jobs::MockJobProducerTrait, models::{ - AppState, NetworkType, NotificationConfig, NotificationType, PlainOrEnvValue, - SecretString, + AppState, AwsKmsSignerFileConfig, GoogleCloudKmsSignerFileConfig, KmsKeyFileConfig, + LocalSignerFileConfig, NetworkType, NotificationConfig, NotificationType, + PlainOrEnvValue, SecretString, ServiceAccountFileConfig, SignerConfig, + SignerFileConfig, SignerFileConfigEnum, VaultSignerFileConfig, + VaultTransitSignerFileConfig, }, repositories::{ InMemoryNetworkRepository, InMemoryNotificationRepository, InMemoryPluginRepository, @@ -587,7 +416,12 @@ mod tests { async fn test_process_signer_test() { let signer = SignerFileConfig { id: "test-signer".to_string(), - config: SignerFileConfigEnum::Test(TestSignerFileConfig {}), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), }; let result = process_signer(&signer).await; @@ -602,11 +436,11 @@ mod tests { assert_eq!(model.id, "test-signer"); match model.config { - SignerConfig::Test(config) => { + SignerConfig::Local(config) => { assert!(!config.raw_key.is_empty()); assert_eq!(config.raw_key.len(), 32); } - _ => panic!("Expected Test config"), + _ => panic!("Expected Local config"), } } @@ -661,7 +495,7 @@ mod tests { let signer = SignerFileConfig { id: "aws-kms-signer".to_string(), config: SignerFileConfigEnum::AwsKms(AwsKmsSignerFileConfig { - region: Some("us-east-1".to_string()), + region: "us-east-1".to_string(), key_id: "test-key-id".to_string(), }), }; @@ -799,11 +633,21 @@ mod tests { let signers = vec![ SignerFileConfig { id: "test-signer-1".to_string(), - config: SignerFileConfigEnum::Test(TestSignerFileConfig {}), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), }, SignerFileConfig { id: "test-signer-2".to_string(), - config: SignerFileConfigEnum::Test(TestSignerFileConfig {}), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), }, ]; @@ -1106,7 +950,12 @@ mod tests { // Create test signers let signers = vec![SignerFileConfig { id: "test-signer-1".to_string(), - config: SignerFileConfigEnum::Test(TestSignerFileConfig {}), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), }]; // Create test relayers @@ -1218,7 +1067,12 @@ mod tests { // Create test signers, relayers, and notifications let signers = vec![SignerFileConfig { id: "test-signer-1".to_string(), - config: SignerFileConfigEnum::Test(TestSignerFileConfig {}), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), }]; let relayers = vec![RelayerFileConfig { @@ -1327,7 +1181,7 @@ mod tests { let signer = SignerFileConfig { id: "gcp-kms-signer".to_string(), config: SignerFileConfigEnum::GoogleCloudKms(GoogleCloudKmsSignerFileConfig { - service_account: ServiceAccountConfig { + service_account: ServiceAccountFileConfig { private_key: PlainOrEnvValue::Plain { value: SecretString::new("-----BEGIN EXAMPLE PRIVATE KEY-----\nFAKEKEYDATA\n-----END EXAMPLE PRIVATE KEY-----\n"), }, @@ -1345,7 +1199,7 @@ mod tests { auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs".to_string(), universe_domain: "googleapis.com".to_string(), }, - key: KmsKeyConfig { + key: KmsKeyFileConfig { location: "global".to_string(), key_id: "fake-key-id".to_string(), key_ring_id: "fake-key-ring-id".to_string(), @@ -1571,7 +1425,12 @@ mod tests { Config { signers: vec![SignerFileConfig { id: "test-signer-1".to_string(), - config: SignerFileConfigEnum::Test(TestSignerFileConfig {}), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), }], relayers: vec![RelayerFileConfig { id: "test-relayer-1".to_string(), diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index 56f058512..2f46db2ed 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -23,7 +23,10 @@ //! to initialize the application components. use crate::{ config::ConfigFileError, - models::{NotificationConfig, NotificationConfigs}, + models::{ + signer::{SignerFileConfig, SignersFileConfig}, + NotificationConfig, NotificationConfigs, + }, }; use serde::{Deserialize, Serialize}; use std::{ @@ -34,9 +37,6 @@ use std::{ mod relayer; pub use relayer::*; -mod signer; -pub use signer::*; - mod plugin; pub use plugin::*; @@ -128,21 +128,6 @@ impl Config { relayer.signer_id, relayer.id )) })?; - - if let SignerFileConfigEnum::Test(_) = signer_config.config { - // ensure that only testnets are used with test signers - let network = networks - .get_network(relayer.network_type, &relayer.network) - .ok_or_else(|| ConfigFileError::InvalidNetwork { - network_type: format!("{:?}", relayer.network_type).to_lowercase(), - name: relayer.network.clone(), - })?; - if !network.is_testnet() { - return Err(ConfigFileError::TestSigner( - "Test signer type cannot be used on production networks".to_string(), - )); - } - } } Ok(()) @@ -226,7 +211,10 @@ pub fn load_config(config_file_path: &str) -> Result { #[cfg(test)] mod tests { - use crate::models::{NotificationType, PlainOrEnvValue, SecretString}; + use crate::models::{ + signer::{LocalSignerFileConfig, SignerFileConfig, SignerFileConfigEnum}, + NotificationType, PlainOrEnvValue, SecretString, + }; use std::path::Path; use super::*; @@ -244,21 +232,15 @@ mod tests { notification_id: Some("test-1".to_string()), custom_rpc_urls: None, }], - signers: vec![ - SignerFileConfig { - id: "test-1".to_string(), - config: SignerFileConfigEnum::Local(LocalSignerFileConfig { - path: "tests/utils/test_keys/unit-test-local-signer.json".to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("test"), - }, - }), - }, - SignerFileConfig { - id: "test-type".to_string(), - config: SignerFileConfigEnum::Test(TestSignerFileConfig {}), - }, - ], + signers: vec![SignerFileConfig { + id: "test-1".to_string(), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "tests/utils/test_keys/unit-test-local-signer.json".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test"), + }, + }), + }], notifications: vec![NotificationConfig { id: "test-1".to_string(), r#type: NotificationType::Webhook, @@ -1445,7 +1427,12 @@ mod tests { relayers: vec![], signers: vec![SignerFileConfig { id: "test-signer".to_string(), - config: SignerFileConfigEnum::Test(TestSignerFileConfig {}), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), }], notifications: vec![], networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig { diff --git a/src/config/config_file/signer/aws_kms.rs b/src/config/config_file/signer/aws_kms.rs deleted file mode 100644 index aaa22642a..000000000 --- a/src/config/config_file/signer/aws_kms.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Configuration for Amazon AWS KMS signer -//! -//! This module provides configuration for using Amazon AWS KMS as a signing service. -//! AWS KMS allows you to manage cryptographic keys and perform signing operations -//! without exposing private keys directly to your application. -//! -//! The configuration supports: -//! - AWS Region (aws_region) - important for region-specific key -//! - KMS Key identification (key_id) -//! -//! The AWS authentication is carried out -//! through recommended credential providers as outlined in -//! https://docs.aws.amazon.com/sdk-for-rust/latest/dg/credproviders.html -//! -//! Currently only EVM signing is supported since, as of June 2025, -//! AWS does not support ed25519 scheme - -use serde::{Deserialize, Serialize}; -use validator::Validate; - -use crate::config::{validate_with_validator, ConfigFileError, SignerConfigValidate}; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Validate)] -#[serde(deny_unknown_fields)] -pub struct AwsKmsSignerFileConfig { - pub region: Option, - #[validate(length(min = 1, message = "key_id cannot be empty"))] - pub key_id: String, -} - -impl SignerConfigValidate for AwsKmsSignerFileConfig { - fn validate(&self) -> Result<(), ConfigFileError> { - validate_with_validator(self) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_aws_kms_signer_file_config_valid() { - let config = AwsKmsSignerFileConfig { - region: Some("us-east-1".to_string()), - key_id: "test-key-id".to_string(), - }; - - assert!( - Validate::validate(&config).is_ok(), - "Config should pass basic validation" - ); - assert!( - SignerConfigValidate::validate(&config).is_ok(), - "Config should pass signer config validation" - ); - } - - #[test] - fn test_aws_signer_file_config_empty_key_id() { - let config = AwsKmsSignerFileConfig { - region: Some("us-east-1".to_string()), - key_id: "".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!( - result.is_err(), - "Config should not pass the signer config validation" - ); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("key_id")); - assert!(error_message.contains("cannot be empty"), "{:?}", e); - } - } - - #[test] - fn test_serde_deserialize() { - let json = r#" - { - "region": "us-east-1", - "key_id": "test-key-id" - } - "#; - let config: AwsKmsSignerFileConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.region.unwrap().as_str(), "us-east-1"); - assert_eq!(config.key_id.as_str(), "test-key-id"); - } - - #[test] - fn test_serde_unknown_field() { - let json = r#" - { - "region": "us-east-1", - "key_id": "test-key-id", - "unknown_field": "should cause error" - } - "#; - let result: Result = serde_json::from_str(json); - assert!(result.is_err()); - } - - #[test] - fn test_serde_serialize_deserialize() { - let config = AwsKmsSignerFileConfig { - region: Some("us-east-1".to_string()), - key_id: "test-key-id".to_string(), - }; - let serialized = serde_json::to_string(&config).unwrap(); - let deserialized: AwsKmsSignerFileConfig = serde_json::from_str(&serialized).unwrap(); - assert_eq!(config.region, deserialized.region); - assert_eq!(config.key_id, deserialized.key_id); - } -} diff --git a/src/config/config_file/signer/google_cloud_kms.rs b/src/config/config_file/signer/google_cloud_kms.rs deleted file mode 100644 index fe271898c..000000000 --- a/src/config/config_file/signer/google_cloud_kms.rs +++ /dev/null @@ -1,422 +0,0 @@ -//! Configuration for Google Cloud KMS signer -//! -//! This module provides configuration for using Google Cloud KMS as a signing mechanism. -//! Google Cloud KMS allows you to manage cryptographic keys and perform signing operations -//! without exposing private keys directly to your application. -//! -//! The configuration supports: -//! - Service account credentials (project_id, private_key_id, private_key, client_email, etc.) -//! - KMS key identification (key_ring_id, key_id, key_version) -//! - Optional universe domain and other GCP-specific fields -//! -//! This configuration is used to securely interact with Google Cloud KMS for operations -//! such as public key retrieval and message signing. -use crate::{ - config::ConfigFileError, - models::{validate_plain_or_env_value, PlainOrEnvValue}, -}; -use serde::{Deserialize, Serialize}; -use validator::Validate; - -use super::{validate_with_validator, SignerConfigValidate}; - -pub fn default_auth_uri() -> String { - "https://accounts.google.com/o/oauth2/auth".to_string() -} -pub fn default_token_uri() -> String { - "https://oauth2.googleapis.com/token".to_string() -} -fn default_auth_provider_x509_cert_url() -> String { - "https://www.googleapis.com/oauth2/v1/certs".to_string() -} -fn default_client_x509_cert_url() -> String { - "https://www.googleapis.com/robot/v1/metadata/x509/solana-signer%40forward-emitter-459820-r7.iam.gserviceaccount.com".to_string() -} - -fn default_universe_domain() -> String { - "googleapis.com".to_string() -} - -fn default_key_version() -> u32 { - 1 -} - -fn default_location() -> String { - "global".to_string() -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Validate)] -#[serde(deny_unknown_fields)] -pub struct ServiceAccountConfig { - #[validate(length(min = 1, message = "project_id cannot be empty"))] - pub project_id: String, - #[validate(custom(function = "validate_plain_or_env_value"))] - pub private_key_id: PlainOrEnvValue, - #[validate(custom(function = "validate_plain_or_env_value"))] - pub private_key: PlainOrEnvValue, - #[validate(custom(function = "validate_plain_or_env_value"))] - pub client_email: PlainOrEnvValue, - #[validate(length(min = 1, message = "client_id cannot be empty"))] - pub client_id: String, - #[validate(url)] - #[serde(default = "default_auth_uri")] - pub auth_uri: String, - #[validate(url)] - #[serde(default = "default_token_uri")] - pub token_uri: String, - #[validate(url)] - #[serde(default = "default_auth_provider_x509_cert_url")] - pub auth_provider_x509_cert_url: String, - #[validate(url)] - #[serde(default = "default_client_x509_cert_url")] - pub client_x509_cert_url: String, - #[serde(default = "default_universe_domain")] - pub universe_domain: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Validate)] -#[serde(deny_unknown_fields)] -pub struct KmsKeyConfig { - #[serde(default = "default_location")] - pub location: String, - #[validate(length(min = 1, message = "key_ring_id name cannot be empty"))] - pub key_ring_id: String, - #[validate(length(min = 1, message = "key_id cannot be empty"))] - pub key_id: String, - #[serde(default = "default_key_version")] - pub key_version: u32, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Validate)] -#[serde(deny_unknown_fields)] -pub struct GoogleCloudKmsSignerFileConfig { - #[validate(nested)] - pub service_account: ServiceAccountConfig, - #[validate(nested)] - pub key: KmsKeyConfig, -} - -impl SignerConfigValidate for GoogleCloudKmsSignerFileConfig { - fn validate(&self) -> Result<(), ConfigFileError> { - validate_with_validator(self) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::SecretString; - - #[test] - fn test_google_cloud_kms_signer_file_config_valid() { - let config = GoogleCloudKmsSignerFileConfig { - service_account: ServiceAccountConfig { - project_id: "project-123".to_string(), - private_key_id: PlainOrEnvValue::Plain { - value: SecretString::new("private-key-id"), - }, - private_key: PlainOrEnvValue::Plain { - value: SecretString::new("private-key"), - }, - client_email: PlainOrEnvValue::Plain { - value: SecretString::new("client@email.com"), - }, - client_id: "client-id-123".to_string(), - auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), - token_uri: "https://oauth2.googleapis.com/token".to_string(), - auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs".to_string(), - client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/solana-signer%40forward-emitter-459820-r7.iam.gserviceaccount.com".to_string(), - universe_domain: "googleapis.com".to_string(), - }, - key: KmsKeyConfig { - location: default_location(), - key_ring_id: "ring-1".to_string(), - key_id: "key-1".to_string(), - key_version: 1, - }, - }; - - assert!(Validate::validate(&config).is_ok()); - assert!(SignerConfigValidate::validate(&config).is_ok()); - } - - #[test] - fn test_google_cloud_kms_signer_file_config_empty_project_id() { - let config = GoogleCloudKmsSignerFileConfig { - service_account: ServiceAccountConfig { - project_id: "".to_string(), - private_key_id: PlainOrEnvValue::Plain { - value: SecretString::new("private-key-id"), - }, - private_key: PlainOrEnvValue::Plain { - value: SecretString::new("private-key"), - }, - client_email: PlainOrEnvValue::Plain { - value: SecretString::new("client@email.com"), - }, - client_id: "client-id-123".to_string(), - auth_uri: default_auth_uri(), - token_uri: default_token_uri(), - auth_provider_x509_cert_url: default_auth_provider_x509_cert_url(), - client_x509_cert_url: default_client_x509_cert_url(), - universe_domain: default_universe_domain(), - }, - key: KmsKeyConfig { - location: default_location(), - key_ring_id: "ring-1".to_string(), - key_id: "key-1".to_string(), - key_version: 1, - }, - }; - - let result = SignerConfigValidate::validate(&config); - - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("project_id")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_google_cloud_kms_signer_file_config_empty_key_ring_id() { - let config = GoogleCloudKmsSignerFileConfig { - service_account: ServiceAccountConfig { - project_id: "project-123".to_string(), - private_key_id: PlainOrEnvValue::Plain { - value: SecretString::new("private-key-id"), - }, - private_key: PlainOrEnvValue::Plain { - value: SecretString::new("private-key"), - }, - client_email: PlainOrEnvValue::Plain { - value: SecretString::new("client@email.com"), - }, - client_id: "client-id-123".to_string(), - auth_uri: default_auth_uri(), - token_uri: default_token_uri(), - auth_provider_x509_cert_url: default_auth_provider_x509_cert_url(), - client_x509_cert_url: default_client_x509_cert_url(), - universe_domain: default_universe_domain(), - }, - key: KmsKeyConfig { - location: default_location(), - key_ring_id: "".to_string(), - key_id: "key-1".to_string(), - key_version: 1, - }, - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("key_ring_id")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_google_cloud_kms_signer_file_config_empty_key_id() { - let config = GoogleCloudKmsSignerFileConfig { - service_account: ServiceAccountConfig { - project_id: "project-123".to_string(), - private_key_id: PlainOrEnvValue::Plain { - value: SecretString::new("private-key-id"), - }, - private_key: PlainOrEnvValue::Plain { - value: SecretString::new("private-key"), - }, - client_email: PlainOrEnvValue::Plain { - value: SecretString::new("client@email.com"), - }, - client_id: "client-id-123".to_string(), - auth_uri: default_auth_uri(), - token_uri: default_token_uri(), - auth_provider_x509_cert_url: default_auth_provider_x509_cert_url(), - client_x509_cert_url: default_client_x509_cert_url(), - universe_domain: default_universe_domain(), - }, - key: KmsKeyConfig { - location: default_location(), - key_ring_id: "ring-1".to_string(), - key_id: "".to_string(), - key_version: 1, - }, - }; - - let result = SignerConfigValidate::validate(&config); - - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("key_id")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_serde_deserialize() { - let json = r#" - { - "service_account": { - "project_id": "project-123", - "private_key_id": { - "type": "plain", - "value": "private-key-id" - }, - "private_key": { - "type": "plain", - "value": "private-key" - }, - "client_email": { - "type": "plain", - "value": "client@email.com" - }, - "client_id": "client-id-123" - }, - "key": { - "key_ring_id": "ring-1", - "key_id": "key-1", - "key_version": 1 - } - } - "#; - - let config: GoogleCloudKmsSignerFileConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.service_account.project_id, "project-123"); - assert_eq!( - config - .service_account - .private_key_id - .get_value() - .unwrap() - .to_str() - .as_str(), - "private-key-id" - ); - assert_eq!( - config - .service_account - .private_key - .get_value() - .unwrap() - .to_str() - .as_str(), - "private-key" - ); - assert_eq!( - config - .service_account - .client_email - .get_value() - .unwrap() - .to_str() - .as_str(), - "client@email.com" - ); - assert_eq!(config.service_account.client_id, "client-id-123"); - assert_eq!(config.key.key_ring_id, "ring-1"); - assert_eq!(config.key.key_id, "key-1"); - assert_eq!(config.key.key_version, 1); - } - - #[test] - fn test_serde_unknown_field() { - let json = r#" - { - "service_account": { - "project_id": "project-123", - "private_key_id": { - "type": "plain", - "value": "private-key-id" - }, - "private_key": { - "type": "plain", - "value": "private-key" - }, - "client_email": { - "type": "plain", - "value": "client@email.com" - }, - "client_id": "client-id-123" - }, - "key": { - "key_ring_id": "ring-1", - "key_id": "key-1", - "key_version": 1 - }, - "unknown_field": "should cause error" - } - "#; - - let result: Result = serde_json::from_str(json); - assert!(result.is_err()); - } - - #[test] - fn test_serde_serialize_deserialize() { - let config = GoogleCloudKmsSignerFileConfig { - service_account: ServiceAccountConfig { - project_id: "project-123".to_string(), - private_key_id: PlainOrEnvValue::Plain { - value: SecretString::new("private-key-id"), - }, - private_key: PlainOrEnvValue::Plain { - value: SecretString::new("private-key"), - }, - client_email: PlainOrEnvValue::Plain { - value: SecretString::new("client@email.com"), - }, - client_id: "client-id-123".to_string(), - auth_uri: default_auth_uri(), - token_uri: default_token_uri(), - auth_provider_x509_cert_url: default_auth_provider_x509_cert_url(), - client_x509_cert_url: default_client_x509_cert_url(), - universe_domain: default_universe_domain(), - }, - key: KmsKeyConfig { - location: default_location(), - key_ring_id: "ring-1".to_string(), - key_id: "key-1".to_string(), - key_version: 1, - }, - }; - - let serialized = serde_json::to_string(&config).unwrap(); - let deserialized: GoogleCloudKmsSignerFileConfig = - serde_json::from_str(&serialized).unwrap(); - - assert_eq!( - config.service_account.project_id, - deserialized.service_account.project_id - ); - assert_eq!(config.key.key_id, deserialized.key.key_id); - assert_eq!(config.key.key_ring_id, deserialized.key.key_ring_id); - assert_eq!(config.key.key_version, deserialized.key.key_version); - } - - #[test] - fn test_defaults_applied() { - let json = r#" - { - "service_account": { - "project_id": "project-123", - "private_key_id": { "type": "plain", "value": "private-key-id" }, - "private_key": { "type": "plain", "value": "private-key" }, - "client_email": { "type": "plain", "value": "client@email.com" }, - "client_id": "client-id-123" - }, - "key": { - "key_ring_id": "ring-1", - "key_id": "key-1" - } - } - "#; - let config: GoogleCloudKmsSignerFileConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.service_account.auth_uri, default_auth_uri()); - assert_eq!(config.service_account.token_uri, default_token_uri()); - assert_eq!(config.key.key_version, 1); - } -} diff --git a/src/config/config_file/signer/local.rs b/src/config/config_file/signer/local.rs deleted file mode 100644 index 1db1af8e9..000000000 --- a/src/config/config_file/signer/local.rs +++ /dev/null @@ -1,382 +0,0 @@ -//! Local signer configuration for the OpenZeppelin Relayer. -//! -//! This module provides functionality for managing and validating local signer configurations -//! that use filesystem-based keystores. It handles loading keystores from disk with passphrase -//! protection, supporting both plain text and environment variable-based passphrases. -//! -//! # Features -//! -//! * Validation of signer file paths and passphrases -//! * Support for environment variable-based passphrase retrieval -use serde::{Deserialize, Serialize}; - -use crate::{config::ConfigFileError, models::PlainOrEnvValue}; - -use super::SignerConfigValidate; -use std::path::Path; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct LocalSignerFileConfig { - pub path: String, - pub passphrase: PlainOrEnvValue, -} - -impl LocalSignerFileConfig { - fn validate_path(&self) -> Result<(), ConfigFileError> { - if self.path.is_empty() { - return Err(ConfigFileError::InvalidIdLength( - "Signer path cannot be empty".into(), - )); - } - - let path = Path::new(&self.path); - if !path.exists() { - return Err(ConfigFileError::FileNotFound(format!( - "Signer file not found at path: {}", - path.display() - ))); - } - - if !path.is_file() { - return Err(ConfigFileError::InvalidFormat(format!( - "Path exists but is not a file: {}", - path.display() - ))); - } - - Ok(()) - } - - fn validate_passphrase(&self) -> Result<(), ConfigFileError> { - match &self.passphrase { - PlainOrEnvValue::Env { value } => { - if value.is_empty() { - return Err(ConfigFileError::MissingField( - "Passphrase environment variable name cannot be empty".into(), - )); - } - if std::env::var(value).is_err() { - return Err(ConfigFileError::MissingEnvVar(format!( - "Environment variable {} not found", - value - ))); - } - } - PlainOrEnvValue::Plain { value } => { - if value.is_empty() { - return Err(ConfigFileError::InvalidFormat( - "Passphrase value cannot be empty".into(), - )); - } - } - } - - Ok(()) - } -} - -impl SignerConfigValidate for LocalSignerFileConfig { - fn validate(&self) -> Result<(), ConfigFileError> { - self.validate_path()?; - self.validate_passphrase()?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::SecretString; - use std::env; - use std::fs::File; - use std::io::Write; - use tempfile::tempdir; - - #[test] - fn test_valid_local_signer_config() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test-keystore.json"); - let mut file = File::create(&file_path).unwrap(); - writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap(); - - let config = LocalSignerFileConfig { - path: file_path.to_str().unwrap().to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("password123"), - }, - }; - - assert!(config.validate().is_ok()); - } - - #[test] - fn test_empty_path() { - let config = LocalSignerFileConfig { - path: "".to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("password123"), - }, - }; - - let result = config.validate(); - assert!(result.is_err()); - assert!(matches!(result, Err(ConfigFileError::InvalidIdLength(_)))); - } - - #[test] - fn test_nonexistent_path() { - let config = LocalSignerFileConfig { - path: "/tmp/definitely-doesnt-exist-12345.json".to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("password123"), - }, - }; - - let result = config.validate(); - assert!(result.is_err()); - assert!(matches!(result, Err(ConfigFileError::FileNotFound(_)))); - } - - #[test] - fn test_path_is_directory() { - let temp_dir = tempdir().unwrap(); - - let config = LocalSignerFileConfig { - path: temp_dir.path().to_str().unwrap().to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("password123"), - }, - }; - - let result = config.validate(); - assert!(result.is_err()); - assert!(matches!(result, Err(ConfigFileError::InvalidFormat(_)))); - } - - #[test] - fn test_empty_plain_passphrase() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test-keystore.json"); - let mut file = File::create(&file_path).unwrap(); - writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap(); - - let config = LocalSignerFileConfig { - path: file_path.to_str().unwrap().to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - }; - - let result = config.validate(); - assert!(result.is_err()); - assert!(matches!(result, Err(ConfigFileError::InvalidFormat(_)))); - } - - #[test] - fn test_empty_env_name() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test-keystore.json"); - let mut file = File::create(&file_path).unwrap(); - writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap(); - - let config = LocalSignerFileConfig { - path: file_path.to_str().unwrap().to_string(), - passphrase: PlainOrEnvValue::Env { - value: "".to_string(), - }, - }; - - let result = config.validate(); - assert!(result.is_err()); - assert!(matches!(result, Err(ConfigFileError::MissingField(_)))); - } - - #[test] - fn test_missing_env_var() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test-keystore.json"); - let mut file = File::create(&file_path).unwrap(); - writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap(); - - // Make sure this environment variable doesn't exist - env::remove_var("TEST_SIGNER_PASSPHRASE_THAT_DOESNT_EXIST"); - - let config = LocalSignerFileConfig { - path: file_path.to_str().unwrap().to_string(), - passphrase: PlainOrEnvValue::Env { - value: "TEST_SIGNER_PASSPHRASE_THAT_DOESNT_EXIST".to_string(), - }, - }; - - let result = config.validate(); - assert!(result.is_err()); - assert!(matches!(result, Err(ConfigFileError::MissingEnvVar(_)))); - } - - #[test] - fn test_valid_env_var_passphrase() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test-keystore.json"); - let mut file = File::create(&file_path).unwrap(); - writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap(); - - env::set_var("TEST_SIGNER_PASSPHRASE", "super-secret-passphrase"); - - let config = LocalSignerFileConfig { - path: file_path.to_str().unwrap().to_string(), - passphrase: PlainOrEnvValue::Env { - value: "TEST_SIGNER_PASSPHRASE".to_string(), - }, - }; - - assert!(config.validate().is_ok()); - - env::remove_var("TEST_SIGNER_PASSPHRASE"); - } - - #[test] - fn test_serialize_deserialize() { - let config = LocalSignerFileConfig { - path: "/path/to/keystore.json".to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("password123"), - }, - }; - - let serialized = serde_json::to_string(&config).unwrap(); - let deserialized: LocalSignerFileConfig = serde_json::from_str(&serialized).unwrap(); - - assert_eq!(config.path, deserialized.path); - assert_ne!(config.passphrase, deserialized.passphrase); - } - - #[test] - fn test_deserialize_from_json() { - let json = r#"{ - "path": "/path/to/keystore.json", - "passphrase": { - "type": "plain", - "value": "password123" - } - }"#; - - let config: LocalSignerFileConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.path, "/path/to/keystore.json"); - - if let PlainOrEnvValue::Plain { value } = &config.passphrase { - assert_eq!(value.to_str().as_str(), "password123"); - } else { - panic!("Expected Plain passphrase variant"); - } - } - - #[test] - fn test_deserialize_env_passphrase() { - let json = r#"{ - "path": "/path/to/keystore.json", - "passphrase": { - "type": "env", - "value": "KEYSTORE_PASSPHRASE" - } - }"#; - - let config: LocalSignerFileConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.path, "/path/to/keystore.json"); - - if let PlainOrEnvValue::Env { value } = &config.passphrase { - assert_eq!(value, "KEYSTORE_PASSPHRASE"); - } else { - panic!("Expected Env passphrase variant"); - } - } - - #[test] - fn test_reject_unknown_fields() { - let json = r#"{ - "path": "/path/to/keystore.json", - "passphrase": { - "type": "plain", - "value": "password123" - }, - "unexpected_field": "should cause error" - }"#; - - let result: Result = serde_json::from_str(json); - assert!(result.is_err()); - } - - #[test] - fn test_validate_path_and_passphrase_methods() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test-keystore.json"); - let mut file = File::create(&file_path).unwrap(); - writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap(); - - let config1 = LocalSignerFileConfig { - path: file_path.to_str().unwrap().to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("password123"), - }, - }; - assert!(config1.validate_path().is_ok()); - - assert!(config1.validate_passphrase().is_ok()); - - let config2 = LocalSignerFileConfig { - path: "/nonexistent/path.json".to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("password123"), - }, - }; - assert!(config2.validate_path().is_err()); - - let config3 = LocalSignerFileConfig { - path: file_path.to_str().unwrap().to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - }; - assert!(config3.validate_passphrase().is_err()); - } - - #[test] - fn test_env_var_passphrase_with_special_chars() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test-keystore.json"); - let mut file = File::create(&file_path).unwrap(); - writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap(); - - // Create a temporary .env file - let env_path = temp_dir.path().join(".env"); - let mut env_file = File::create(&env_path).unwrap(); - writeln!( - env_file, - "TEST_SIGNER_PASSPHRASE_SPECIAL=super#secret#passphrase" - ) - .unwrap(); - - // Load the .env file - dotenvy::from_path(&env_path).unwrap(); - - let config = LocalSignerFileConfig { - path: file_path.to_str().unwrap().to_string(), - passphrase: PlainOrEnvValue::Env { - value: "TEST_SIGNER_PASSPHRASE_SPECIAL".to_string(), - }, - }; - - assert!(config.validate().is_ok()); - // Validate that the value from config matches the environment variable - if let PlainOrEnvValue::Env { value } = &config.passphrase { - assert_eq!( - std::env::var(value).unwrap(), - "super#secret#passphrase", - "Environment variable value should match the expected value" - ); - } else { - panic!("Expected Env passphrase variant"); - } - } -} diff --git a/src/config/config_file/signer/mod.rs b/src/config/config_file/signer/mod.rs deleted file mode 100644 index 232caf94c..000000000 --- a/src/config/config_file/signer/mod.rs +++ /dev/null @@ -1,879 +0,0 @@ -//! Configuration file definitions for signer services. -//! -//! Provides configuration structures and validation for different signer types: -//! - Test (temporary private keys) -//! - Local keystore (encrypted JSON files) -//! - HashiCorp Vault integration -//! - Turnkey service integration -//! - Google Cloud integration -//! - AWS KMS integration (EVM only) -use super::ConfigFileError; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use validator::Validate; - -mod local; -pub use local::*; - -mod vault; -pub use vault::*; - -mod vault_cloud; -pub use vault_cloud::*; - -mod vault_transit; -pub use vault_transit::*; - -mod turnkey; -pub use turnkey::*; - -mod google_cloud_kms; -pub use google_cloud_kms::*; - -mod aws_kms; -pub use aws_kms::*; - -pub trait SignerConfigValidate { - fn validate(&self) -> Result<(), ConfigFileError>; -} - -fn collect_validation_errors(errors: &validator::ValidationErrors) -> Vec { - let mut messages = Vec::new(); - - for (field, field_errors) in errors.field_errors().iter() { - let field_msgs: Vec = field_errors - .iter() - .map(|error| error.message.clone().unwrap_or_default().to_string()) - .collect(); - messages.push(format!("{}: {}", field, field_msgs.join(", "))); - } - - for (struct_field, kind) in errors.errors().iter() { - if let validator::ValidationErrorsKind::Struct(nested) = kind { - let nested_msgs = collect_validation_errors(nested); - for msg in nested_msgs { - messages.push(format!("{}.{}", struct_field, msg)); - } - } - } - - messages -} - -/// Validates a signer config using validator::Validate -pub fn validate_with_validator(config: &T) -> Result<(), ConfigFileError> -where - T: SignerConfigValidate + Validate, -{ - match Validate::validate(config) { - Ok(_) => Ok(()), - Err(errors) => Err(ConfigFileError::InvalidFormat( - collect_validation_errors(&errors).join("; "), - )), - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct TestSignerFileConfig {} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(tag = "type", rename_all = "lowercase", content = "config")] -pub enum SignerFileConfigEnum { - Test(TestSignerFileConfig), - Local(LocalSignerFileConfig), - #[serde(rename = "aws_kms")] - AwsKms(AwsKmsSignerFileConfig), - Vault(VaultSignerFileConfig), - #[serde(rename = "vault_cloud")] - VaultCloud(VaultCloudSignerFileConfig), - #[serde(rename = "vault_transit")] - VaultTransit(VaultTransitSignerFileConfig), - Turnkey(TurnkeySignerFileConfig), - #[serde(rename = "google_cloud_kms")] - GoogleCloudKms(GoogleCloudKmsSignerFileConfig), -} - -impl SignerFileConfigEnum { - pub fn get_local(&self) -> Option<&LocalSignerFileConfig> { - match self { - SignerFileConfigEnum::Local(local) => Some(local), - _ => None, - } - } - - pub fn get_vault(&self) -> Option<&VaultSignerFileConfig> { - match self { - SignerFileConfigEnum::Vault(vault) => Some(vault), - _ => None, - } - } - - pub fn get_vault_cloud(&self) -> Option<&VaultCloudSignerFileConfig> { - match self { - SignerFileConfigEnum::VaultCloud(vault_cloud) => Some(vault_cloud), - _ => None, - } - } - - pub fn get_vault_transit(&self) -> Option<&VaultTransitSignerFileConfig> { - match self { - SignerFileConfigEnum::VaultTransit(vault_transit) => Some(vault_transit), - _ => None, - } - } - - pub fn get_test(&self) -> Option<&TestSignerFileConfig> { - match self { - SignerFileConfigEnum::Test(test) => Some(test), - _ => None, - } - } - - pub fn get_aws_kms(&self) -> Option<&AwsKmsSignerFileConfig> { - match self { - SignerFileConfigEnum::AwsKms(aws_kms) => Some(aws_kms), - _ => None, - } - } - - pub fn get_turnkey(&self) -> Option<&TurnkeySignerFileConfig> { - match self { - SignerFileConfigEnum::Turnkey(turnkey) => Some(turnkey), - _ => None, - } - } - - pub fn get_google_cloud_kms(&self) -> Option<&GoogleCloudKmsSignerFileConfig> { - match self { - SignerFileConfigEnum::GoogleCloudKms(google_cloud_kms) => Some(google_cloud_kms), - _ => None, - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct SignerFileConfig { - pub id: String, - #[serde(flatten)] - pub config: SignerFileConfigEnum, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum SignerFileConfigPassphrase { - Env { name: String }, - Plain { value: String }, -} - -impl SignerFileConfig { - pub fn validate_signer(&self) -> Result<(), ConfigFileError> { - if self.id.is_empty() { - return Err(ConfigFileError::InvalidIdLength( - "Signer ID cannot be empty".into(), - )); - } - - match &self.config { - SignerFileConfigEnum::Test(_) => Ok(()), - SignerFileConfigEnum::Local(local_config) => local_config.validate(), - SignerFileConfigEnum::AwsKms(aws_kms_config) => { - SignerConfigValidate::validate(aws_kms_config) - } - SignerFileConfigEnum::Vault(vault_config) => { - SignerConfigValidate::validate(vault_config) - } - SignerFileConfigEnum::VaultCloud(vault_cloud_config) => { - SignerConfigValidate::validate(vault_cloud_config) - } - SignerFileConfigEnum::VaultTransit(vault_transit_config) => { - SignerConfigValidate::validate(vault_transit_config) - } - SignerFileConfigEnum::Turnkey(turnkey_config) => { - SignerConfigValidate::validate(turnkey_config) - } - SignerFileConfigEnum::GoogleCloudKms(google_cloud_kms_config) => { - SignerConfigValidate::validate(google_cloud_kms_config) - } - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct SignersFileConfig { - pub signers: Vec, -} - -impl SignersFileConfig { - pub fn new(signers: Vec) -> Self { - Self { signers } - } - - pub fn validate(&self) -> Result<(), ConfigFileError> { - if self.signers.is_empty() { - return Err(ConfigFileError::MissingField("signers".into())); - } - - let mut ids = HashSet::new(); - for signer in &self.signers { - signer.validate_signer()?; - if !ids.insert(signer.id.clone()) { - return Err(ConfigFileError::DuplicateId(signer.id.clone())); - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use crate::models::{PlainOrEnvValue, PlainOrEnvValueError, SecretString}; - - use super::*; - use serde_json::json; - use std::env; - - #[test] - fn test_plain_or_env_config_value_plain() { - let plain = PlainOrEnvValue::Plain { - value: SecretString::new("test-value"), - }; - - assert_eq!( - plain.get_value().unwrap().to_str().as_str(), - "test-value".to_string() - ); - } - - #[test] - fn test_plain_or_env_config_value_env_exists() { - env::set_var("TEST_ENV_VAR", "env-test-value"); - - let env_value = PlainOrEnvValue::Env { - value: "TEST_ENV_VAR".to_string(), - }; - - assert_eq!( - env_value.get_value().unwrap().to_str().as_str(), - "env-test-value".to_string() - ); - env::remove_var("TEST_ENV_VAR"); - } - - #[test] - fn test_plain_or_env_config_value_env_missing() { - env::remove_var("NONEXISTENT_TEST_VAR"); - - let env_value = PlainOrEnvValue::Env { - value: "NONEXISTENT_TEST_VAR".to_string(), - }; - - let result = env_value.get_value(); - assert!(result.is_err()); - assert!(matches!( - result, - Err(PlainOrEnvValueError::MissingEnvVar(_)) - )); - } - - #[test] - fn test_valid_signer_config() { - let config = json!({ - "id": "local-signer", - "type": "local", - "config": { - "path": "tests/utils/test_keys/unit-test-local-signer.json", - "passphrase": { - "type": "plain", - "value": "secret", - } - } - }); - - let signer_config: SignerFileConfig = serde_json::from_value(config).unwrap(); - assert!(signer_config.validate_signer().is_ok()); - } - - #[test] - fn test_valid_signer_config_env() { - env::set_var("LOCAL_SIGNER_KEY_PASSPHRASE", "mocked_value"); - - let config = json!({ - "id": "local-signer", - "type": "local", - "config": { - "path": "tests/utils/test_keys/unit-test-local-signer.json", - "passphrase": { - "type": "env", - "value": "LOCAL_SIGNER_KEY_PASSPHRASE" - } - } - }); - - let signer_config: SignerFileConfig = serde_json::from_value(config).unwrap(); - assert!(signer_config.validate_signer().is_ok()); - env::remove_var("LOCAL_SIGNER_KEY_PASSPHRASE"); - } - - #[test] - fn test_duplicate_signer_ids() { - let config = json!({ - "signers": [ - { - "id": "local-signer", - "type": "local", - "config": { - "path": "tests/utils/test_keys/unit-test-local-signer.json", - "passphrase": { - "type": "plain", - "value": "secret", - } - } - }, - { - "id": "local-signer", - "type": "local", - "config": { - "path": "tests/utils/test_keys/unit-test-local-signer.json", - "passphrase": { - "type": "plain", - "value": "secret", - } - } - } - ] - }); - - let signer_config: SignersFileConfig = serde_json::from_value(config).unwrap(); - assert!(matches!( - signer_config.validate(), - Err(ConfigFileError::DuplicateId(_)) - )); - } - - #[test] - fn test_empty_signer_id() { - let config = json!({ - "signers": [ - { - "id": "", - "type": "local", - "config": { - "path": "tests/utils/test_keys/unit-test-local-signer.json", - "passphrase": { - "type": "plain", - "value": "secret", - } - } - - } - ] - }); - - let signer_config: SignersFileConfig = serde_json::from_value(config).unwrap(); - assert!(matches!( - signer_config.validate(), - Err(ConfigFileError::InvalidIdLength(_)) - )); - } - - #[test] - fn test_validate_test_signer() { - let config = json!({ - "id": "test-signer", - "type": "test", - "config": {} - }); - - let signer_config: SignerFileConfig = serde_json::from_value(config).unwrap(); - assert!(signer_config.validate_signer().is_ok()); - } - - #[test] - fn test_validate_vault_signer() { - let config = json!({ - "id": "vault-signer", - "type": "vault", - "config": { - "address": "https://vault.example.com", - "role_id": { - "type":"plain", - "value":"role-123" - }, - "secret_id": { - "type":"plain", - "value":"secret-456" - }, - "key_name": "test-key" - } - }); - - let signer_config: SignerFileConfig = serde_json::from_value(config).unwrap(); - assert!(signer_config.validate_signer().is_ok()); - } - - #[test] - fn test_validate_vault_cloud_signer() { - let config = json!({ - "id": "vault-cloud-signer", - "type": "vault_cloud", - "config": { - "client_id": "client-123", - "client_secret": { - "type": "plain", - "value":"secret-abc" - }, - "org_id": "org-456", - "project_id": "proj-789", - "app_name": "my-app", - "key_name": "cloud-key" - } - }); - - let signer_config: SignerFileConfig = serde_json::from_value(config).unwrap(); - assert!(signer_config.validate_signer().is_ok()); - } - - #[test] - fn test_validate_vault_transit_signer() { - let config = json!({ - "id": "vault-transit-signer", - "type": "vault_transit", - "config": { - "key_name": "transit-key", - "address": "https://vault.example.com", - "role_id": { - "type":"plain", - "value":"role-123" - }, - "secret_id": { - "type":"plain", - "value":"secret-456" - }, - "pubkey": "test-pubkey" - } - }); - - let signer_config: SignerFileConfig = serde_json::from_value(config).unwrap(); - assert!(signer_config.validate_signer().is_ok()); - } - - #[test] - fn test_validate_vault_transit_signer_invalid() { - let config = json!({ - "id": "vault-transit-signer", - "type": "vault_transit", - "config": { - "key_name": "", - "address": "https://vault.example.com", - "role_id": { - "type":"plain", - "value":"role-123" - }, - "secret_id": { - "type":"plain", - "value":"secret-456" - }, - "pubkey": "test-pubkey" - } - }); - - let signer_config: SignerFileConfig = serde_json::from_value(config).unwrap(); - assert!(signer_config.validate_signer().is_err()); - } - - #[test] - fn test_validate_turnkey_signer() { - let config = json!({ - "id": "turnkey-signer", - "type": "turnkey", - "config": { - "api_private_key": {"type": "plain", "value": "key"}, - "api_public_key": "api_public_key", - "organization_id": "organization_id", - "private_key_id": "private_key_id", - "public_key": "public_key", - } - }); - - let signer_config: SignerFileConfig = serde_json::from_value(config).unwrap(); - assert!(signer_config.validate_signer().is_ok()); - } - - #[test] - fn test_validate_turnkey_invalid() { - let config = json!({ - "id": "turnkey-signer", - "type": "turnkey", - "config": { - "api_private_key": {"type": "plain", "value": "key"}, - "api_public_key": "", - "organization_id": "organization_id", - "private_key_id": "private_key_id", - "public_key": "public_key", - } - }); - - let signer_config: SignerFileConfig = serde_json::from_value(config).unwrap(); - assert!(signer_config.validate_signer().is_err()); - } - - #[test] - fn test_validate_google_cloud_kms_signer() { - let config = json!({ - "id": "google-signer", - "type": "google_cloud_kms", - "config": { - "service_account": { - "private_key": { - "type": "plain", - "value": "key" - }, - "client_email": { - "type": "plain", - "value": "email" - }, - "private_key_id": { - "type": "plain", - "value": "key_id" - }, - "project_id": "id", - "client_id": "client_id" - }, - "key": { - "key_id": "my-key", - "key_ring_id": "my-keyring", - "key_version": 1 - } - } - }); - - let signer_config: SignerFileConfig = serde_json::from_value(config).unwrap(); - assert!(signer_config.validate_signer().is_ok()); - } - - #[test] - fn test_validate_google_cloud_kms_invalid() { - let config = json!({ - "id": "google-signer", - "type": "google_cloud_kms", - "config": { - "service_account": { - "private_key": { - "type": "plain", - "value": "key" - }, - "client_email": { - "type": "plain", - "value": "email" - }, - "private_key_id": { - "type": "plain", - "value": "key_id" - }, - "project_id": "", - "client_id": "client_id" - }, - "key": { - "key_id": "my-key", - "key_ring_id": "my-keyring", - "key_version": 1 - } - } - }); - - let signer_config: SignerFileConfig = serde_json::from_value(config).unwrap(); - assert!(signer_config.validate_signer().is_err()); - } - - #[test] - fn test_empty_signers_array() { - let config = json!({ - "signers": [] - }); - - let signer_config: SignersFileConfig = serde_json::from_value(config).unwrap(); - let result = signer_config.validate(); - assert!(result.is_err()); - assert!(matches!(result, Err(ConfigFileError::MissingField(_)))); - } - - #[test] - fn test_signers_file_config_new() { - let signer = SignerFileConfig { - id: "test-signer".to_string(), - config: SignerFileConfigEnum::Test(TestSignerFileConfig {}), - }; - - let config = SignersFileConfig::new(vec![signer.clone()]); - assert_eq!(config.signers.len(), 1); - assert_eq!(config.signers[0].id, "test-signer"); - assert!(matches!( - config.signers[0].config, - SignerFileConfigEnum::Test(_) - )); - } - - #[test] - fn test_serde_for_enum_variants() { - let test_config = json!({ - "type": "test", - "config": {} - }); - let parsed: SignerFileConfigEnum = serde_json::from_value(test_config).unwrap(); - assert!(matches!(parsed, SignerFileConfigEnum::Test(_))); - - let local_config = json!({ - "type": "local", - "config": { - "path": "test-path", - "passphrase": { - "type": "plain", - "value": "test-passphrase" - } - } - }); - let parsed: SignerFileConfigEnum = serde_json::from_value(local_config).unwrap(); - assert!(matches!(parsed, SignerFileConfigEnum::Local(_))); - - let vault_config = json!({ - "type": "vault", - "config": { - "address": "https://vault.example.com", - "role_id": {"type": "plain", "value": "role-123"}, - "secret_id": { "type": "plain", "value": "secret-456"}, - "key_name": "test-key" - } - }); - let parsed: SignerFileConfigEnum = serde_json::from_value(vault_config).unwrap(); - assert!(matches!(parsed, SignerFileConfigEnum::Vault(_))); - - let vault_cloud_config = json!({ - "type": "vault_cloud", - "config": { - "client_id": "client-123", - "client_secret": {"type": "plain", "value": "secret-abc"}, - "org_id": "org-456", - "project_id": "proj-789", - "app_name": "my-app", - "key_name": "cloud-key" - } - }); - let parsed: SignerFileConfigEnum = serde_json::from_value(vault_cloud_config).unwrap(); - assert!(matches!(parsed, SignerFileConfigEnum::VaultCloud(_))); - - let vault_transit_config = json!({ - "type": "vault_transit", - "config": { - "key_name": "transit-key", - "address": "https://vault.example.com", - "role_id": {"type": "plain", "value": "role-123"}, - "secret_id": { "type": "plain", "value": "secret-456"}, - "pubkey": "test-pubkey" - } - }); - let parsed: SignerFileConfigEnum = serde_json::from_value(vault_transit_config).unwrap(); - assert!(matches!(parsed, SignerFileConfigEnum::VaultTransit(_))); - - let aws_kms_config = json!({ - "type": "aws_kms", - "config": { - "region": "us-east-1", - "key_id": "test-key-id" - } - }); - let parsed: SignerFileConfigEnum = serde_json::from_value(aws_kms_config).unwrap(); - assert!(matches!(parsed, SignerFileConfigEnum::AwsKms(_))); - - let turnkey_config = json!({ - "type": "turnkey", - "config": { - "api_private_key": {"type": "plain", "value": "key"}, - "api_public_key": "api_public_key", - "organization_id": "organization_id", - "private_key_id": "private_key_id", - "public_key": "public_key", - } - }); - let parsed: SignerFileConfigEnum = serde_json::from_value(turnkey_config).unwrap(); - assert!(matches!(parsed, SignerFileConfigEnum::Turnkey(_))); - - let google_config = json!({ - "type": "google_cloud_kms", - "config": { - "service_account": { - "private_key": {"type": "plain", "value": "key"}, - "client_email": {"type": "plain", "value": "email"}, - "private_key_id": {"type": "plain", "value": "key_id"}, - "project_id": "id", - "client_id": "client_id" - }, - "key": { - "key_id": "my-key", - "key_ring_id": "my-keyring", - "key_version": 1 - } - } - }); - let parsed: SignerFileConfigEnum = serde_json::from_value(google_config).unwrap(); - assert!(matches!(parsed, SignerFileConfigEnum::GoogleCloudKms(_))); - } - - #[test] - fn test_get_methods_for_signer_config() { - let test_config = SignerFileConfigEnum::Test(TestSignerFileConfig {}); - assert!(test_config.get_test().is_some()); - assert!(test_config.get_local().is_none()); - assert!(test_config.get_vault().is_none()); - assert!(test_config.get_vault_cloud().is_none()); - assert!(test_config.get_vault_transit().is_none()); - assert!(test_config.get_aws_kms().is_none()); - assert!(test_config.get_turnkey().is_none()); - assert!(test_config.get_google_cloud_kms().is_none()); - - let local_config = SignerFileConfigEnum::Local(LocalSignerFileConfig { - path: "test-path".to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("test-passphrase"), - }, - }); - assert!(local_config.get_test().is_none()); - assert!(local_config.get_local().is_some()); - assert!(local_config.get_vault().is_none()); - assert!(local_config.get_vault_cloud().is_none()); - assert!(local_config.get_vault_transit().is_none()); - assert!(local_config.get_aws_kms().is_none()); - assert!(local_config.get_turnkey().is_none()); - assert!(local_config.get_google_cloud_kms().is_none()); - - let vault_config = SignerFileConfigEnum::Vault(VaultSignerFileConfig { - address: "https://vault.example.com".to_string(), - namespace: None, - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - key_name: "test-key".to_string(), - mount_point: None, - }); - assert!(vault_config.get_test().is_none()); - assert!(vault_config.get_local().is_none()); - assert!(vault_config.get_vault().is_some()); - assert!(vault_config.get_vault_cloud().is_none()); - assert!(vault_config.get_vault_transit().is_none()); - assert!(vault_config.get_aws_kms().is_none()); - assert!(vault_config.get_turnkey().is_none()); - assert!(vault_config.get_google_cloud_kms().is_none()); - - let vault_cloud_config = SignerFileConfigEnum::VaultCloud(VaultCloudSignerFileConfig { - client_id: "client-123".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new("secret-abc"), - }, - org_id: "org-456".to_string(), - project_id: "proj-789".to_string(), - app_name: "my-app".to_string(), - key_name: "cloud-key".to_string(), - }); - assert!(vault_cloud_config.get_test().is_none()); - assert!(vault_cloud_config.get_local().is_none()); - assert!(vault_cloud_config.get_vault().is_none()); - assert!(vault_cloud_config.get_vault_cloud().is_some()); - assert!(vault_cloud_config.get_vault_transit().is_none()); - assert!(vault_cloud_config.get_aws_kms().is_none()); - - let vault_transit_config = - SignerFileConfigEnum::VaultTransit(VaultTransitSignerFileConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com".to_string(), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - pubkey: "test-pubkey".to_string(), - mount_point: None, - namespace: None, - }); - assert!(vault_transit_config.get_test().is_none()); - assert!(vault_transit_config.get_local().is_none()); - assert!(vault_transit_config.get_vault().is_none()); - assert!(vault_transit_config.get_vault_cloud().is_none()); - assert!(vault_transit_config.get_vault_transit().is_some()); - assert!(vault_transit_config.get_aws_kms().is_none()); - assert!(vault_transit_config.get_turnkey().is_none()); - assert!(vault_transit_config.get_google_cloud_kms().is_none()); - - let aws_kms_config = SignerFileConfigEnum::AwsKms(AwsKmsSignerFileConfig { - region: Some("us-east-1".to_string()), - key_id: "test-key-id".to_string(), - }); - assert!(aws_kms_config.get_test().is_none()); - assert!(aws_kms_config.get_local().is_none()); - assert!(aws_kms_config.get_vault().is_none()); - assert!(aws_kms_config.get_vault_cloud().is_none()); - assert!(aws_kms_config.get_vault_transit().is_none()); - assert!(aws_kms_config.get_aws_kms().is_some()); - assert!(aws_kms_config.get_turnkey().is_none()); - assert!(aws_kms_config.get_google_cloud_kms().is_none()); - - let turnkey_config = SignerFileConfigEnum::Turnkey(TurnkeySignerFileConfig { - api_private_key: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - api_public_key: "api_public_key".to_string(), - organization_id: "organization_id".to_string(), - private_key_id: "private_key_id".to_string(), - public_key: "public_key".to_string(), - }); - assert!(turnkey_config.get_test().is_none()); - assert!(turnkey_config.get_local().is_none()); - assert!(turnkey_config.get_vault().is_none()); - assert!(turnkey_config.get_vault_cloud().is_none()); - assert!(turnkey_config.get_vault_transit().is_none()); - assert!(turnkey_config.get_aws_kms().is_none()); - assert!(turnkey_config.get_turnkey().is_some()); - assert!(turnkey_config.get_google_cloud_kms().is_none()); - - let google_cloud_kms_config = - SignerFileConfigEnum::GoogleCloudKms(GoogleCloudKmsSignerFileConfig { - service_account: ServiceAccountConfig { - private_key: PlainOrEnvValue::Plain { - value: SecretString::new("key"), - }, - client_email: PlainOrEnvValue::Plain { - value: SecretString::new("email"), - }, - private_key_id: PlainOrEnvValue::Plain { - value: SecretString::new("key_id"), - }, - project_id: "id".to_string(), - client_id: "client_id".to_string(), - auth_uri: "uri".to_string(), - token_uri: "uri".to_string(), - client_x509_cert_url: "uri".to_string(), - auth_provider_x509_cert_url: "uri".to_string(), - universe_domain: "uri".to_string(), - }, - key: KmsKeyConfig { - location: "global".to_string(), - key_id: "my-key".to_string(), - key_ring_id: "my-keyring".to_string(), - key_version: 1, - }, - }); - assert!(google_cloud_kms_config.get_test().is_none()); - assert!(google_cloud_kms_config.get_local().is_none()); - assert!(google_cloud_kms_config.get_vault().is_none()); - assert!(google_cloud_kms_config.get_vault_cloud().is_none()); - assert!(google_cloud_kms_config.get_vault_transit().is_none()); - assert!(google_cloud_kms_config.get_aws_kms().is_none()); - assert!(google_cloud_kms_config.get_turnkey().is_none()); - assert!(google_cloud_kms_config.get_google_cloud_kms().is_some()); - } -} diff --git a/src/config/config_file/signer/turnkey.rs b/src/config/config_file/signer/turnkey.rs deleted file mode 100644 index 07f051862..000000000 --- a/src/config/config_file/signer/turnkey.rs +++ /dev/null @@ -1,298 +0,0 @@ -//! Configuration for Turnkey signer -//! -//! This module provides configuration for using Turnkey -//! as a signing mechanism. Turnkey is a custody platform that offers secure key management -//! and signing services without exposing private keys. -//! -//! The configuration supports: -//! - API credentials (public key and private key) for authenticating with Turnkey -//! - Organization ID to identify the Turnkey organization -//! - Private key ID to identify the specific private key within Turnkey -//! - Public key representation for verification -//! -//! Turnkey allows for secure signing operations where private keys are managed and -//! protected within Turnkey's secure infrastructure. -use crate::{ - config::ConfigFileError, - models::{validate_plain_or_env_value, PlainOrEnvValue}, -}; -use serde::{Deserialize, Serialize}; -use validator::Validate; - -use super::{validate_with_validator, SignerConfigValidate}; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Validate)] -#[serde(deny_unknown_fields)] -pub struct TurnkeySignerFileConfig { - #[validate(length(min = 1, message = "api_public_key field cannot be empty"))] - pub api_public_key: String, - #[validate(custom(function = "validate_plain_or_env_value"))] - pub api_private_key: PlainOrEnvValue, - #[validate(length(min = 1, message = "organization_id field cannot be empty"))] - pub organization_id: String, - #[validate(length(min = 1, message = "private_key_id cannot be empty"))] - pub private_key_id: String, - #[validate(length(min = 1, message = "public_key cannot be empty"))] - pub public_key: String, -} - -impl SignerConfigValidate for TurnkeySignerFileConfig { - fn validate(&self) -> Result<(), ConfigFileError> { - validate_with_validator(self) - } -} -#[cfg(test)] -mod tests { - use crate::models::SecretString; - - use super::*; - - #[test] - fn test_vault_transit_signer_file_config_valid() { - let config = TurnkeySignerFileConfig { - api_private_key: PlainOrEnvValue::Plain { - value: SecretString::new("api_private_key"), - }, - api_public_key: "public-key".to_string(), - organization_id: "org-id".to_string(), - private_key_id: "private-key-id".to_string(), - public_key: "public-key".to_string(), - }; - - assert!(Validate::validate(&config).is_ok()); - assert!(SignerConfigValidate::validate(&config).is_ok()); - } - - #[test] - fn test_turnkey_signer_file_config_empty_api_public_key() { - let config = TurnkeySignerFileConfig { - api_private_key: PlainOrEnvValue::Plain { - value: SecretString::new("api_private_key"), - }, - api_public_key: "".to_string(), - organization_id: "org-id".to_string(), - private_key_id: "private-key-id".to_string(), - public_key: "public-key".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("api_public_key")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_turnkey_signer_file_config_empty_api_private_key() { - let config = TurnkeySignerFileConfig { - api_private_key: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - api_public_key: "public-key".to_string(), - organization_id: "org-id".to_string(), - private_key_id: "private-key-id".to_string(), - public_key: "public-key".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("api_private_key")); - } - } - - #[test] - fn test_turnkey_signer_file_config_empty_organization_id() { - let config = TurnkeySignerFileConfig { - api_private_key: PlainOrEnvValue::Plain { - value: SecretString::new("api_private_key"), - }, - api_public_key: "public-key".to_string(), - organization_id: "".to_string(), - private_key_id: "private-key-id".to_string(), - public_key: "public-key".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("organization_id")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_turnkey_signer_file_config_empty_private_key_id() { - let config = TurnkeySignerFileConfig { - api_private_key: PlainOrEnvValue::Plain { - value: SecretString::new("api_private_key"), - }, - api_public_key: "public-key".to_string(), - organization_id: "org-id".to_string(), - private_key_id: "".to_string(), - public_key: "public-key".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("private_key_id")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_turnkey_signer_file_config_empty_public_key() { - let config = TurnkeySignerFileConfig { - api_private_key: PlainOrEnvValue::Plain { - value: SecretString::new("api_private_key"), - }, - api_public_key: "public-key".to_string(), - organization_id: "org-id".to_string(), - private_key_id: "private-key-id".to_string(), - public_key: "".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("public_key")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_turnkey_signer_file_config_multiple_errors() { - let config = TurnkeySignerFileConfig { - api_private_key: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - api_public_key: "".to_string(), - organization_id: "".to_string(), - private_key_id: "".to_string(), - public_key: "".to_string(), - }; - - let result = validate_with_validator(&config); - assert!(result.is_err()); - - if let Err(e) = result { - if let ConfigFileError::InvalidFormat(msg) = e { - assert!(msg.contains("api_public_key")); - assert!(msg.contains("api_private_key")); - assert!(msg.contains("organization_id")); - assert!(msg.contains("private_key_id")); - assert!(msg.contains("public_key")); - } else { - panic!("Expected ConfigFileError::InvalidFormat, got {:?}", e); - } - } - } - - #[test] - fn test_serde_deserialize() { - let json = r#" - { - "api_public_key": "turnkey-api-public-key", - "api_private_key": { - "type": "plain", - "value": "turnkey-api-private-key" - }, - "organization_id": "org-123456", - "private_key_id": "key-123456", - "public_key": "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==" - } - "#; - - let config: TurnkeySignerFileConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.api_public_key, "turnkey-api-public-key"); - assert_eq!( - config - .api_private_key - .get_value() - .unwrap() - .to_str() - .as_str(), - "turnkey-api-private-key" - ); - assert_eq!(config.organization_id, "org-123456"); - assert_eq!(config.private_key_id, "key-123456"); - assert_eq!(config.public_key, "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A=="); - } - - #[test] - fn test_serde_unknown_field() { - let json = r#" - { - "api_public_key": "turnkey-api-public-key", - "api_private_key": { - "type": "plain", - "value": "turnkey-api-private-key" - }, - "organization_id": "org-123456", - "private_key_id": "key-123456", - "public_key": "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==", - "unknown_field": "should cause error" - } - "#; - - let result: Result = serde_json::from_str(json); - assert!(result.is_err()); - } - - #[test] - fn test_serde_serialize_deserialize() { - let config = TurnkeySignerFileConfig { - api_private_key: PlainOrEnvValue::Plain { - value: SecretString::new("api_private_key"), - }, - api_public_key: "public-key".to_string(), - organization_id: "org-id".to_string(), - private_key_id: "private-key-id".to_string(), - public_key: "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==".to_string(), - }; - - let serialized = serde_json::to_string(&config).unwrap(); - let deserialized: TurnkeySignerFileConfig = serde_json::from_str(&serialized).unwrap(); - - assert_eq!(config.api_public_key, deserialized.api_public_key); - assert_eq!(config.organization_id, deserialized.organization_id); - assert_eq!(config.private_key_id, deserialized.private_key_id); - assert_eq!(config.public_key, deserialized.public_key); - } - - #[test] - fn test_turnkey_signer_file_config_env_variable() { - let env_var_name = "TEST_API_PRIVATE_KEY"; - std::env::set_var(env_var_name, "env-api-private-key"); - - let config = TurnkeySignerFileConfig { - api_private_key: PlainOrEnvValue::Env { - value: env_var_name.to_string(), - }, - api_public_key: "public-key".to_string(), - organization_id: "org-id".to_string(), - private_key_id: "private-key-id".to_string(), - public_key: "public-key".to_string(), - }; - - assert!(SignerConfigValidate::validate(&config).is_ok()); - assert_eq!( - config - .api_private_key - .get_value() - .unwrap() - .to_str() - .as_str(), - "env-api-private-key" - ); - - std::env::remove_var(env_var_name); - } -} diff --git a/src/config/config_file/signer/vault.rs b/src/config/config_file/signer/vault.rs deleted file mode 100644 index 65121f3b7..000000000 --- a/src/config/config_file/signer/vault.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! Configuration for a Vault signer -//! -//! This module provides configuration for interacting with HashiCorp Vault as a key -//! management system for signing operations. It supports both AppRole authentication. -//! -//! The configuration supports: -//! - Vault server address (URL) -//! - Optional namespace (for Vault Enterprise) -//! - AppRole authentication (role_id and secret_id) -//! - Key name to use for signing operations -//! - Optional mount point override for Transit engine -use crate::{ - config::ConfigFileError, - models::{validate_plain_or_env_value, PlainOrEnvValue}, -}; -use serde::{Deserialize, Serialize}; -use validator::Validate; - -use super::{validate_with_validator, SignerConfigValidate}; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Validate)] -#[serde(deny_unknown_fields)] -pub struct VaultSignerFileConfig { - #[validate(url)] - pub address: String, - pub namespace: Option, - #[validate(custom(function = "validate_plain_or_env_value"))] - pub role_id: PlainOrEnvValue, - #[validate(custom(function = "validate_plain_or_env_value"))] - pub secret_id: PlainOrEnvValue, - #[validate(length(min = 1, message = "Vault key name cannot be empty"))] - pub key_name: String, - pub mount_point: Option, -} - -impl SignerConfigValidate for VaultSignerFileConfig { - fn validate(&self) -> Result<(), ConfigFileError> { - validate_with_validator(self) - } -} - -#[cfg(test)] -mod tests { - use crate::models::SecretString; - - use super::*; - use validator::Validate; - - #[test] - fn test_vault_signer_file_config_valid() { - let config = VaultSignerFileConfig { - address: "https://vault.example.com:8200".to_string(), - namespace: Some("namespace1".to_string()), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - key_name: "my-key".to_string(), - mount_point: Some("transit".to_string()), - }; - - assert!(Validate::validate(&config).is_ok()); - assert!(SignerConfigValidate::validate(&config).is_ok()); - } - - #[test] - fn test_vault_signer_file_config_invalid_address() { - let config = VaultSignerFileConfig { - address: "not-a-url".to_string(), - namespace: Some("namespace1".to_string()), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - key_name: "my-key".to_string(), - mount_point: Some("transit".to_string()), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("address")); - } - } - - #[test] - fn test_vault_signer_file_config_empty_role_id() { - let config = VaultSignerFileConfig { - address: "https://vault.example.com:8200".to_string(), - namespace: Some("namespace1".to_string()), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - key_name: "my-key".to_string(), - mount_point: Some("transit".to_string()), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("role_id")); - } - } - - #[test] - fn test_vault_signer_file_config_empty_secret_id() { - let config = VaultSignerFileConfig { - address: "https://vault.example.com:8200".to_string(), - namespace: None, - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - key_name: "my-key".to_string(), - mount_point: None, - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("secret_id")); - } - } - - #[test] - fn test_vault_signer_file_config_empty_key_name() { - let config = VaultSignerFileConfig { - address: "https://vault.example.com:8200".to_string(), - namespace: None, - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - key_name: "".to_string(), - mount_point: None, - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("key_name")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_vault_signer_file_config_optional_fields() { - let config = VaultSignerFileConfig { - address: "https://vault.example.com:8200".to_string(), - namespace: None, - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - key_name: "my-key".to_string(), - mount_point: None, - }; - - assert!(SignerConfigValidate::validate(&config).is_ok()); - } - - #[test] - fn test_vault_signer_file_config_multiple_errors() { - // Create a config with multiple validation errors - let config = VaultSignerFileConfig { - address: "invalid-url".to_string(), - namespace: None, - role_id: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - key_name: "".to_string(), - mount_point: None, - }; - - let result = validate_with_validator(&config); - assert!(result.is_err()); - - if let Err(e) = result { - if let ConfigFileError::InvalidFormat(msg) = e { - assert!(msg.contains("address")); - assert!(msg.contains("role_id")); - assert!(msg.contains("secret_id")); - assert!(msg.contains("key_name")); - } else { - panic!("Expected ConfigFileError::InvalidFormat, got {:?}", e); - } - } - } - - #[test] - fn test_serde_deserialize() { - let json = r#" - { - "address": "https://vault.example.com:8200", - "namespace": "my-namespace", - "role_id": { - "type": "plain", - "value": "role-123" - }, - "secret_id": { - "type": "plain", - "value": "secret-456" - }, - "key_name": "my-key", - "mount_point": "transit" - } - "#; - - let config: VaultSignerFileConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.address, "https://vault.example.com:8200"); - assert_eq!(config.namespace, Some("my-namespace".to_string())); - assert_eq!( - config.role_id.get_value().unwrap().to_str().as_str(), - "role-123" - ); - assert_eq!( - config.secret_id.get_value().unwrap().to_str().as_str(), - "secret-456" - ); - assert_eq!(config.key_name, "my-key"); - assert_eq!(config.mount_point, Some("transit".to_string())); - } - - #[test] - fn test_serde_unknown_field() { - let json = r#" - { - "address": "https://vault.example.com:8200", - "namespace": "my-namespace", - "role_id": { - "type": "plain", - "value": "role-123" - }, - "secret_id": { - "type": "plain", - "value": "secret-456" - }, - "key_name": "my-key", - "mount_point": "transit", - "unknown_field": "should cause error" - } - "#; - - let result: Result = serde_json::from_str(json); - assert!(result.is_err()); - } - - #[test] - fn test_serde_serialize_deserialize() { - let config = VaultSignerFileConfig { - address: "https://vault.example.com:8200".to_string(), - namespace: Some("namespace1".to_string()), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - key_name: "my-key".to_string(), - mount_point: Some("transit".to_string()), - }; - - let serialized = serde_json::to_string(&config).unwrap(); - let deserialized: VaultSignerFileConfig = serde_json::from_str(&serialized).unwrap(); - - assert_eq!(config.address, deserialized.address); - assert_eq!(config.key_name, deserialized.key_name); - assert_eq!(config.mount_point, deserialized.mount_point); - assert_eq!(config.namespace, deserialized.namespace); - assert_ne!(config.role_id, deserialized.role_id); - assert_ne!(config.secret_id, deserialized.secret_id); - } -} diff --git a/src/config/config_file/signer/vault_cloud.rs b/src/config/config_file/signer/vault_cloud.rs deleted file mode 100644 index e4fb3295d..000000000 --- a/src/config/config_file/signer/vault_cloud.rs +++ /dev/null @@ -1,351 +0,0 @@ -//! Configuration for HashiCorp Vault Cloud signer -//! -//! This module provides configuration for integrating with HashiCorp Cloud Platform (HCP) Vault, -//! which is the managed service offering of Vault. The configuration handles the OAuth2 client -//! credentials flow required for authenticating with HCP. -//! -//! The configuration supports: -//! - Client ID and Secret for OAuth2 authentication -//! - Organization ID for the HCP account -//! - Project ID within the organization -//! - Application name for identification in logs and metrics -//! - Key name to use for signing operations -//! -//! HCP Vault differs from self-hosted Vault by requiring OAuth-based authentication -//! instead of token or AppRole based authentication methods. -use crate::{ - config::ConfigFileError, - models::{validate_plain_or_env_value, PlainOrEnvValue}, -}; -use serde::{Deserialize, Serialize}; -use validator::Validate; - -use super::{validate_with_validator, SignerConfigValidate}; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Validate)] -#[serde(deny_unknown_fields)] -pub struct VaultCloudSignerFileConfig { - #[validate(length(min = 1, message = "Client ID cannot be empty"))] - pub client_id: String, - #[validate(custom(function = "validate_plain_or_env_value"))] - pub client_secret: PlainOrEnvValue, - #[validate(length(min = 1, message = "Organization ID cannot be empty"))] - pub org_id: String, - #[validate(length(min = 1, message = "Project ID cannot be empty"))] - pub project_id: String, - #[validate(length(min = 1, message = "Application name cannot be empty"))] - pub app_name: String, - #[validate(length(min = 1, message = "Key name cannot be empty"))] - pub key_name: String, -} - -impl SignerConfigValidate for VaultCloudSignerFileConfig { - fn validate(&self) -> Result<(), ConfigFileError> { - validate_with_validator(self) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::SecretString; - - #[test] - fn test_vault_cloud_signer_file_config_valid() { - let config = VaultCloudSignerFileConfig { - client_id: "client-123".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new("secret-abc"), - }, - org_id: "org-456".to_string(), - project_id: "proj-789".to_string(), - app_name: "my-cloud-app".to_string(), - key_name: "hcp-key".to_string(), - }; - - assert!(Validate::validate(&config).is_ok()); - assert!(SignerConfigValidate::validate(&config).is_ok()); - } - - #[test] - fn test_vault_cloud_signer_file_config_empty_client_id() { - let config = VaultCloudSignerFileConfig { - client_id: "".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new("secret-abc"), - }, - org_id: "org-456".to_string(), - project_id: "proj-789".to_string(), - app_name: "my-cloud-app".to_string(), - key_name: "hcp-key".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("client_id")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_vault_cloud_signer_file_config_empty_client_secret() { - let config = VaultCloudSignerFileConfig { - client_id: "client-123".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - org_id: "org-456".to_string(), - project_id: "proj-789".to_string(), - app_name: "my-cloud-app".to_string(), - key_name: "hcp-key".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("client_secret")); - } - } - - #[test] - fn test_vault_cloud_signer_file_config_empty_org_id() { - let config = VaultCloudSignerFileConfig { - client_id: "client-123".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new("secret-abc"), - }, - org_id: "".to_string(), - project_id: "proj-789".to_string(), - app_name: "my-cloud-app".to_string(), - key_name: "hcp-key".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("org_id")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_vault_cloud_signer_file_config_empty_project_id() { - let config = VaultCloudSignerFileConfig { - client_id: "client-123".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new("secret-abc"), - }, - org_id: "org-456".to_string(), - project_id: "".to_string(), - app_name: "my-cloud-app".to_string(), - key_name: "hcp-key".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("project_id")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_vault_cloud_signer_file_config_empty_app_name() { - let config = VaultCloudSignerFileConfig { - client_id: "client-123".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new("secret-abc"), - }, - org_id: "org-456".to_string(), - project_id: "proj-789".to_string(), - app_name: "".to_string(), - key_name: "hcp-key".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("app_name")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_vault_cloud_signer_file_config_empty_key_name() { - let config = VaultCloudSignerFileConfig { - client_id: "client-123".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new("secret-abc"), - }, - org_id: "org-456".to_string(), - project_id: "proj-789".to_string(), - app_name: "my-cloud-app".to_string(), - key_name: "".to_string(), - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("key_name")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_vault_cloud_signer_file_config_multiple_errors() { - // Config with multiple validation errors - let config = VaultCloudSignerFileConfig { - client_id: "".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - org_id: "".to_string(), - project_id: "".to_string(), - app_name: "".to_string(), - key_name: "".to_string(), - }; - - let result = validate_with_validator(&config); - assert!(result.is_err()); - - if let Err(e) = result { - if let ConfigFileError::InvalidFormat(msg) = e { - assert!(msg.contains("client_id")); - assert!(msg.contains("client_secret")); - assert!(msg.contains("org_id")); - assert!(msg.contains("project_id")); - assert!(msg.contains("app_name")); - assert!(msg.contains("key_name")); - } else { - panic!("Expected ConfigFileError::InvalidFormat, got {:?}", e); - } - } - } - - #[test] - fn test_serde_deserialize() { - let json = r#" - { - "client_id": "client-123", - "client_secret": { - "type": "plain", - "value":"secret-abc" - }, - "org_id": "org-456", - "project_id": "proj-789", - "app_name": "my-cloud-app", - "key_name": "hcp-key" - } - "#; - - let config: VaultCloudSignerFileConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.client_id, "client-123"); - assert_eq!( - config.client_secret.get_value().unwrap().to_str().as_str(), - "secret-abc" - ); - assert_eq!(config.org_id, "org-456"); - assert_eq!(config.project_id, "proj-789"); - assert_eq!(config.app_name, "my-cloud-app"); - assert_eq!(config.key_name, "hcp-key"); - } - - #[test] - fn test_serde_unknown_field() { - let json = r#" - { - "client_id": "client-123", - "client_secret": "secret-abc", - "org_id": "org-456", - "project_id": "proj-789", - "app_name": "my-cloud-app", - "key_name": "hcp-key", - "unknown_field": "should cause error" - } - "#; - - let result: Result = serde_json::from_str(json); - assert!(result.is_err()); - } - - #[test] - fn test_serde_serialize_deserialize() { - let config = VaultCloudSignerFileConfig { - client_id: "client-123".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new("secret-abc"), - }, - org_id: "org-456".to_string(), - project_id: "proj-789".to_string(), - app_name: "my-cloud-app".to_string(), - key_name: "hcp-key".to_string(), - }; - - let serialized = serde_json::to_string(&config).unwrap(); - let deserialized: VaultCloudSignerFileConfig = serde_json::from_str(&serialized).unwrap(); - - assert_eq!(config.app_name, deserialized.app_name); - assert_eq!(config.client_id, deserialized.client_id); - assert_eq!(config.key_name, deserialized.key_name); - assert_eq!(config.org_id, deserialized.org_id); - assert_eq!(config.project_id, deserialized.project_id); - assert_ne!(config.client_secret, deserialized.client_secret); - } - - #[test] - fn test_serde_pretty_json() { - let json = r#"{ - "client_id": "client-123", - "client_secret": { - "type": "plain", - "value":"secret-abc" - }, - "org_id": "org-456", - "project_id": "proj-789", - "app_name": "my-cloud-app", - "key_name": "hcp-key" - }"#; - - let config: VaultCloudSignerFileConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.client_id, "client-123"); - assert_eq!( - config.client_secret.get_value().unwrap().to_str().as_str(), - "secret-abc" - ); - } - - #[test] - fn test_validate_with_validator() { - let valid_config = VaultCloudSignerFileConfig { - client_id: "client-123".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new("secret-abc"), - }, - org_id: "org-456".to_string(), - project_id: "proj-789".to_string(), - app_name: "my-cloud-app".to_string(), - key_name: "hcp-key".to_string(), - }; - - let invalid_config = VaultCloudSignerFileConfig { - client_id: "".to_string(), - client_secret: PlainOrEnvValue::Plain { - value: SecretString::new("secret-abc"), - }, - org_id: "org-456".to_string(), - project_id: "proj-789".to_string(), - app_name: "my-cloud-app".to_string(), - key_name: "hcp-key".to_string(), - }; - - assert!(Validate::validate(&valid_config).is_ok()); - assert!(Validate::validate(&invalid_config).is_err()); - } -} diff --git a/src/config/config_file/signer/vault_transit.rs b/src/config/config_file/signer/vault_transit.rs deleted file mode 100644 index 772533524..000000000 --- a/src/config/config_file/signer/vault_transit.rs +++ /dev/null @@ -1,327 +0,0 @@ -//! Configuration for HashiCorp Vault Transit engine signer -//! -//! This module provides configuration for using HashiCorp Vault's Transit engine -//! as a signing mechanism. Transit is Vault's cryptographic backend that allows -//! for signing operations without exposing private keys. -//! -//! The configuration supports: -//! - Key name for the Transit engine key to use -//! - Vault server address (URL) -//! - AppRole authentication (role_id and secret_id) -//! - Public key representation for verification -//! - Optional mount point override for the Transit engine -//! - Optional namespace (for Vault Enterprise) -//! -//! Unlike regular Vault configuration, this specifically targets the Transit -//! engine use case where keys are managed and stored within Vault itself. -use crate::{ - config::ConfigFileError, - models::{validate_plain_or_env_value, PlainOrEnvValue}, -}; -use serde::{Deserialize, Serialize}; -use validator::Validate; - -use super::{validate_with_validator, SignerConfigValidate}; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Validate)] -#[serde(deny_unknown_fields)] -pub struct VaultTransitSignerFileConfig { - #[validate(length(min = 1, message = "Key name cannot be empty"))] - pub key_name: String, - #[validate(url)] - pub address: String, - #[validate(custom(function = "validate_plain_or_env_value"))] - pub role_id: PlainOrEnvValue, - #[validate(custom(function = "validate_plain_or_env_value"))] - pub secret_id: PlainOrEnvValue, - #[validate(length(min = 1, message = "pubkey cannot be empty"))] - pub pubkey: String, - pub mount_point: Option, - pub namespace: Option, -} - -impl SignerConfigValidate for VaultTransitSignerFileConfig { - fn validate(&self) -> Result<(), ConfigFileError> { - validate_with_validator(self) - } -} -#[cfg(test)] -mod tests { - use crate::models::SecretString; - - use super::*; - - #[test] - fn test_vault_transit_signer_file_config_valid() { - let config = VaultTransitSignerFileConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com:8200".to_string(), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - pubkey: "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==".to_string(), - mount_point: Some("transit".to_string()), - namespace: Some("namespace1".to_string()), - }; - - assert!(Validate::validate(&config).is_ok()); - assert!(SignerConfigValidate::validate(&config).is_ok()); - } - - #[test] - fn test_vault_transit_signer_file_config_invalid_address() { - let config = VaultTransitSignerFileConfig { - key_name: "transit-key".to_string(), - address: "not-a-url".to_string(), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - pubkey: "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==".to_string(), - mount_point: Some("transit".to_string()), - namespace: None, - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("address")); - } - } - - #[test] - fn test_vault_transit_signer_file_config_empty_key_name() { - let config = VaultTransitSignerFileConfig { - key_name: "".to_string(), - address: "https://vault.example.com:8200".to_string(), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - pubkey: "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==".to_string(), - mount_point: Some("transit".to_string()), - namespace: None, - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("key_name")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_vault_transit_signer_file_config_empty_role_id() { - let config = VaultTransitSignerFileConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com:8200".to_string(), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - pubkey: "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==".to_string(), - mount_point: Some("transit".to_string()), - namespace: None, - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("role_id")); - } - } - - #[test] - fn test_vault_transit_signer_file_config_empty_secret_id() { - let config = VaultTransitSignerFileConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com:8200".to_string(), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new(""), - }, - pubkey: "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==".to_string(), - mount_point: Some("transit".to_string()), - namespace: None, - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("secret_id")); - } - } - - #[test] - fn test_vault_transit_signer_file_config_empty_pubkey() { - let config = VaultTransitSignerFileConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com:8200".to_string(), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - pubkey: "".to_string(), - mount_point: Some("transit".to_string()), - namespace: None, - }; - - let result = SignerConfigValidate::validate(&config); - assert!(result.is_err()); - if let Err(e) = result { - let error_message = format!("{:?}", e); - assert!(error_message.contains("pubkey")); - assert!(error_message.contains("cannot be empty")); - } - } - - #[test] - fn test_vault_transit_signer_file_config_optional_fields() { - let config = VaultTransitSignerFileConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com:8200".to_string(), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - pubkey: "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==".to_string(), - mount_point: None, - namespace: None, - }; - - assert!(SignerConfigValidate::validate(&config).is_ok()); - } - - #[test] - fn test_vault_transit_signer_file_config_multiple_errors() { - let config = VaultTransitSignerFileConfig { - key_name: "".to_string(), - address: "invalid-url".to_string(), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - pubkey: "".to_string(), - mount_point: None, - namespace: None, - }; - - let result = validate_with_validator(&config); - assert!(result.is_err()); - - if let Err(e) = result { - if let ConfigFileError::InvalidFormat(msg) = e { - assert!(msg.contains("key_name")); - assert!(msg.contains("address")); - assert!(msg.contains("pubkey")); - } else { - panic!("Expected ConfigFileError::InvalidFormat, got {:?}", e); - } - } - } - - #[test] - fn test_serde_deserialize() { - let json = r#" - { - "key_name": "transit-key", - "address": "https://vault.example.com:8200", - "role_id": { - "type": "plain", - "value": "role-123" - }, - "secret_id": { - "type": "plain", - "value": "secret-456" - }, - "pubkey": "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==", - "mount_point": "transit", - "namespace": "my-namespace" - } - "#; - - let config: VaultTransitSignerFileConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.key_name, "transit-key"); - assert_eq!(config.address, "https://vault.example.com:8200"); - assert_eq!( - config.role_id.get_value().unwrap().to_str().as_str(), - "role-123" - ); - assert_eq!( - config.secret_id.get_value().unwrap().to_str().as_str(), - "secret-456" - ); - assert_eq!(config.pubkey, "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A=="); - assert_eq!(config.mount_point, Some("transit".to_string())); - assert_eq!(config.namespace, Some("my-namespace".to_string())); - } - - #[test] - fn test_serde_unknown_field() { - let json = r#" - { - "key_name": "transit-key", - "address": "https://vault.example.com:8200", - "role_id": "role-123", - "secret_id": "secret-456", - "pubkey": "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==", - "mount_point": "transit", - "namespace": "my-namespace", - "unknown_field": "should cause error" - } - "#; - - let result: Result = serde_json::from_str(json); - assert!(result.is_err()); - } - - #[test] - fn test_serde_serialize_deserialize() { - let config = VaultTransitSignerFileConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com:8200".to_string(), - role_id: PlainOrEnvValue::Plain { - value: SecretString::new("role-123"), - }, - secret_id: PlainOrEnvValue::Plain { - value: SecretString::new("secret-456"), - }, - pubkey: "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEd+vn+WOG+lGUiJCzHsj8VItmr7Lmdv/Zr+tIhJM7rM+QT9QEzvEX2jWOPyXrvCwUyvVgWoMwUYIo3hd1PFTy7A==".to_string(), - mount_point: Some("transit".to_string()), - namespace: Some("namespace1".to_string()), - }; - - let serialized = serde_json::to_string(&config).unwrap(); - let deserialized: VaultTransitSignerFileConfig = serde_json::from_str(&serialized).unwrap(); - - assert_eq!(config.address, deserialized.address); - assert_eq!(config.key_name, deserialized.key_name); - assert_eq!(config.mount_point, deserialized.mount_point); - assert_eq!(config.namespace, deserialized.namespace); - assert_eq!(config.pubkey, deserialized.pubkey); - assert_ne!(config.role_id, deserialized.role_id); - assert_ne!(config.secret_id, deserialized.secret_id); - } -} diff --git a/src/models/mod.rs b/src/models/mod.rs index 305793516..d06a0ac50 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -23,7 +23,7 @@ pub use error::*; mod pagination; pub use pagination::*; -mod signer; +pub mod signer; pub use signer::*; mod address; diff --git a/src/models/signer/config.rs b/src/models/signer/config.rs new file mode 100644 index 000000000..c9d405e2b --- /dev/null +++ b/src/models/signer/config.rs @@ -0,0 +1,598 @@ +//! Configuration file representation and parsing for signers. +//! +//! This module handles the configuration file format for signers, providing: +//! +//! - **Config Models**: Structures that match the configuration file schema +//! - **Conversions**: Bidirectional mapping between config and domain models +//! - **Collections**: Container types for managing multiple signer configurations +//! +//! Used primarily during application startup to parse signer settings from config files. +//! Validation is handled by the domain model in signer.rs to ensure reusability. + +use crate::{ + config::ConfigFileError, + models::signer::signer::{ + AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, + GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, Signer, SignerConfig, + TurnkeySignerConfig, VaultCloudSignerConfig, VaultSignerConfig, VaultTransitSignerConfig, + }, + models::PlainOrEnvValue, +}; +use secrets::SecretVec; +use serde::{Deserialize, Serialize}; +use std::{collections::HashSet, path::Path}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct LocalSignerFileConfig { + pub path: String, + pub passphrase: PlainOrEnvValue, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct AwsKmsSignerFileConfig { + pub region: String, + pub key_id: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct TurnkeySignerFileConfig { + pub api_public_key: String, + pub api_private_key: PlainOrEnvValue, + pub organization_id: String, + pub private_key_id: String, + pub public_key: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct VaultSignerFileConfig { + pub address: String, + pub namespace: Option, + pub role_id: PlainOrEnvValue, + pub secret_id: PlainOrEnvValue, + pub key_name: String, + pub mount_point: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct VaultCloudSignerFileConfig { + pub client_id: String, + pub client_secret: PlainOrEnvValue, + pub org_id: String, + pub project_id: String, + pub app_name: String, + pub key_name: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct VaultTransitSignerFileConfig { + pub key_name: String, + pub address: String, + pub role_id: PlainOrEnvValue, + pub secret_id: PlainOrEnvValue, + pub pubkey: String, + pub mount_point: Option, + pub namespace: Option, +} + +fn default_auth_uri() -> String { + "https://accounts.google.com/o/oauth2/auth".to_string() +} + +fn default_token_uri() -> String { + "https://oauth2.googleapis.com/token".to_string() +} + +fn default_auth_provider_x509_cert_url() -> String { + "https://www.googleapis.com/oauth2/v1/certs".to_string() +} + +fn default_client_x509_cert_url() -> String { + "https://www.googleapis.com/robot/v1/metadata/x509/solana-signer%40forward-emitter-459820-r7.iam.gserviceaccount.com".to_string() +} + +fn default_universe_domain() -> String { + "googleapis.com".to_string() +} + +fn default_key_version() -> u32 { + 1 +} + +fn default_location() -> String { + "global".to_string() +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ServiceAccountFileConfig { + pub project_id: String, + pub private_key_id: PlainOrEnvValue, + pub private_key: PlainOrEnvValue, + pub client_email: PlainOrEnvValue, + pub client_id: String, + #[serde(default = "default_auth_uri")] + pub auth_uri: String, + #[serde(default = "default_token_uri")] + pub token_uri: String, + #[serde(default = "default_auth_provider_x509_cert_url")] + pub auth_provider_x509_cert_url: String, + #[serde(default = "default_client_x509_cert_url")] + pub client_x509_cert_url: String, + #[serde(default = "default_universe_domain")] + pub universe_domain: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct KmsKeyFileConfig { + #[serde(default = "default_location")] + pub location: String, + pub key_ring_id: String, + pub key_id: String, + #[serde(default = "default_key_version")] + pub key_version: u32, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct GoogleCloudKmsSignerFileConfig { + pub service_account: ServiceAccountFileConfig, + pub key: KmsKeyFileConfig, +} + +/// Main enum for all signer config types +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(tag = "type", rename_all = "lowercase", content = "config")] +pub enum SignerFileConfigEnum { + Local(LocalSignerFileConfig), + #[serde(rename = "aws_kms")] + AwsKms(AwsKmsSignerFileConfig), + Turnkey(TurnkeySignerFileConfig), + Vault(VaultSignerFileConfig), + #[serde(rename = "vault_cloud")] + VaultCloud(VaultCloudSignerFileConfig), + #[serde(rename = "vault_transit")] + VaultTransit(VaultTransitSignerFileConfig), + #[serde(rename = "google_cloud_kms")] + GoogleCloudKms(GoogleCloudKmsSignerFileConfig), +} + +/// Individual signer configuration from config file +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct SignerFileConfig { + pub id: String, + #[serde(flatten)] + pub config: SignerFileConfigEnum, +} + +/// Collection of signer configurations +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct SignersFileConfig { + pub signers: Vec, +} + +impl SignerFileConfig { + pub fn validate_basic(&self) -> Result<(), ConfigFileError> { + if self.id.is_empty() { + return Err(ConfigFileError::InvalidIdLength( + "Signer ID cannot be empty".into(), + )); + } + Ok(()) + } +} + +impl SignersFileConfig { + pub fn new(signers: Vec) -> Self { + Self { signers } + } + + pub fn validate(&self) -> Result<(), ConfigFileError> { + if self.signers.is_empty() { + return Err(ConfigFileError::MissingField("signers".into())); + } + + let mut ids = HashSet::new(); + for signer in &self.signers { + signer.validate_basic()?; + if !ids.insert(signer.id.clone()) { + return Err(ConfigFileError::DuplicateId(signer.id.clone())); + } + } + Ok(()) + } +} + +// ===== CONVERSION IMPLEMENTATIONS ===== + +impl TryFrom for LocalSignerConfig { + type Error = ConfigFileError; + + fn try_from(config: LocalSignerFileConfig) -> Result { + if config.path.is_empty() { + return Err(ConfigFileError::InvalidIdLength( + "Signer path cannot be empty".into(), + )); + } + + let path = Path::new(&config.path); + if !path.exists() { + return Err(ConfigFileError::FileNotFound(format!( + "Signer file not found at path: {}", + path.display() + ))); + } + + if !path.is_file() { + return Err(ConfigFileError::InvalidFormat(format!( + "Path exists but is not a file: {}", + path.display() + ))); + } + + let passphrase = config.passphrase.get_value().map_err(|e| { + ConfigFileError::InvalidFormat(format!("Failed to get passphrase value: {}", e)) + })?; + + let raw_key = SecretVec::new(32, |buffer| { + let loaded = oz_keystore::LocalClient::load( + Path::new(&config.path).to_path_buf(), + passphrase.to_str().as_str().to_string(), + ); + buffer.copy_from_slice(&loaded); + }); + + Ok(LocalSignerConfig { raw_key }) + } +} + +impl TryFrom for AwsKmsSignerConfig { + type Error = ConfigFileError; + + fn try_from(config: AwsKmsSignerFileConfig) -> Result { + Ok(AwsKmsSignerConfig { + region: Some(config.region), + key_id: config.key_id, + }) + } +} + +impl TryFrom for TurnkeySignerConfig { + type Error = ConfigFileError; + + fn try_from(config: TurnkeySignerFileConfig) -> Result { + let api_private_key = config.api_private_key.get_value().map_err(|e| { + ConfigFileError::InvalidFormat(format!("Failed to get API private key: {}", e)) + })?; + + Ok(TurnkeySignerConfig { + api_public_key: config.api_public_key, + api_private_key, + organization_id: config.organization_id, + private_key_id: config.private_key_id, + public_key: config.public_key, + }) + } +} + +impl TryFrom for VaultSignerConfig { + type Error = ConfigFileError; + + fn try_from(config: VaultSignerFileConfig) -> Result { + let role_id = config + .role_id + .get_value() + .map_err(|e| ConfigFileError::InvalidFormat(format!("Failed to get role ID: {}", e)))?; + + let secret_id = config.secret_id.get_value().map_err(|e| { + ConfigFileError::InvalidFormat(format!("Failed to get secret ID: {}", e)) + })?; + + Ok(VaultSignerConfig { + address: config.address, + namespace: config.namespace, + role_id, + secret_id, + key_name: config.key_name, + mount_point: config.mount_point, + }) + } +} + +impl TryFrom for VaultCloudSignerConfig { + type Error = ConfigFileError; + + fn try_from(config: VaultCloudSignerFileConfig) -> Result { + let client_secret = config.client_secret.get_value().map_err(|e| { + ConfigFileError::InvalidFormat(format!("Failed to get client secret: {}", e)) + })?; + + Ok(VaultCloudSignerConfig { + client_id: config.client_id, + client_secret, + org_id: config.org_id, + project_id: config.project_id, + app_name: config.app_name, + key_name: config.key_name, + }) + } +} + +impl TryFrom for VaultTransitSignerConfig { + type Error = ConfigFileError; + + fn try_from(config: VaultTransitSignerFileConfig) -> Result { + let role_id = config + .role_id + .get_value() + .map_err(|e| ConfigFileError::InvalidFormat(format!("Failed to get role ID: {}", e)))?; + + let secret_id = config.secret_id.get_value().map_err(|e| { + ConfigFileError::InvalidFormat(format!("Failed to get secret ID: {}", e)) + })?; + + Ok(VaultTransitSignerConfig { + key_name: config.key_name, + address: config.address, + namespace: config.namespace, + role_id, + secret_id, + pubkey: config.pubkey, + mount_point: config.mount_point, + }) + } +} + +impl TryFrom for GoogleCloudKmsSignerConfig { + type Error = ConfigFileError; + + fn try_from(config: GoogleCloudKmsSignerFileConfig) -> Result { + let private_key = config + .service_account + .private_key + .get_value() + .map_err(|e| { + ConfigFileError::InvalidFormat(format!("Failed to get private key: {}", e)) + })?; + + let private_key_id = config + .service_account + .private_key_id + .get_value() + .map_err(|e| { + ConfigFileError::InvalidFormat(format!("Failed to get private key ID: {}", e)) + })?; + + let client_email = config + .service_account + .client_email + .get_value() + .map_err(|e| { + ConfigFileError::InvalidFormat(format!("Failed to get client email: {}", e)) + })?; + + let service_account = GoogleCloudKmsSignerServiceAccountConfig { + private_key, + private_key_id, + project_id: config.service_account.project_id, + client_email, + client_id: config.service_account.client_id, + auth_uri: config.service_account.auth_uri, + token_uri: config.service_account.token_uri, + auth_provider_x509_cert_url: config.service_account.auth_provider_x509_cert_url, + client_x509_cert_url: config.service_account.client_x509_cert_url, + universe_domain: config.service_account.universe_domain, + }; + + let key = GoogleCloudKmsSignerKeyConfig { + location: config.key.location, + key_ring_id: config.key.key_ring_id, + key_id: config.key.key_id, + key_version: config.key.key_version, + }; + + Ok(GoogleCloudKmsSignerConfig { + service_account, + key, + }) + } +} + +impl TryFrom for SignerConfig { + type Error = ConfigFileError; + + fn try_from(config: SignerFileConfigEnum) -> Result { + match config { + SignerFileConfigEnum::Local(local) => { + Ok(SignerConfig::Local(LocalSignerConfig::try_from(local)?)) + } + SignerFileConfigEnum::AwsKms(aws_kms) => { + Ok(SignerConfig::AwsKms(AwsKmsSignerConfig::try_from(aws_kms)?)) + } + SignerFileConfigEnum::Turnkey(turnkey) => Ok(SignerConfig::Turnkey( + TurnkeySignerConfig::try_from(turnkey)?, + )), + SignerFileConfigEnum::Vault(vault) => { + Ok(SignerConfig::Vault(VaultSignerConfig::try_from(vault)?)) + } + SignerFileConfigEnum::VaultCloud(vault_cloud) => Ok(SignerConfig::VaultCloud( + VaultCloudSignerConfig::try_from(vault_cloud)?, + )), + SignerFileConfigEnum::VaultTransit(vault_transit) => Ok(SignerConfig::VaultTransit( + VaultTransitSignerConfig::try_from(vault_transit)?, + )), + SignerFileConfigEnum::GoogleCloudKms(gcp_kms) => Ok(SignerConfig::GoogleCloudKms( + GoogleCloudKmsSignerConfig::try_from(gcp_kms)?, + )), + } + } +} + +impl TryFrom for Signer { + type Error = ConfigFileError; + + fn try_from(config: SignerFileConfig) -> Result { + config.validate_basic()?; + + let signer_config = SignerConfig::try_from(config.config)?; + + // Create core signer with configuration + let signer = Signer::new(config.id, signer_config, None, None); + + // Validate using domain model validation logic + signer.validate().map_err(|e| match e { + crate::models::signer::signer::SignerValidationError::EmptyId => { + ConfigFileError::MissingField("signer id".into()) + } + crate::models::signer::signer::SignerValidationError::InvalidIdFormat => { + ConfigFileError::InvalidFormat("Invalid signer ID format".into()) + } + crate::models::signer::signer::SignerValidationError::EmptyName => { + ConfigFileError::InvalidFormat("Signer name cannot be empty".into()) + } + crate::models::signer::signer::SignerValidationError::EmptyDescription => { + ConfigFileError::InvalidFormat("Signer description cannot be empty".into()) + } + crate::models::signer::signer::SignerValidationError::InvalidConfig(msg) => { + ConfigFileError::InvalidFormat(format!("Invalid signer configuration: {}", msg)) + } + })?; + + Ok(signer) + } +} + +#[cfg(test)] +mod tests { + use crate::models::SecretString; + + use super::*; + + #[test] + fn test_aws_kms_conversion() { + let config = AwsKmsSignerFileConfig { + region: "us-east-1".to_string(), + key_id: "test-key-id".to_string(), + }; + + let result = AwsKmsSignerConfig::try_from(config); + assert!(result.is_ok()); + + let aws_config = result.unwrap(); + assert_eq!(aws_config.region, Some("us-east-1".to_string())); + assert_eq!(aws_config.key_id, "test-key-id"); + } + + #[test] + fn test_turnkey_conversion() { + let config = TurnkeySignerFileConfig { + api_public_key: "test-public-key".to_string(), + api_private_key: PlainOrEnvValue::Plain { + value: SecretString::new("test-private-key"), + }, + organization_id: "test-org".to_string(), + private_key_id: "test-private-key-id".to_string(), + public_key: "test-public-key".to_string(), + }; + + let result = TurnkeySignerConfig::try_from(config); + assert!(result.is_ok()); + + let turnkey_config = result.unwrap(); + assert_eq!(turnkey_config.api_public_key, "test-public-key"); + assert_eq!(turnkey_config.organization_id, "test-org"); + } + + #[test] + fn test_signer_file_config_validation() { + let signer_config = SignerFileConfig { + id: "test-signer".to_string(), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), + }; + + assert!(signer_config.validate_basic().is_ok()); + } + + #[test] + fn test_empty_signer_id() { + let signer_config = SignerFileConfig { + id: "".to_string(), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), + }; + + assert!(signer_config.validate_basic().is_err()); + } + + #[test] + fn test_signers_config_validation() { + let configs = SignersFileConfig::new(vec![ + SignerFileConfig { + id: "signer1".to_string(), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), + }, + SignerFileConfig { + id: "signer2".to_string(), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), + }, + ]); + + assert!(configs.validate().is_ok()); + } + + #[test] + fn test_duplicate_signer_ids() { + let configs = SignersFileConfig::new(vec![ + SignerFileConfig { + id: "signer1".to_string(), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), + }, + SignerFileConfig { + id: "signer1".to_string(), // Duplicate ID + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "test-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }), + }, + ]); + + assert!(matches!( + configs.validate(), + Err(ConfigFileError::DuplicateId(_)) + )); + } +} diff --git a/src/models/signer/mod.rs b/src/models/signer/mod.rs index 6a5c34a21..44d862968 100644 --- a/src/models/signer/mod.rs +++ b/src/models/signer/mod.rs @@ -1,2 +1,28 @@ mod repository; -pub use repository::*; +pub use repository::{ + AwsKmsSignerConfigStorage, + GoogleCloudKmsSignerConfigStorage, + GoogleCloudKmsSignerKeyConfigStorage, + GoogleCloudKmsSignerServiceAccountConfigStorage, + // Don't re-export SignerConfig or config structs from repository to avoid conflicts with domain + LocalSignerConfigStorage, + SignerConfigStorage, + SignerRepoModel, + SignerRepoModelStorage, + TurnkeySignerConfigStorage, + VaultCloudSignerConfigStorage, + VaultSignerConfigStorage, + VaultTransitSignerConfigStorage, +}; + +mod config; +pub use config::*; // Export config file models + +pub mod signer; // Make public for access from other modules +pub use signer::*; // This exports domain models including Signer, SignerConfig, SignerType, etc. + +mod request; +pub use request::*; + +mod response; +pub use response::*; diff --git a/src/models/signer/repository.rs b/src/models/signer/repository.rs index 88bf0f11e..14a8933b7 100644 --- a/src/models/signer/repository.rs +++ b/src/models/signer/repository.rs @@ -1,96 +1,63 @@ -use secrets::SecretVec; -use serde::{Deserialize, Serialize, Serializer}; +//! Repository layer models and data persistence for signers. +//! +//! This module provides the data layer representation of signers, including: +//! +//! - **Repository Models**: Data structures optimized for storage and retrieval +//! - **Data Conversions**: Mapping between domain objects and repository representations +//! - **Persistence Logic**: Storage-specific validation and constraints +//! +//! Acts as the bridge between the domain layer and actual data storage implementations +//! (in-memory, Redis, etc.), ensuring consistent data representation across repositories. use crate::{ - models::SecretString, + models::signer::signer::{Signer, SignerConfig, SignerValidationError}, utils::{base64_decode, base64_encode}, }; +use secrets::SecretVec; +use serde::{Deserialize, Serialize, Serializer}; -fn serialize_secret_redacted(_secret: &SecretVec, serializer: S) -> Result -where - S: Serializer, -{ - serializer.serialize_str("[REDACTED]") -} - -fn serialize_secret_base64(_secret: &SecretVec, serializer: S) -> Result +/// Helper function to serialize secrets as base64 for storage +fn serialize_secret_base64(secret: &SecretVec, serializer: S) -> Result where S: Serializer, { - let base64 = base64_encode(_secret.borrow().as_ref()); + let base64 = base64_encode(secret.borrow().as_ref()); serializer.serialize_str(&base64) } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum SignerType { - Test, - Local, - #[serde(rename = "aws_kms")] - AwsKms, - Vault, - Turnkey, -} - +/// Repository model for signer storage and retrieval #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignerRepoModel { pub id: String, pub config: SignerConfig, } -/// This is the model used for storing the signer config in the database. +/// Storage model for direct serialization/deserialization #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignerRepoModelStorage { pub id: String, pub config: SignerConfigStorage, } -impl From for SignerRepoModelStorage { - fn from(model: SignerRepoModel) -> Self { - Self { - id: model.id, - config: model.config.into(), - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct LocalSignerConfig { - #[serde(serialize_with = "serialize_secret_redacted")] - pub raw_key: SecretVec, +/// Storage-optimized configuration for signers +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SignerConfigStorage { + Local(LocalSignerConfigStorage), + Vault(VaultSignerConfigStorage), + VaultCloud(VaultCloudSignerConfigStorage), + VaultTransit(VaultTransitSignerConfigStorage), + AwsKms(AwsKmsSignerConfigStorage), + Turnkey(TurnkeySignerConfigStorage), + GoogleCloudKms(GoogleCloudKmsSignerConfigStorage), } +/// Local signer configuration for storage (with base64 encoding) #[derive(Debug, Clone, Serialize)] pub struct LocalSignerConfigStorage { #[serde(serialize_with = "serialize_secret_base64")] pub raw_key: SecretVec, } -impl<'de> Deserialize<'de> for LocalSignerConfig { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct LocalSignerConfigHelper { - raw_key: String, - } - - let helper = LocalSignerConfigHelper::deserialize(deserializer)?; - let raw_key = if helper.raw_key == "[REDACTED]" { - // Return a zero-filled SecretVec when deserializing redacted data - SecretVec::zero(32) - } else { - // For actual data, try to decode as base64 - let decoded = base64_decode(&helper.raw_key) - .map_err(|e| serde::de::Error::custom(format!("Invalid base64: {}", e)))?; - SecretVec::new(decoded.len(), |v| v.copy_from_slice(&decoded)) - }; - - Ok(LocalSignerConfig { raw_key }) - } -} - impl<'de> Deserialize<'de> for LocalSignerConfigStorage { fn deserialize(deserializer: D) -> Result where @@ -102,52 +69,67 @@ impl<'de> Deserialize<'de> for LocalSignerConfigStorage { } let helper = LocalSignerConfigHelper::deserialize(deserializer)?; - let raw_key = if helper.raw_key == "[REDACTED]" { - // Return a zero-filled SecretVec when deserializing redacted data - SecretVec::zero(32) - } else { - // For actual data, try to decode as base64 - let decoded = base64_decode(&helper.raw_key) - .map_err(|e| serde::de::Error::custom(format!("Invalid base64: {}", e)))?; - SecretVec::new(decoded.len(), |v| v.copy_from_slice(&decoded)) - }; + let decoded = base64_decode(&helper.raw_key) + .map_err(|e| serde::de::Error::custom(format!("Invalid base64: {}", e)))?; + let raw_key = SecretVec::new(decoded.len(), |v| v.copy_from_slice(&decoded)); Ok(LocalSignerConfigStorage { raw_key }) } } +/// Storage representations for other signer types (these are simpler as they don't contain secrets that need encoding) #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AwsKmsSignerConfig { +pub struct AwsKmsSignerConfigStorage { pub region: Option, pub key_id: String, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VaultTransitSignerConfig { +pub struct VaultSignerConfigStorage { + pub address: String, + pub namespace: Option, + pub role_id: String, // Stored as string for simplicity + pub secret_id: String, // Stored as string for simplicity + pub key_name: String, + pub mount_point: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultCloudSignerConfigStorage { + pub client_id: String, + pub client_secret: String, // Stored as string for simplicity + pub org_id: String, + pub project_id: String, + pub app_name: String, + pub key_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultTransitSignerConfigStorage { pub key_name: String, pub address: String, pub namespace: Option, - pub role_id: SecretString, - pub secret_id: SecretString, + pub role_id: String, // Stored as string for simplicity + pub secret_id: String, // Stored as string for simplicity pub pubkey: String, pub mount_point: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TurnkeySignerConfig { +pub struct TurnkeySignerConfigStorage { pub api_public_key: String, - pub api_private_key: SecretString, + pub api_private_key: String, // Stored as string for simplicity pub organization_id: String, pub private_key_id: String, pub public_key: String, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GoogleCloudKmsSignerServiceAccountConfig { - pub private_key: SecretString, - pub private_key_id: SecretString, +pub struct GoogleCloudKmsSignerServiceAccountConfigStorage { + pub private_key: String, // Stored as string for simplicity + pub private_key_id: String, // Stored as string for simplicity pub project_id: String, - pub client_email: SecretString, + pub client_email: String, // Stored as string for simplicity pub client_id: String, pub auth_uri: String, pub token_uri: String, @@ -157,7 +139,7 @@ pub struct GoogleCloudKmsSignerServiceAccountConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GoogleCloudKmsSignerKeyConfig { +pub struct GoogleCloudKmsSignerKeyConfigStorage { pub location: String, pub key_ring_id: String, pub key_id: String, @@ -165,524 +147,388 @@ pub struct GoogleCloudKmsSignerKeyConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GoogleCloudKmsSignerConfig { - pub service_account: GoogleCloudKmsSignerServiceAccountConfig, - pub key: GoogleCloudKmsSignerKeyConfig, +pub struct GoogleCloudKmsSignerConfigStorage { + pub service_account: GoogleCloudKmsSignerServiceAccountConfigStorage, + pub key: GoogleCloudKmsSignerKeyConfigStorage, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum SignerConfig { - Test(LocalSignerConfig), - Local(LocalSignerConfig), - Vault(LocalSignerConfig), - VaultCloud(LocalSignerConfig), - VaultTransit(VaultTransitSignerConfig), - AwsKms(AwsKmsSignerConfig), - Turnkey(TurnkeySignerConfig), - GoogleCloudKms(GoogleCloudKmsSignerConfig), +/// Convert from domain model to repository model +impl From for SignerRepoModel { + fn from(signer: Signer) -> Self { + Self { + id: signer.id, + config: signer.config, + } + } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum SignerConfigStorage { - Test(LocalSignerConfigStorage), - Local(LocalSignerConfigStorage), - Vault(LocalSignerConfig), - VaultCloud(LocalSignerConfigStorage), - VaultTransit(VaultTransitSignerConfig), - AwsKms(AwsKmsSignerConfig), - Turnkey(TurnkeySignerConfig), - GoogleCloudKms(GoogleCloudKmsSignerConfig), +/// Convert repository model to storage model +impl From for SignerRepoModelStorage { + fn from(model: SignerRepoModel) -> Self { + Self { + id: model.id, + config: model.config.into(), + } + } +} + +/// Convert from repository model to domain model +impl From for Signer { + fn from(repo_model: SignerRepoModel) -> Self { + Self { + id: repo_model.id, + config: repo_model.config, + name: None, // Repository doesn't store metadata + description: None, // Repository doesn't store metadata + } + } } +/// Convert domain config to storage config impl From for SignerConfigStorage { fn from(config: SignerConfig) -> Self { match config { - SignerConfig::Local(local_config) => SignerConfigStorage::Local(local_config.into()), - SignerConfig::AwsKms(aws_config) => SignerConfigStorage::AwsKms(aws_config), - SignerConfig::GoogleCloudKms(gcp_config) => { - SignerConfigStorage::GoogleCloudKms(gcp_config) + SignerConfig::Local(local) => SignerConfigStorage::Local(local.into()), + SignerConfig::AwsKms(aws) => SignerConfigStorage::AwsKms(aws.into()), + SignerConfig::Vault(vault) => SignerConfigStorage::Vault(vault.into()), + SignerConfig::VaultCloud(vault_cloud) => { + SignerConfigStorage::VaultCloud(vault_cloud.into()) } - SignerConfig::Turnkey(turnkey_config) => SignerConfigStorage::Turnkey(turnkey_config), - SignerConfig::Vault(vault_config) => SignerConfigStorage::Vault(vault_config), - SignerConfig::Test(test_config) => SignerConfigStorage::Test(test_config.into()), - SignerConfig::VaultCloud(vault_cloud_config) => { - SignerConfigStorage::VaultCloud(vault_cloud_config.into()) + SignerConfig::VaultTransit(vault_transit) => { + SignerConfigStorage::VaultTransit(vault_transit.into()) + } + SignerConfig::Turnkey(turnkey) => SignerConfigStorage::Turnkey(turnkey.into()), + SignerConfig::GoogleCloudKms(gcp) => SignerConfigStorage::GoogleCloudKms(gcp.into()), + } + } +} + +/// Convert storage config to domain config +impl From for SignerConfig { + fn from(storage: SignerConfigStorage) -> Self { + match storage { + SignerConfigStorage::Local(local) => SignerConfig::Local(local.into()), + SignerConfigStorage::AwsKms(aws) => SignerConfig::AwsKms(aws.into()), + SignerConfigStorage::Vault(vault) => SignerConfig::Vault(vault.into()), + SignerConfigStorage::VaultCloud(vault_cloud) => { + SignerConfig::VaultCloud(vault_cloud.into()) } - SignerConfig::VaultTransit(vault_transit_config) => { - SignerConfigStorage::VaultTransit(vault_transit_config) + SignerConfigStorage::VaultTransit(vault_transit) => { + SignerConfig::VaultTransit(vault_transit.into()) } + SignerConfigStorage::Turnkey(turnkey) => SignerConfig::Turnkey(turnkey.into()), + SignerConfigStorage::GoogleCloudKms(gcp) => SignerConfig::GoogleCloudKms(gcp.into()), } } } +// Individual config type conversions - these handle the mapping between domain and storage representations +use crate::models::signer::signer::{ + AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, + GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, TurnkeySignerConfig, + VaultCloudSignerConfig, VaultSignerConfig, VaultTransitSignerConfig, +}; +use crate::models::SecretString; + impl From for LocalSignerConfigStorage { fn from(config: LocalSignerConfig) -> Self { Self { - raw_key: config.raw_key, // SecretVec can be moved directly + raw_key: config.raw_key, } } } -impl SignerConfig { - pub fn get_local(&self) -> Option<&LocalSignerConfig> { - match self { - Self::Local(config) - | Self::Test(config) - | Self::Vault(config) - | Self::VaultCloud(config) => Some(config), - Self::VaultTransit(_) - | Self::AwsKms(_) - | Self::Turnkey(_) - | Self::GoogleCloudKms(_) => None, +impl From for LocalSignerConfig { + fn from(storage: LocalSignerConfigStorage) -> Self { + Self { + raw_key: storage.raw_key, } } +} - pub fn get_aws_kms(&self) -> Option<&AwsKmsSignerConfig> { - let SignerConfig::AwsKms(config) = self else { - return None; - }; - - Some(config) +impl From for AwsKmsSignerConfigStorage { + fn from(config: AwsKmsSignerConfig) -> Self { + Self { + region: config.region, + key_id: config.key_id, + } } +} - pub fn get_vault_transit(&self) -> Option<&VaultTransitSignerConfig> { - let SignerConfig::VaultTransit(config) = self else { - return None; - }; +impl From for AwsKmsSignerConfig { + fn from(storage: AwsKmsSignerConfigStorage) -> Self { + Self { + region: storage.region, + key_id: storage.key_id, + } + } +} - Some(config) +impl From for VaultSignerConfigStorage { + fn from(config: VaultSignerConfig) -> Self { + Self { + address: config.address, + namespace: config.namespace, + role_id: config.role_id.to_str().to_string(), + secret_id: config.secret_id.to_str().to_string(), + key_name: config.key_name, + mount_point: config.mount_point, + } } +} - pub fn get_turnkey(&self) -> Option<&TurnkeySignerConfig> { - let SignerConfig::Turnkey(config) = self else { - return None; - }; +impl From for VaultSignerConfig { + fn from(storage: VaultSignerConfigStorage) -> Self { + Self { + address: storage.address, + namespace: storage.namespace, + role_id: SecretString::new(&storage.role_id), + secret_id: SecretString::new(&storage.secret_id), + key_name: storage.key_name, + mount_point: storage.mount_point, + } + } +} - Some(config) +impl From for VaultCloudSignerConfigStorage { + fn from(config: VaultCloudSignerConfig) -> Self { + Self { + client_id: config.client_id, + client_secret: config.client_secret.to_str().to_string(), + org_id: config.org_id, + project_id: config.project_id, + app_name: config.app_name, + key_name: config.key_name, + } } +} - pub fn get_google_cloud_kms(&self) -> Option<&GoogleCloudKmsSignerConfig> { - let SignerConfig::GoogleCloudKms(config) = self else { - return None; - }; +impl From for VaultCloudSignerConfig { + fn from(storage: VaultCloudSignerConfigStorage) -> Self { + Self { + client_id: storage.client_id, + client_secret: SecretString::new(&storage.client_secret), + org_id: storage.org_id, + project_id: storage.project_id, + app_name: storage.app_name, + key_name: storage.key_name, + } + } +} - Some(config) +impl From for VaultTransitSignerConfigStorage { + fn from(config: VaultTransitSignerConfig) -> Self { + Self { + key_name: config.key_name, + address: config.address, + namespace: config.namespace, + role_id: config.role_id.to_str().to_string(), + secret_id: config.secret_id.to_str().to_string(), + pubkey: config.pubkey, + mount_point: config.mount_point, + } } } -#[cfg(test)] -mod tests { - use super::*; - use serde_json::{from_str, to_string}; +impl From for VaultTransitSignerConfig { + fn from(storage: VaultTransitSignerConfigStorage) -> Self { + Self { + key_name: storage.key_name, + address: storage.address, + namespace: storage.namespace, + role_id: SecretString::new(&storage.role_id), + secret_id: SecretString::new(&storage.secret_id), + pubkey: storage.pubkey, + mount_point: storage.mount_point, + } + } +} - #[test] - fn test_signer_type_serialization() { - assert_eq!(to_string(&SignerType::Test).unwrap(), "\"test\""); - assert_eq!(to_string(&SignerType::Local).unwrap(), "\"local\""); - assert_eq!(to_string(&SignerType::AwsKms).unwrap(), "\"aws_kms\""); - assert_eq!(to_string(&SignerType::Vault).unwrap(), "\"vault\""); - assert_eq!(to_string(&SignerType::Turnkey).unwrap(), "\"turnkey\""); +impl From for TurnkeySignerConfigStorage { + fn from(config: TurnkeySignerConfig) -> Self { + Self { + api_public_key: config.api_public_key, + api_private_key: config.api_private_key.to_str().to_string(), + organization_id: config.organization_id, + private_key_id: config.private_key_id, + public_key: config.public_key, + } } +} - #[test] - fn test_signer_type_deserialization() { - assert_eq!( - from_str::("\"test\"").unwrap(), - SignerType::Test - ); - assert_eq!( - from_str::("\"local\"").unwrap(), - SignerType::Local - ); - assert_eq!( - from_str::("\"aws_kms\"").unwrap(), - SignerType::AwsKms - ); - assert_eq!( - from_str::("\"vault\"").unwrap(), - SignerType::Vault - ); - assert_eq!( - from_str::("\"turnkey\"").unwrap(), - SignerType::Turnkey - ); +impl From for TurnkeySignerConfig { + fn from(storage: TurnkeySignerConfigStorage) -> Self { + Self { + api_public_key: storage.api_public_key, + api_private_key: SecretString::new(&storage.api_private_key), + organization_id: storage.organization_id, + private_key_id: storage.private_key_id, + public_key: storage.public_key, + } } +} - #[test] - fn test_signer_repo_model_creation() { - let model = SignerRepoModel { - id: "test-signer".to_string(), - config: SignerConfig::Test(LocalSignerConfig { - raw_key: SecretVec::new(4, |v| v.copy_from_slice(&[1, 2, 3, 4])), - }), - }; +impl From for GoogleCloudKmsSignerConfigStorage { + fn from(config: GoogleCloudKmsSignerConfig) -> Self { + Self { + service_account: config.service_account.into(), + key: config.key.into(), + } + } +} - assert_eq!(model.id, "test-signer"); - assert!(matches!(model.config, SignerConfig::Test(_))); +impl From for GoogleCloudKmsSignerConfig { + fn from(storage: GoogleCloudKmsSignerConfigStorage) -> Self { + Self { + service_account: storage.service_account.into(), + key: storage.key.into(), + } } +} - #[test] - fn test_local_signer_config() { - let private_key = vec![0, 1, 2, 3, 4, 5]; - let config = LocalSignerConfig { - raw_key: SecretVec::new(private_key.len(), |v| v.copy_from_slice(&private_key)), - }; +impl From + for GoogleCloudKmsSignerServiceAccountConfigStorage +{ + fn from(config: GoogleCloudKmsSignerServiceAccountConfig) -> Self { + Self { + private_key: config.private_key.to_str().to_string(), + private_key_id: config.private_key_id.to_str().to_string(), + project_id: config.project_id, + client_email: config.client_email.to_str().to_string(), + client_id: config.client_id, + auth_uri: config.auth_uri, + token_uri: config.token_uri, + auth_provider_x509_cert_url: config.auth_provider_x509_cert_url, + client_x509_cert_url: config.client_x509_cert_url, + universe_domain: config.universe_domain, + } + } +} - let test = config.raw_key.borrow(); - assert_eq!(*test, private_key); +impl From + for GoogleCloudKmsSignerServiceAccountConfig +{ + fn from(storage: GoogleCloudKmsSignerServiceAccountConfigStorage) -> Self { + Self { + private_key: SecretString::new(&storage.private_key), + private_key_id: SecretString::new(&storage.private_key_id), + project_id: storage.project_id, + client_email: SecretString::new(&storage.client_email), + client_id: storage.client_id, + auth_uri: storage.auth_uri, + token_uri: storage.token_uri, + auth_provider_x509_cert_url: storage.auth_provider_x509_cert_url, + client_x509_cert_url: storage.client_x509_cert_url, + universe_domain: storage.universe_domain, + } } +} - #[test] - fn test_vault_transit_signer_config() { - let config = VaultTransitSignerConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com".to_string(), - namespace: Some("ns1".to_string()), - role_id: SecretString::new("role-123"), - secret_id: SecretString::new("secret-456"), - pubkey: "mypubkey123".to_string(), - mount_point: Some("transit".to_string()), - }; +impl From for GoogleCloudKmsSignerKeyConfigStorage { + fn from(config: GoogleCloudKmsSignerKeyConfig) -> Self { + Self { + location: config.location, + key_ring_id: config.key_ring_id, + key_id: config.key_id, + key_version: config.key_version, + } + } +} - assert_eq!(config.key_name, "transit-key"); - assert_eq!(config.address, "https://vault.example.com"); - assert_eq!(config.namespace, Some("ns1".to_string())); - assert_eq!(config.role_id.to_str().as_str(), "role-123"); - assert_eq!(config.secret_id.to_str().as_str(), "secret-456"); - assert_eq!(config.pubkey, "mypubkey123"); - assert_eq!(config.mount_point, Some("transit".to_string())); - - let config2 = VaultTransitSignerConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com".to_string(), - namespace: None, - role_id: SecretString::new("role-123"), - secret_id: SecretString::new("secret-456"), - pubkey: "mypubkey123".to_string(), - mount_point: None, - }; +impl From for GoogleCloudKmsSignerKeyConfig { + fn from(storage: GoogleCloudKmsSignerKeyConfigStorage) -> Self { + Self { + location: storage.location, + key_ring_id: storage.key_ring_id, + key_id: storage.key_id, + key_version: storage.key_version, + } + } +} - assert_eq!(config2.namespace, None); - assert_eq!(config2.mount_point, None); +impl SignerRepoModel { + /// Validates the repository model using core validation logic + pub fn validate(&self) -> Result<(), SignerValidationError> { + let core_signer = Signer::from(self.clone()); + core_signer.validate() } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::signer::signer::{LocalSignerConfig, SignerConfig}; + use secrets::SecretVec; #[test] - fn test_turnkey_signer_config() { - let config = TurnkeySignerConfig { - api_private_key: SecretString::new("123"), - api_public_key: "api_public_key".to_string(), - organization_id: "organization_id".to_string(), - private_key_id: "private_key_id".to_string(), - public_key: "public_key".to_string(), + fn test_from_core_signer() { + let config = LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), }; - assert_eq!(config.api_public_key, "api_public_key"); - assert_eq!(config.organization_id, "organization_id"); - assert_eq!(config.api_private_key.to_str().as_str(), "123"); - assert_eq!(config.private_key_id, "private_key_id"); - assert_eq!(config.public_key, "public_key"); + let core = crate::models::signer::signer::Signer::new( + "test-id".to_string(), + SignerConfig::Local(config), + Some("Test Signer".to_string()), + Some("A test signer".to_string()), + ); + + let repo_model = SignerRepoModel::from(core); + assert_eq!(repo_model.id, "test-id"); + assert!(matches!(repo_model.config, SignerConfig::Local(_))); } #[test] - fn test_google_cloud_kms_config() { - let config = GoogleCloudKmsSignerConfig { - service_account: GoogleCloudKmsSignerServiceAccountConfig { - private_key: SecretString::new("private_key"), - private_key_id: SecretString::new("private_key_id"), - project_id: "project_id".to_string(), - client_email: SecretString::new("client_email"), - client_id: "client_id".to_string(), - auth_uri: "auth_uri".to_string(), - token_uri: "token_uri".to_string(), - auth_provider_x509_cert_url: "auth_provider_x509_cert_url".to_string(), - client_x509_cert_url: "client_x509_cert_url".to_string(), - universe_domain: "universe_domain".to_string(), - }, - key: GoogleCloudKmsSignerKeyConfig { - location: "global".to_string(), - key_ring_id: "key_ring_id".to_string(), - key_id: "key_id".to_string(), - key_version: 1, - }, + fn test_to_core_signer() { + use crate::models::signer::signer::AwsKmsSignerConfig; + + let domain_config = AwsKmsSignerConfig { + region: Some("us-east-1".to_string()), + key_id: "test-key".to_string(), }; - assert_eq!(config.service_account.project_id, "project_id"); - assert_eq!(config.key.key_ring_id, "key_ring_id"); - assert_eq!(config.key.key_id, "key_id"); - assert_eq!(config.key.key_version, 1); - assert_eq!( - config.service_account.private_key.to_str().as_str(), - "private_key" - ); - assert_eq!( - config.service_account.private_key_id.to_str().as_str(), - "private_key_id" - ); - assert_eq!( - config.service_account.client_email.to_str().as_str(), - "client_email" - ); - assert_eq!(config.service_account.client_id, "client_id"); - assert_eq!(config.service_account.auth_uri, "auth_uri"); - assert_eq!(config.service_account.token_uri, "token_uri"); - assert_eq!( - config.service_account.auth_provider_x509_cert_url, - "auth_provider_x509_cert_url" - ); + let repo_model = SignerRepoModel { + id: "test-id".to_string(), + config: SignerConfig::AwsKms(domain_config), + }; + + let core = Signer::from(repo_model); + assert_eq!(core.id, "test-id"); assert_eq!( - config.service_account.client_x509_cert_url, - "client_x509_cert_url" + core.signer_type(), + crate::models::signer::signer::SignerType::AwsKms ); - assert_eq!(config.service_account.universe_domain, "universe_domain"); + // Note: metadata (name, description) is None when coming from repository + assert_eq!(core.name, None); + assert_eq!(core.description, None); } #[test] - fn test_signer_config_variants() { - let test_config = SignerConfig::Test(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[1, 2, 3])), - }); - - let local_config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[4, 5, 6])), - }); - - let vault_config = SignerConfig::Vault(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[7, 8, 9])), - }); - - let vault_cloud_config = SignerConfig::VaultCloud(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[10, 11, 12])), - }); - - let vault_transit_config = SignerConfig::VaultTransit(VaultTransitSignerConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com".to_string(), - namespace: None, - role_id: SecretString::new("role-123"), - secret_id: SecretString::new("secret-456"), - pubkey: "mypubkey123".to_string(), - mount_point: None, - }); - - let aws_kms_config = SignerConfig::AwsKms(AwsKmsSignerConfig { - region: Some("us-east-1".to_string()), - key_id: "test-key-id".to_string(), - }); - - let turnkey_config = SignerConfig::Turnkey(TurnkeySignerConfig { - api_private_key: SecretString::new("123"), - api_public_key: "api_public_key".to_string(), - organization_id: "organization_id".to_string(), - private_key_id: "private_key_id".to_string(), - public_key: "public_key".to_string(), - }); - - let google_cloud_kms_config = SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { - service_account: GoogleCloudKmsSignerServiceAccountConfig { - private_key: SecretString::new("private_key"), - private_key_id: SecretString::new("private_key_id"), - project_id: "project_id".to_string(), - client_email: SecretString::new("client_email"), - client_id: "client_id".to_string(), - auth_uri: "auth_uri".to_string(), - token_uri: "token_uri".to_string(), - auth_provider_x509_cert_url: "auth_provider_x509_cert_url".to_string(), - client_x509_cert_url: "client_x509_cert_url".to_string(), - universe_domain: "universe_domain".to_string(), - }, - key: GoogleCloudKmsSignerKeyConfig { - location: "global".to_string(), - key_ring_id: "key_ring_id".to_string(), - key_id: "key_id".to_string(), - key_version: 1, - }, - }); - - assert!(matches!(test_config, SignerConfig::Test(_))); - assert!(matches!(local_config, SignerConfig::Local(_))); - assert!(matches!(vault_config, SignerConfig::Vault(_))); - assert!(matches!(vault_cloud_config, SignerConfig::VaultCloud(_))); - assert!(matches!( - vault_transit_config, - SignerConfig::VaultTransit(_) - )); - assert!(matches!(aws_kms_config, SignerConfig::AwsKms(_))); - assert!(matches!(turnkey_config, SignerConfig::Turnkey(_))); - assert!(matches!( - google_cloud_kms_config, - SignerConfig::GoogleCloudKms(_) - )); - } + fn test_validation() { + let domain_config = LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), + }; - #[test] - fn test_signer_config_get_local() { - let local_config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[1, 2, 3])), - }); - let retrieved = local_config.get_local().unwrap(); - assert_eq!(*retrieved.raw_key.borrow(), vec![1, 2, 3]); - - let test_config = SignerConfig::Test(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[4, 5, 6])), - }); - let retrieved = test_config.get_local().unwrap(); - assert_eq!(*retrieved.raw_key.borrow(), vec![4, 5, 6]); - - let vault_config = SignerConfig::Vault(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[7, 8, 9])), - }); - let retrieved = vault_config.get_local().unwrap(); - assert_eq!(*retrieved.raw_key.borrow(), vec![7, 8, 9]); - - let vault_cloud_config = SignerConfig::VaultCloud(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[10, 11, 12])), - }); - let retrieved = vault_cloud_config.get_local().unwrap(); - assert_eq!(*retrieved.raw_key.borrow(), vec![10, 11, 12]); - - let vault_transit_config = SignerConfig::VaultTransit(VaultTransitSignerConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com".to_string(), - namespace: None, - role_id: SecretString::new("role-123"), - secret_id: SecretString::new("secret-456"), - pubkey: "mypubkey123".to_string(), - mount_point: None, - }); - assert!(vault_transit_config.get_local().is_none()); - - let google_cloud_kms_config = SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { - service_account: GoogleCloudKmsSignerServiceAccountConfig { - private_key: SecretString::new("private_key"), - private_key_id: SecretString::new("private_key_id"), - project_id: "project_id".to_string(), - client_email: SecretString::new("client_email"), - client_id: "client_id".to_string(), - auth_uri: "auth_uri".to_string(), - token_uri: "token_uri".to_string(), - auth_provider_x509_cert_url: "auth_provider_x509_cert_url".to_string(), - client_x509_cert_url: "client_x509_cert_url".to_string(), - universe_domain: "universe_domain".to_string(), - }, - key: GoogleCloudKmsSignerKeyConfig { - location: "global".to_string(), - key_ring_id: "key_ring_id".to_string(), - key_id: "key_id".to_string(), - key_version: 1, - }, - }); - assert!(google_cloud_kms_config.get_local().is_none()); - - let aws_kms_config = SignerConfig::AwsKms(AwsKmsSignerConfig { - region: Some("us-east-1".to_string()), - key_id: "test-key-id".to_string(), - }); - assert!(aws_kms_config.get_local().is_none()); - - let turnkey_config = SignerConfig::Turnkey(TurnkeySignerConfig { - api_private_key: SecretString::new("123"), - api_public_key: "api_public_key".to_string(), - organization_id: "organization_id".to_string(), - private_key_id: "private_key_id".to_string(), - public_key: "public_key".to_string(), - }); - assert!(turnkey_config.get_local().is_none()); - } + let repo_model = SignerRepoModel { + id: "test-id".to_string(), + config: SignerConfig::Local(domain_config), + }; - #[test] - fn test_signer_config_get_aws_kms() { - let aws_kms_config = SignerConfig::AwsKms(AwsKmsSignerConfig { - region: Some("us-east-1".to_string()), - key_id: "test-key-id".to_string(), - }); - assert!(aws_kms_config.get_aws_kms().is_some()); - - // Test with configs that should return None - let local_config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[1, 2, 3])), - }); - assert!(local_config.get_aws_kms().is_none()); - - let test_config = SignerConfig::Test(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[4, 5, 6])), - }); - assert!(test_config.get_aws_kms().is_none()); + assert!(repo_model.validate().is_ok()); } #[test] - fn test_signer_config_get_vault_transit() { - let vault_transit_config = SignerConfig::VaultTransit(VaultTransitSignerConfig { - key_name: "transit-key".to_string(), - address: "https://vault.example.com".to_string(), - namespace: None, - role_id: SecretString::new("role-123"), - secret_id: SecretString::new("secret-456"), - pubkey: "mypubkey123".to_string(), - mount_point: None, - }); - let retrieved = vault_transit_config.get_vault_transit().unwrap(); - assert_eq!(retrieved.key_name, "transit-key"); - assert_eq!(retrieved.address, "https://vault.example.com"); - - let local_config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[1, 2, 3])), - }); - assert!(local_config.get_vault_transit().is_none()); - - let vault_config = SignerConfig::Vault(LocalSignerConfig { - raw_key: SecretVec::new(3, |v| v.copy_from_slice(&[7, 8, 9])), - }); - assert!(vault_config.get_vault_transit().is_none()); - } + fn test_local_config_storage_conversion() { + let domain_config = LocalSignerConfig { + raw_key: SecretVec::new(4, |v| v.copy_from_slice(&[1, 2, 3, 4])), + }; - #[test] - fn test_signer_config_get_turnkey() { - let turnkey_config = SignerConfig::Turnkey(TurnkeySignerConfig { - api_private_key: SecretString::new("123"), - api_public_key: "api_public_key".to_string(), - organization_id: "organization_id".to_string(), - private_key_id: "private_key_id".to_string(), - public_key: "public_key".to_string(), - }); - - let retrieved = turnkey_config.get_turnkey().unwrap(); - - assert_eq!(retrieved.api_public_key, "api_public_key"); - assert_eq!(retrieved.organization_id, "organization_id"); - assert_eq!(retrieved.api_private_key.to_str().as_str(), "123"); - assert_eq!(retrieved.private_key_id, "private_key_id"); - assert_eq!(retrieved.public_key, "public_key"); - assert!(turnkey_config.get_aws_kms().is_none()); - assert!(turnkey_config.get_local().is_none()); - assert!(turnkey_config.get_vault_transit().is_none()); - } + let storage_config = LocalSignerConfigStorage::from(domain_config.clone()); + let converted_back = LocalSignerConfig::from(storage_config); - #[test] - fn test_signer_config_get_google_cloud_kms() { - let google_config = SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { - service_account: GoogleCloudKmsSignerServiceAccountConfig { - private_key: SecretString::new("private_key"), - private_key_id: SecretString::new("private_key_id"), - project_id: "project_id".to_string(), - client_email: SecretString::new("client_email"), - client_id: "client_id".to_string(), - auth_uri: "auth_uri".to_string(), - token_uri: "token_uri".to_string(), - auth_provider_x509_cert_url: "auth_provider_x509_cert_url".to_string(), - client_x509_cert_url: "client_x509_cert_url".to_string(), - universe_domain: "universe_domain".to_string(), - }, - key: GoogleCloudKmsSignerKeyConfig { - location: "global".to_string(), - key_ring_id: "key_ring_id".to_string(), - key_id: "key_id".to_string(), - key_version: 1, - }, - }); - let retrieved = google_config.get_google_cloud_kms().unwrap(); - assert_eq!(retrieved.service_account.project_id, "project_id"); - assert!(google_config.get_aws_kms().is_none()); - assert!(google_config.get_local().is_none()); - assert!(google_config.get_vault_transit().is_none()); + // Compare the actual secret data + let original_data = domain_config.raw_key.borrow(); + let converted_data = converted_back.raw_key.borrow(); + assert_eq!(*original_data, *converted_data); } } diff --git a/src/models/signer/request.rs b/src/models/signer/request.rs new file mode 100644 index 000000000..5b384ebd6 --- /dev/null +++ b/src/models/signer/request.rs @@ -0,0 +1,545 @@ +//! API request models and validation for signer endpoints. +//! +//! This module handles incoming HTTP requests for signer operations, providing: +//! +//! - **Request Models**: Structures for creating and updating signers via API +//! - **Input Validation**: Sanitization and validation of user-provided data +//! - **Domain Conversion**: Transformation from API requests to domain objects +//! +//! Serves as the entry point for signer data from external clients, ensuring +//! all input is properly validated before reaching the core business logic. + +use crate::models::{ + ApiError, AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, + GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, SecretString, Signer, + SignerConfig, TurnkeySignerConfig, VaultCloudSignerConfig, VaultSignerConfig, + VaultTransitSignerConfig, +}; +use secrets::SecretVec; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use zeroize::Zeroize; + + +/// AWS KMS signer configuration for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +pub struct PlainSignerRequestConfig { + pub key: String, +} + +/// AWS KMS signer configuration for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +pub struct AwsKmsSignerRequestConfig { + pub region: String, + pub key_id: String, +} + +/// Vault signer configuration for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +pub struct VaultSignerRequestConfig { + pub address: String, + pub namespace: Option, + pub role_id: String, + pub secret_id: String, + pub key_name: String, + pub mount_point: Option, +} + +/// Vault Cloud signer configuration for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +pub struct VaultCloudSignerRequestConfig { + pub client_id: String, + pub client_secret: String, + pub org_id: String, + pub project_id: String, + pub app_name: String, + pub key_name: String, +} + +/// Vault Transit signer configuration for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +pub struct VaultTransitSignerRequestConfig { + pub key_name: String, + pub address: String, + pub namespace: Option, + pub role_id: String, + pub secret_id: String, + pub pubkey: String, + pub mount_point: Option, +} + +/// Turnkey signer configuration for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +pub struct TurnkeySignerRequestConfig { + pub api_public_key: String, + pub api_private_key: String, + pub organization_id: String, + pub private_key_id: String, + pub public_key: String, +} + +/// Google Cloud KMS service account configuration for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +pub struct GoogleCloudKmsSignerServiceAccountRequestConfig { + pub private_key: String, + pub private_key_id: String, + pub project_id: String, + pub client_email: String, + pub client_id: String, + pub auth_uri: String, + pub token_uri: String, + pub auth_provider_x509_cert_url: String, + pub client_x509_cert_url: String, + pub universe_domain: String, +} + +/// Google Cloud KMS key configuration for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +pub struct GoogleCloudKmsSignerKeyRequestConfig { + pub location: String, + pub key_ring_id: String, + pub key_id: String, + pub key_version: u32, +} + +/// Google Cloud KMS signer configuration for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +pub struct GoogleCloudKmsSignerRequestConfig { + pub service_account: GoogleCloudKmsSignerServiceAccountRequestConfig, + pub key: GoogleCloudKmsSignerKeyRequestConfig, +} + +/// Signer configuration enum for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum SignerConfigRequest { + #[serde(rename = "plain")] + Local { + config: PlainSignerRequestConfig, + }, + #[serde(rename = "aws_kms")] + AwsKms { + config: AwsKmsSignerRequestConfig, + }, + Vault { + config: VaultSignerRequestConfig, + }, + #[serde(rename = "vault_cloud")] + VaultCloud { + config: VaultCloudSignerRequestConfig, + }, + #[serde(rename = "vault_transit")] + VaultTransit { + config: VaultTransitSignerRequestConfig, + }, + Turnkey { + config: TurnkeySignerRequestConfig, + }, + #[serde(rename = "google_cloud_kms")] + GoogleCloudKms { + config: GoogleCloudKmsSignerRequestConfig, + }, +} + +/// Request model for creating a new signer +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +pub struct SignerCreateRequest { + /// Optional ID - if not provided, a UUID will be generated + pub id: Option, + /// The signer configuration including type and config data + #[serde(flatten)] + pub config: SignerConfigRequest, + /// Optional human-readable name for the signer + pub name: Option, + /// Optional description of the signer's purpose + pub description: Option, +} + +/// Request model for updating an existing signer +#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +pub struct SignerUpdateRequest { + /// Optional updated name for the signer + pub name: Option, + /// Optional updated description for the signer + pub description: Option, + // Note: signer_type and config are immutable after creation for security +} + +impl From for AwsKmsSignerConfig { + fn from(config: AwsKmsSignerRequestConfig) -> Self { + Self { + region: Some(config.region), + key_id: config.key_id, + } + } +} + +impl From for VaultSignerConfig { + fn from(config: VaultSignerRequestConfig) -> Self { + Self { + address: config.address, + namespace: config.namespace, + role_id: SecretString::new(&config.role_id), + secret_id: SecretString::new(&config.secret_id), + key_name: config.key_name, + mount_point: config.mount_point, + } + } +} + +impl From for VaultCloudSignerConfig { + fn from(config: VaultCloudSignerRequestConfig) -> Self { + Self { + client_id: config.client_id, + client_secret: SecretString::new(&config.client_secret), + org_id: config.org_id, + project_id: config.project_id, + app_name: config.app_name, + key_name: config.key_name, + } + } +} + +impl From for VaultTransitSignerConfig { + fn from(config: VaultTransitSignerRequestConfig) -> Self { + Self { + key_name: config.key_name, + address: config.address, + namespace: config.namespace, + role_id: SecretString::new(&config.role_id), + secret_id: SecretString::new(&config.secret_id), + pubkey: config.pubkey, + mount_point: config.mount_point, + } + } +} + +impl From for TurnkeySignerConfig { + fn from(config: TurnkeySignerRequestConfig) -> Self { + Self { + api_public_key: config.api_public_key, + api_private_key: SecretString::new(&config.api_private_key), + organization_id: config.organization_id, + private_key_id: config.private_key_id, + public_key: config.public_key, + } + } +} + +impl From + for GoogleCloudKmsSignerServiceAccountConfig +{ + fn from(config: GoogleCloudKmsSignerServiceAccountRequestConfig) -> Self { + Self { + private_key: SecretString::new(&config.private_key), + private_key_id: SecretString::new(&config.private_key_id), + project_id: config.project_id, + client_email: SecretString::new(&config.client_email), + client_id: config.client_id, + auth_uri: config.auth_uri, + token_uri: config.token_uri, + auth_provider_x509_cert_url: config.auth_provider_x509_cert_url, + client_x509_cert_url: config.client_x509_cert_url, + universe_domain: config.universe_domain, + } + } +} + +impl From for GoogleCloudKmsSignerKeyConfig { + fn from(config: GoogleCloudKmsSignerKeyRequestConfig) -> Self { + Self { + location: config.location, + key_ring_id: config.key_ring_id, + key_id: config.key_id, + key_version: config.key_version, + } + } +} + +impl From for GoogleCloudKmsSignerConfig { + fn from(config: GoogleCloudKmsSignerRequestConfig) -> Self { + Self { + service_account: config.service_account.into(), + key: config.key.into(), + } + } +} + +impl TryFrom for SignerConfig { + type Error = ApiError; + + fn try_from(config: SignerConfigRequest) -> Result { + let domain_config = match config { + SignerConfigRequest::Local { config } => { + // Decode hex string to raw bytes for cryptographic key + let key_bytes = hex::decode(&config.key) + .map_err(|e| ApiError::BadRequest(format!( + "Invalid hex key format: {}. Key must be a 64-character hex string (32 bytes).", e + )))?; + + let raw_key = SecretVec::new(key_bytes.len(), |buffer| { + buffer.copy_from_slice(&key_bytes); + }); + + SignerConfig::Local(LocalSignerConfig { + raw_key, + }) + } + SignerConfigRequest::AwsKms { config } => SignerConfig::AwsKms(config.into()), + SignerConfigRequest::Vault { config } => SignerConfig::Vault(config.into()), + SignerConfigRequest::VaultCloud { config } => SignerConfig::VaultCloud(config.into()), + SignerConfigRequest::VaultTransit { config } => { + SignerConfig::VaultTransit(config.into()) + } + SignerConfigRequest::Turnkey { config } => SignerConfig::Turnkey(config.into()), + SignerConfigRequest::GoogleCloudKms { config } => { + SignerConfig::GoogleCloudKms(config.into()) + } + }; + + // Validate the configuration using domain model validation + domain_config.validate().map_err(|e| ApiError::from(e))?; + + Ok(domain_config) + } +} + +impl TryFrom for Signer { + type Error = ApiError; + + fn try_from(request: SignerCreateRequest) -> Result { + // Generate UUID if no ID provided + let id = request + .id + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + // Convert request config to domain config (with validation) + let config = SignerConfig::try_from(request.config)?; + + // Create the signer + let signer = Signer::new(id, config, request.name, request.description); + + // Validate using domain model validation (this will also validate the config) + signer.validate().map_err(ApiError::from)?; + + Ok(signer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::signer::signer::SignerType; + + #[test] + fn test_valid_aws_kms_create_request() { + let request = SignerCreateRequest { + id: Some("test-aws-signer".to_string()), + config: SignerConfigRequest::AwsKms { + config: AwsKmsSignerRequestConfig { + region: "us-east-1".to_string(), + key_id: "test-key-id".to_string(), + }, + }, + name: Some("Test AWS KMS Signer".to_string()), + description: Some("A test AWS KMS signer".to_string()), + }; + + let result = Signer::try_from(request); + assert!(result.is_ok()); + + let signer = result.unwrap(); + assert_eq!(signer.id, "test-aws-signer"); + assert_eq!(signer.signer_type(), SignerType::AwsKms); + assert_eq!(signer.name, Some("Test AWS KMS Signer".to_string())); + + // Verify the config was properly converted + if let Some(aws_config) = signer.config.get_aws_kms() { + assert_eq!(aws_config.region, Some("us-east-1".to_string())); + assert_eq!(aws_config.key_id, "test-key-id"); + } else { + panic!("Expected AWS KMS config"); + } + } + + #[test] + fn test_valid_vault_create_request() { + let request = SignerCreateRequest { + id: Some("test-vault-signer".to_string()), + config: SignerConfigRequest::Vault { + config: VaultSignerRequestConfig { + address: "https://vault.example.com".to_string(), + namespace: None, + role_id: "test-role-id".to_string(), + secret_id: "test-secret-id".to_string(), + key_name: "test-key".to_string(), + mount_point: None, + }, + }, + name: Some("Test Vault Signer".to_string()), + description: None, + }; + + let result = Signer::try_from(request); + assert!(result.is_ok()); + + let signer = result.unwrap(); + assert_eq!(signer.id, "test-vault-signer"); + assert_eq!(signer.signer_type(), SignerType::Vault); + } + + #[test] + fn test_invalid_aws_kms_empty_key_id() { + let request = SignerCreateRequest { + id: Some("test-signer".to_string()), + config: SignerConfigRequest::AwsKms { + config: AwsKmsSignerRequestConfig { + region: "us-east-1".to_string(), + key_id: "".to_string(), // Empty key ID should fail validation + }, + }, + name: None, + description: None, + }; + + let result = Signer::try_from(request); + assert!(result.is_err()); + + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Key ID cannot be empty")); + } else { + panic!("Expected BadRequest error for empty key ID"); + } + } + + #[test] + fn test_invalid_vault_empty_address() { + let request = SignerCreateRequest { + id: Some("test-signer".to_string()), + config: SignerConfigRequest::Vault { + config: VaultSignerRequestConfig { + address: "".to_string(), // Empty address should fail validation + namespace: None, + role_id: "test-role".to_string(), + secret_id: "test-secret".to_string(), + key_name: "test-key".to_string(), + mount_point: None, + }, + }, + name: None, + description: None, + }; + + let result = Signer::try_from(request); + assert!(result.is_err()); + } + + #[test] + fn test_invalid_vault_invalid_url() { + let request = SignerCreateRequest { + id: Some("test-signer".to_string()), + config: SignerConfigRequest::Vault { + config: VaultSignerRequestConfig { + address: "not-a-url".to_string(), // Invalid URL should fail validation + namespace: None, + role_id: "test-role".to_string(), + secret_id: "test-secret".to_string(), + key_name: "test-key".to_string(), + mount_point: None, + }, + }, + name: None, + description: None, + }; + + let result = Signer::try_from(request); + assert!(result.is_err()); + + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Address must be a valid URL")); + } else { + panic!("Expected BadRequest error for invalid URL"); + } + } + + #[test] + fn test_create_request_generates_uuid_when_no_id() { + let request = SignerCreateRequest { + id: None, + config: SignerConfigRequest::Local { config: PlainSignerRequestConfig { + key: "1111111111111111111111111111111111111111111111111111111111111111".to_string() // 32 bytes as hex + } }, + name: Some("Test Signer".to_string()), + description: None, + }; + + let result = Signer::try_from(request); + assert!(result.is_ok()); + + let signer = result.unwrap(); + assert!(!signer.id.is_empty()); + assert_eq!(signer.signer_type(), SignerType::Local); + + // Verify it's a valid UUID format + assert!(uuid::Uuid::parse_str(&signer.id).is_ok()); + } + + #[test] + fn test_invalid_id_format() { + let request = SignerCreateRequest { + id: Some("invalid@id".to_string()), // Invalid characters + config: SignerConfigRequest::Local { config: PlainSignerRequestConfig { + key: "2222222222222222222222222222222222222222222222222222222222222222".to_string() // 32 bytes as hex + } }, + name: None, + description: None, + }; + + let result = Signer::try_from(request); + assert!(result.is_err()); + + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("ID must contain only letters, numbers, dashes and underscores")); + } else { + panic!("Expected BadRequest error with validation message"); + } + } + + #[test] + fn test_test_signer_creation() { + let request = SignerCreateRequest { + id: Some("test-signer".to_string()), + config: SignerConfigRequest::Local { config: PlainSignerRequestConfig { + key: "3333333333333333333333333333333333333333333333333333333333333333".to_string() // 32 bytes as hex + } }, + name: None, + description: None, + }; + + let result = Signer::try_from(request); + assert!(result.is_ok()); + + let signer = result.unwrap(); + assert_eq!(signer.id, "test-signer"); + assert_eq!(signer.signer_type(), SignerType::Local); + } + + #[test] + fn test_local_signer_creation() { + let request = SignerCreateRequest { + id: Some("local-signer".to_string()), + config: SignerConfigRequest::Local { config: PlainSignerRequestConfig { + key: "4444444444444444444444444444444444444444444444444444444444444444".to_string() // 32 bytes as hex + }}, + name: Some("Local Test Signer".to_string()), + description: None, + }; + + let result = Signer::try_from(request); + assert!(result.is_ok()); + + let signer = result.unwrap(); + assert_eq!(signer.id, "local-signer"); + assert_eq!(signer.signer_type(), SignerType::Local); + } +} diff --git a/src/models/signer/response.rs b/src/models/signer/response.rs new file mode 100644 index 000000000..39ec2e750 --- /dev/null +++ b/src/models/signer/response.rs @@ -0,0 +1,357 @@ +//! API response models for signer endpoints. +//! +//! This module handles outgoing HTTP responses for signer operations, providing: +//! +//! - **Response Models**: Structures for returning signer data via API +//! - **Data Sanitization**: Ensures sensitive information is not exposed +//! - **Domain Conversion**: Transformation from domain/repository objects to API responses +//! +//! Serves as the exit point for signer data to external clients, ensuring +//! proper data formatting and security considerations. + +use crate::models::{Signer, SignerConfig, SignerRepoModel, SignerType}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Signer configuration response +/// Does not include sensitive information like private keys +#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(untagged)] +#[serde(rename_all = "lowercase")] +pub enum SignerConfigResponse { + #[serde(rename = "plain")] + Plain { + has_key: bool, + }, + Vault { + address: String, + namespace: Option, + key_name: String, + mount_point: Option, + has_role_id: bool, + has_secret_id: bool, + }, + #[serde(rename = "vault_cloud")] + VaultCloud { + client_id: String, + org_id: String, + project_id: String, + app_name: String, + key_name: String, + has_client_secret: bool, + }, + #[serde(rename = "vault_transit")] + VaultTransit { + key_name: String, + address: String, + namespace: Option, + pubkey: String, + mount_point: Option, + has_role_id: bool, + has_secret_id: bool, + }, + #[serde(rename = "aws_kms")] + AwsKms { + region: Option, + key_id: String, + }, + Turnkey { + api_public_key: String, + organization_id: String, + private_key_id: String, + public_key: String, + has_api_private_key: bool, + }, + #[serde(rename = "google_cloud_kms")] + GoogleCloudKms { + service_account: GoogleCloudKmsSignerServiceAccountResponseConfig, + key: GoogleCloudKmsSignerKeyResponseConfig, + }, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +pub struct GoogleCloudKmsSignerServiceAccountResponseConfig { + pub project_id: String, + pub client_id: String, + pub auth_uri: String, + pub token_uri: String, + pub auth_provider_x509_cert_url: String, + pub client_x509_cert_url: String, + pub universe_domain: String, + pub has_private_key: bool, + pub has_private_key_id: bool, + pub has_client_email: bool, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +pub struct GoogleCloudKmsSignerKeyResponseConfig { + pub location: String, + pub key_ring_id: String, + pub key_id: String, + pub key_version: u32, +} + +impl From for SignerConfigResponse { + fn from(config: SignerConfig) -> Self { + match config { + SignerConfig::Local(c) => SignerConfigResponse::Plain { + has_key: !c.raw_key.is_empty(), + }, + SignerConfig::Vault(c) => SignerConfigResponse::Vault { + address: c.address, + namespace: c.namespace, + key_name: c.key_name, + mount_point: c.mount_point, + has_role_id: !c.role_id.is_empty(), + has_secret_id: !c.secret_id.is_empty(), + }, + SignerConfig::VaultCloud(c) => { + SignerConfigResponse::VaultCloud { + client_id: c.client_id, + org_id: c.org_id, + project_id: c.project_id, + app_name: c.app_name, + key_name: c.key_name, + has_client_secret: !c.client_secret.is_empty(), + } + } + SignerConfig::VaultTransit(c) => { + SignerConfigResponse::VaultTransit { + key_name: c.key_name, + address: c.address, + namespace: c.namespace, + pubkey: c.pubkey, + mount_point: c.mount_point, + has_role_id: !c.role_id.is_empty(), + has_secret_id: !c.secret_id.is_empty(), + } + } + SignerConfig::AwsKms(c) => { + SignerConfigResponse::AwsKms { + region: c.region, + key_id: c.key_id, + } + } + SignerConfig::Turnkey(c) => { + SignerConfigResponse::Turnkey { + api_public_key: c.api_public_key, + organization_id: c.organization_id, + private_key_id: c.private_key_id, + public_key: c.public_key, + has_api_private_key: !c.api_private_key.is_empty(), + } + } + SignerConfig::GoogleCloudKms(c) => { + SignerConfigResponse::GoogleCloudKms { + service_account: GoogleCloudKmsSignerServiceAccountResponseConfig { + project_id: c.service_account.project_id, + client_id: c.service_account.client_id, + auth_uri: c.service_account.auth_uri, + token_uri: c.service_account.token_uri, + auth_provider_x509_cert_url: c.service_account.auth_provider_x509_cert_url, + client_x509_cert_url: c.service_account.client_x509_cert_url, + universe_domain: c.service_account.universe_domain, + has_private_key: !c.service_account.private_key.is_empty(), + has_private_key_id: !c.service_account.private_key_id.is_empty(), + has_client_email: !c.service_account.client_email.is_empty(), + }, + key: GoogleCloudKmsSignerKeyResponseConfig { + location: c.key.location, + key_ring_id: c.key.key_ring_id, + key_id: c.key.key_id, + key_version: c.key.key_version, + }, + } + } + } + } +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct SignerResponse { + /// The unique identifier of the signer + pub id: String, + /// The type of signer (local, aws_kms, google_cloud_kms, vault, etc.) + pub r#type: SignerType, + /// Optional human-readable name for the signer + pub name: Option, + /// Optional description of the signer's purpose + pub description: Option, + /// Non-secret configuration details + pub config: SignerConfigResponse, +} + +impl From for SignerResponse { + fn from(repo_model: SignerRepoModel) -> Self { + // Convert to domain model + let domain_signer = Signer::from(repo_model); + + Self { + id: domain_signer.id.clone(), + r#type: domain_signer.signer_type(), + name: domain_signer.name, + description: domain_signer.description, + config: SignerConfigResponse::from(domain_signer.config), + } + } +} + +impl From for SignerResponse { + fn from(signer: Signer) -> Self { + Self { + id: signer.id.clone(), + r#type: signer.signer_type(), + name: signer.name, + description: signer.description, + config: SignerConfigResponse::from(signer.config), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{LocalSignerConfig, SignerConfig}; + use secrets::SecretVec; + + #[test] + fn test_signer_response_from_repo_model() { + let repo_model = SignerRepoModel { + id: "test-signer".to_string(), + config: SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), + }), + }; + + let response = SignerResponse::from(repo_model); + + assert_eq!(response.id, "test-signer"); + assert_eq!(response.r#type, SignerType::Local); + assert_eq!(response.name, None); + assert_eq!(response.description, None); + assert_eq!(response.config, SignerConfigResponse::Plain { + has_key: true, + }); + } + + #[test] + fn test_signer_response_from_domain_model() { + use crate::models::signer::signer::{AwsKmsSignerConfig, SignerConfig}; + + let aws_config = AwsKmsSignerConfig { + key_id: "test-key-id".to_string(), + region: Some("us-east-1".to_string()), + }; + + let signer = crate::models::Signer::new( + "domain-signer".to_string(), + SignerConfig::AwsKms(aws_config), + Some("AWS KMS Signer".to_string()), + Some("Production AWS KMS signer".to_string()), + ); + + let response = SignerResponse::from(signer); + + assert_eq!(response.id, "domain-signer"); + assert_eq!(response.r#type, SignerType::AwsKms); + assert_eq!(response.name, Some("AWS KMS Signer".to_string())); + assert_eq!( + response.description, + Some("Production AWS KMS signer".to_string()) + ); + assert_eq!( + response.config, + SignerConfigResponse::AwsKms { + region: Some("us-east-1".to_string()), + key_id: "test-key-id".to_string(), + } + ); + } + + #[test] + fn test_signer_type_mapping_from_config() { + let test_cases = vec![ + ( + SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), + }), + SignerType::Local, + SignerConfigResponse::Plain { + has_key: true, + }, + ), + ( + SignerConfig::AwsKms(crate::models::AwsKmsSignerConfig { + region: Some("us-east-1".to_string()), + key_id: "test-key".to_string(), + }), + SignerType::AwsKms, + SignerConfigResponse::AwsKms { + region: Some("us-east-1".to_string()), + key_id: "test-key".to_string(), + }, + ), + ]; + + for (config, expected_type, expected_config) in test_cases { + let repo_model = SignerRepoModel { + id: "test".to_string(), + config, + }; + + let response = SignerResponse::from(repo_model); + assert_eq!( + response.r#type, expected_type, + "Type mapping failed for {:?}", + expected_type + ); + assert_eq!(response.config, expected_config); + } + } + + #[test] + fn test_response_serialization() { + let response = SignerResponse { + id: "test-signer".to_string(), + r#type: SignerType::Local, + name: Some("Test Signer".to_string()), + description: Some("A test signer".to_string()), + config: SignerConfigResponse::Plain { + has_key: true, + }, + }; + + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"id\":\"test-signer\"")); + assert!(json.contains("\"type\":\"local\"")); + assert!(json.contains("\"name\":\"Test Signer\"")); + assert!(json.contains("\"has_key\":true")); // Updated to match actual format + } + + #[test] + fn test_response_deserialization() { + let json = r#"{ + "id": "test-signer", + "type": "aws_kms", + "name": "AWS KMS Signer", + "description": "Production signer", + "config": { + "region": "us-east-1", + "key_id": "test-key-id" + } + }"#; + + let response: SignerResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.id, "test-signer"); + assert_eq!(response.r#type, SignerType::AwsKms); + assert_eq!(response.name, Some("AWS KMS Signer".to_string())); + assert_eq!(response.description, Some("Production signer".to_string())); + assert_eq!( + response.config, + SignerConfigResponse::AwsKms { + region: Some("us-east-1".to_string()), + key_id: "test-key-id".to_string(), + } + ); + } +} diff --git a/src/models/signer/signer.rs b/src/models/signer/signer.rs new file mode 100644 index 000000000..1980cf164 --- /dev/null +++ b/src/models/signer/signer.rs @@ -0,0 +1,779 @@ +//! Core signer domain model and business logic. +//! +//! This module provides the central `Signer` type that represents signers +//! throughout the relayer system, including: +//! +//! - **Domain Model**: Core `Signer` struct with validation and configuration +//! - **Business Logic**: Update operations and validation rules +//! - **Error Handling**: Comprehensive validation error types +//! - **Interoperability**: Conversions between API, config, and repository representations +//! +//! The signer model supports multiple signer types including local keys, AWS KMS, +//! Google Cloud KMS, Vault, and Turnkey service integrations. + +use crate::{ + constants::ID_REGEX, + models::{SecretString, SignerUpdateRequest}, +}; +use secrets::SecretVec; +use serde::{Deserialize, Serialize, Serializer}; +use utoipa::ToSchema; +use validator::Validate; + +/// Helper function to serialize secrets as redacted +fn serialize_secret_redacted(_secret: &SecretVec, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str("[REDACTED]") +} + +/// Local signer configuration for storing private keys +#[derive(Debug, Clone, Serialize)] +pub struct LocalSignerConfig { + #[serde(serialize_with = "serialize_secret_redacted")] + pub raw_key: SecretVec, +} + +impl LocalSignerConfig { + /// Validates the raw key for cryptographic requirements + pub fn validate(&self) -> Result<(), SignerValidationError> { + let key_bytes = self.raw_key.borrow(); + + // Check key length - must be exactly 32 bytes for crypto operations + if key_bytes.len() != 32 { + return Err(SignerValidationError::InvalidConfig(format!( + "Raw key must be exactly 32 bytes, got {} bytes", + key_bytes.len() + ))); + } + + // Check if key is all zeros (cryptographically invalid) + if key_bytes.iter().all(|&b| b == 0) { + return Err(SignerValidationError::InvalidConfig( + "Raw key cannot be all zeros".to_string() + )); + } + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for LocalSignerConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct LocalSignerConfigHelper { + raw_key: String, + } + + let helper = LocalSignerConfigHelper::deserialize(deserializer)?; + let raw_key = if helper.raw_key == "[REDACTED]" { + // Return a zero-filled SecretVec when deserializing redacted data + SecretVec::zero(32) + } else { + // For actual data, assume it's the raw bytes represented as a string + // In practice, this would come from proper key loading + SecretVec::new(helper.raw_key.len(), |v| { + v.copy_from_slice(helper.raw_key.as_bytes()) + }) + }; + + Ok(LocalSignerConfig { raw_key }) + } +} + +/// AWS KMS signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct AwsKmsSignerConfig { + #[validate(length(min = 1, message = "Region cannot be empty"))] + pub region: Option, + #[validate(length(min = 1, message = "Key ID cannot be empty"))] + pub key_id: String, +} + +/// Vault signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct VaultSignerConfig { + #[validate(url(message = "Address must be a valid URL"))] + pub address: String, + pub namespace: Option, + #[validate(custom( + function = "validate_secret_string", + message = "Role ID cannot be empty" + ))] + pub role_id: SecretString, + #[validate(custom( + function = "validate_secret_string", + message = "Secret ID cannot be empty" + ))] + pub secret_id: SecretString, + #[validate(length(min = 1, message = "Vault key name cannot be empty"))] + pub key_name: String, + pub mount_point: Option, +} + +/// Vault Cloud signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct VaultCloudSignerConfig { + #[validate(length(min = 1, message = "Client ID cannot be empty"))] + pub client_id: String, + #[validate(custom( + function = "validate_secret_string", + message = "Client secret cannot be empty" + ))] + pub client_secret: SecretString, + #[validate(length(min = 1, message = "Organization ID cannot be empty"))] + pub org_id: String, + #[validate(length(min = 1, message = "Project ID cannot be empty"))] + pub project_id: String, + #[validate(length(min = 1, message = "Application name cannot be empty"))] + pub app_name: String, + #[validate(length(min = 1, message = "Key name cannot be empty"))] + pub key_name: String, +} + +/// Vault Transit signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct VaultTransitSignerConfig { + #[validate(length(min = 1, message = "Key name cannot be empty"))] + pub key_name: String, + #[validate(url(message = "Address must be a valid URL"))] + pub address: String, + pub namespace: Option, + #[validate(custom( + function = "validate_secret_string", + message = "Role ID cannot be empty" + ))] + pub role_id: SecretString, + #[validate(custom( + function = "validate_secret_string", + message = "Secret ID cannot be empty" + ))] + pub secret_id: SecretString, + #[validate(length(min = 1, message = "pubkey cannot be empty"))] + pub pubkey: String, + pub mount_point: Option, +} + +/// Turnkey signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct TurnkeySignerConfig { + #[validate(length(min = 1, message = "API public key cannot be empty"))] + pub api_public_key: String, + #[validate(custom( + function = "validate_secret_string", + message = "API private key cannot be empty" + ))] + pub api_private_key: SecretString, + #[validate(length(min = 1, message = "Organization ID cannot be empty"))] + pub organization_id: String, + #[validate(length(min = 1, message = "Private key ID cannot be empty"))] + pub private_key_id: String, + #[validate(length(min = 1, message = "Public key cannot be empty"))] + pub public_key: String, +} + +/// Google Cloud KMS service account configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct GoogleCloudKmsSignerServiceAccountConfig { + #[validate(custom( + function = "validate_secret_string", + message = "Private key cannot be empty" + ))] + pub private_key: SecretString, + #[validate(custom( + function = "validate_secret_string", + message = "Private key ID cannot be empty" + ))] + pub private_key_id: SecretString, + #[validate(length(min = 1, message = "Project ID cannot be empty"))] + pub project_id: String, + #[validate(custom( + function = "validate_secret_string", + message = "Client email cannot be empty" + ))] + pub client_email: SecretString, + #[validate(length(min = 1, message = "Client ID cannot be empty"))] + pub client_id: String, + #[validate(url(message = "Auth URI must be a valid URL"))] + pub auth_uri: String, + #[validate(url(message = "Token URI must be a valid URL"))] + pub token_uri: String, + #[validate(url(message = "Auth provider x509 cert URL must be a valid URL"))] + pub auth_provider_x509_cert_url: String, + #[validate(url(message = "Client x509 cert URL must be a valid URL"))] + pub client_x509_cert_url: String, + pub universe_domain: String, +} + +/// Google Cloud KMS key configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct GoogleCloudKmsSignerKeyConfig { + pub location: String, + #[validate(length(min = 1, message = "Key ring ID cannot be empty"))] + pub key_ring_id: String, + #[validate(length(min = 1, message = "Key ID cannot be empty"))] + pub key_id: String, + pub key_version: u32, +} + +/// Google Cloud KMS signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct GoogleCloudKmsSignerConfig { + #[validate(nested)] + pub service_account: GoogleCloudKmsSignerServiceAccountConfig, + #[validate(nested)] + pub key: GoogleCloudKmsSignerKeyConfig, +} + +/// Custom validator for SecretString +fn validate_secret_string(secret: &SecretString) -> Result<(), validator::ValidationError> { + if secret.to_str().is_empty() { + return Err(validator::ValidationError::new("empty_secret")); + } + Ok(()) +} + +/// Domain signer configuration enum containing all supported signer types +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SignerConfig { + Local(LocalSignerConfig), + Vault(VaultSignerConfig), + VaultCloud(VaultCloudSignerConfig), + VaultTransit(VaultTransitSignerConfig), + AwsKms(AwsKmsSignerConfig), + Turnkey(TurnkeySignerConfig), + GoogleCloudKms(GoogleCloudKmsSignerConfig), +} + +impl SignerConfig { + /// Validates the configuration using the appropriate validator + pub fn validate(&self) -> Result<(), SignerValidationError> { + match self { + Self::Local(config) => config.validate(), + Self::AwsKms(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "AWS KMS validation failed: {}", + format_validation_errors(&e) + )) + }), + Self::Vault(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "Vault validation failed: {}", + format_validation_errors(&e) + )) + }), + Self::VaultCloud(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "Vault Cloud validation failed: {}", + format_validation_errors(&e) + )) + }), + Self::VaultTransit(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "Vault Transit validation failed: {}", + format_validation_errors(&e) + )) + }), + Self::Turnkey(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "Turnkey validation failed: {}", + format_validation_errors(&e) + )) + }), + Self::GoogleCloudKms(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "Google Cloud KMS validation failed: {}", + format_validation_errors(&e) + )) + }), + } + } + + /// Get local signer config if this is a local or test signer + pub fn get_local(&self) -> Option<&LocalSignerConfig> { + match self { + Self::Local(config) => Some(config), + _ => None, + } + } + + /// Get AWS KMS signer config if this is an AWS KMS signer + pub fn get_aws_kms(&self) -> Option<&AwsKmsSignerConfig> { + match self { + Self::AwsKms(config) => Some(config), + _ => None, + } + } + + /// Get Vault signer config if this is a Vault signer + pub fn get_vault(&self) -> Option<&VaultSignerConfig> { + match self { + Self::Vault(config) => Some(config), + _ => None, + } + } + + /// Get Vault Cloud signer config if this is a Vault Cloud signer + pub fn get_vault_cloud(&self) -> Option<&VaultCloudSignerConfig> { + match self { + Self::VaultCloud(config) => Some(config), + _ => None, + } + } + + /// Get Vault Transit signer config if this is a Vault Transit signer + pub fn get_vault_transit(&self) -> Option<&VaultTransitSignerConfig> { + match self { + Self::VaultTransit(config) => Some(config), + _ => None, + } + } + + /// Get Turnkey signer config if this is a Turnkey signer + pub fn get_turnkey(&self) -> Option<&TurnkeySignerConfig> { + match self { + Self::Turnkey(config) => Some(config), + _ => None, + } + } + + /// Get Google Cloud KMS signer config if this is a Google Cloud KMS signer + pub fn get_google_cloud_kms(&self) -> Option<&GoogleCloudKmsSignerConfig> { + match self { + Self::GoogleCloudKms(config) => Some(config), + _ => None, + } + } + + /// Get the signer type from the configuration + pub fn get_signer_type(&self) -> SignerType { + match self { + Self::Local(_) => SignerType::Local, + Self::AwsKms(_) => SignerType::AwsKms, + Self::Vault(_) => SignerType::Vault, + Self::VaultCloud(_) => SignerType::VaultCloud, + Self::VaultTransit(_) => SignerType::VaultTransit, + Self::Turnkey(_) => SignerType::Turnkey, + Self::GoogleCloudKms(_) => SignerType::GoogleCloudKms, + } + } +} + +/// Helper function to format validation errors +fn format_validation_errors(errors: &validator::ValidationErrors) -> String { + let mut messages = Vec::new(); + + for (field, field_errors) in errors.field_errors().iter() { + let field_msgs: Vec = field_errors + .iter() + .map(|error| error.message.clone().unwrap_or_default().to_string()) + .collect(); + messages.push(format!("{}: {}", field, field_msgs.join(", "))); + } + + for (struct_field, kind) in errors.errors().iter() { + if let validator::ValidationErrorsKind::Struct(nested) = kind { + let nested_msgs = format_validation_errors(nested); + messages.push(format!("{}.{}", struct_field, nested_msgs)); + } + } + + messages.join("; ") +} + +/// Core signer domain model containing both metadata and configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct Signer { + #[validate( + length(min = 1, max = 36, message = "ID must be between 1 and 36 characters"), + regex( + path = "*ID_REGEX", + message = "ID must contain only letters, numbers, dashes and underscores" + ) + )] + pub id: String, + pub config: SignerConfig, + pub name: Option, + pub description: Option, +} + +/// Signer type enum used for validation and API responses +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum SignerType { + Local, + #[serde(rename = "aws_kms")] + AwsKms, + #[serde(rename = "google_cloud_kms")] + GoogleCloudKms, + Vault, + #[serde(rename = "vault_cloud")] + VaultCloud, + #[serde(rename = "vault_transit")] + VaultTransit, + Turnkey, +} + +impl Signer { + /// Creates a new signer with configuration + pub fn new( + id: String, + config: SignerConfig, + name: Option, + description: Option, + ) -> Self { + Self { + id, + config, + name, + description, + } + } + + /// Gets the signer type from the configuration + pub fn signer_type(&self) -> SignerType { + self.config.get_signer_type() + } + + /// Validates the signer using both struct validation and config validation + pub fn validate(&self) -> Result<(), SignerValidationError> { + // First validate struct-level constraints (ID format, etc.) + Validate::validate(self).map_err(|validation_errors| { + // Convert validator errors to our custom error type + // Return the first error for simplicity + for (field, errors) in validation_errors.field_errors() { + if let Some(error) = errors.first() { + let field_str = field.as_ref(); + return match (field_str, error.code.as_ref()) { + ("id", "length") => SignerValidationError::InvalidIdFormat, + ("id", "regex") => SignerValidationError::InvalidIdFormat, + _ => SignerValidationError::InvalidIdFormat, // fallback + }; + } + } + // Fallback error + SignerValidationError::InvalidIdFormat + })?; + + // Then validate the configuration + self.config.validate()?; + + Ok(()) + } + + /// Applies an update request to create a new validated signer + /// + /// This method provides a domain-first approach where the core model handles + /// its own business rules and validation rather than having update logic + /// scattered across request models. + /// + /// # Arguments + /// * `request` - The update request containing partial data to apply + /// + /// # Returns + /// * `Ok(Signer)` - A new validated signer with updates applied + /// * `Err(SignerValidationError)` - If the resulting signer would be invalid + pub fn apply_update( + &self, + request: &SignerUpdateRequest, + ) -> Result { + let mut updated = self.clone(); + + // Apply updates from request (only metadata fields can be updated) + if let Some(name) = &request.name { + updated.name = if name.trim().is_empty() { + None + } else { + Some(name.clone()) + }; + } + + if let Some(description) = &request.description { + updated.description = if description.trim().is_empty() { + None + } else { + Some(description.clone()) + }; + } + + // Note: config is immutable after creation for security reasons + // If someone needs to change the signer config, they should create a new signer + + // Validate the updated model + updated.validate()?; + + Ok(updated) + } +} + +/// Validation errors for signers +#[derive(Debug, thiserror::Error)] +pub enum SignerValidationError { + #[error("Signer ID cannot be empty")] + EmptyId, + #[error("Signer ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long")] + InvalidIdFormat, + #[error("Signer name cannot be empty when provided")] + EmptyName, + #[error("Signer description cannot be empty when provided")] + EmptyDescription, + #[error("Invalid signer configuration: {0}")] + InvalidConfig(String), +} + +/// Centralized conversion from SignerValidationError to ApiError +impl From for crate::models::ApiError { + fn from(error: SignerValidationError) -> Self { + use crate::models::ApiError; + + ApiError::BadRequest(match error { + SignerValidationError::EmptyId => "ID cannot be empty".to_string(), + SignerValidationError::InvalidIdFormat => { + "ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long".to_string() + } + SignerValidationError::EmptyName => "Name cannot be empty when provided".to_string(), + SignerValidationError::EmptyDescription => "Description cannot be empty when provided".to_string(), + SignerValidationError::InvalidConfig(msg) => format!("Invalid signer configuration: {}", msg), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_local_signer() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), + }); + + let signer = Signer::new( + "valid-id".to_string(), + config, + Some("Test Signer".to_string()), + Some("A test signer for development".to_string()), + ); + + assert!(signer.validate().is_ok()); + assert_eq!(signer.signer_type(), SignerType::Local); + } + + #[test] + fn test_valid_aws_kms_signer() { + let config = SignerConfig::AwsKms(AwsKmsSignerConfig { + region: Some("us-east-1".to_string()), + key_id: "test-key-id".to_string(), + }); + + let signer = Signer::new("aws-signer".to_string(), config, None, None); + + assert!(signer.validate().is_ok()); + assert_eq!(signer.signer_type(), SignerType::AwsKms); + } + + #[test] + fn test_empty_id() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key + }); + + let signer = Signer::new("".to_string(), config, None, None); + + assert!(matches!( + signer.validate(), + Err(SignerValidationError::InvalidIdFormat) + )); + } + + #[test] + fn test_id_too_long() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key + }); + + let signer = Signer::new("a".repeat(37), config, None, None); + + assert!(matches!( + signer.validate(), + Err(SignerValidationError::InvalidIdFormat) + )); + } + + #[test] + fn test_invalid_id_format() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key + }); + + let signer = Signer::new("invalid@id".to_string(), config, None, None); + + assert!(matches!( + signer.validate(), + Err(SignerValidationError::InvalidIdFormat) + )); + } + + #[test] + fn test_local_signer_invalid_key_length() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(16, |v| v.fill(1)), // Invalid length: 16 bytes instead of 32 + }); + + let signer = Signer::new("valid-id".to_string(), config, None, None); + + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("Raw key must be exactly 32 bytes")); + assert!(msg.contains("got 16 bytes")); + } else { + panic!("Expected InvalidConfig error for invalid key length"); + } + } + + #[test] + fn test_local_signer_all_zero_key() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(0)), // Invalid: all zeros + }); + + let signer = Signer::new("valid-id".to_string(), config, None, None); + + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert_eq!(msg, "Raw key cannot be all zeros"); + } else { + panic!("Expected InvalidConfig error for all-zero key"); + } + } + + #[test] + fn test_local_signer_valid_key() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), // Valid: 32 bytes, non-zero + }); + + let signer = Signer::new("valid-id".to_string(), config, None, None); + + assert!(signer.validate().is_ok()); + } + + #[test] + fn test_apply_update_success() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), + }); + + let original = Signer::new( + "test-id".to_string(), + config, + Some("Original Name".to_string()), + None, + ); + + let update_request = SignerUpdateRequest { + name: Some("Updated Name".to_string()), + description: Some("Updated description".to_string()), + }; + + let result = original.apply_update(&update_request); + assert!(result.is_ok()); + + let updated = result.unwrap(); + assert_eq!(updated.id, "test-id"); // ID should remain unchanged + assert_eq!(updated.signer_type(), SignerType::Local); // Type should remain unchanged + assert_eq!(updated.name, Some("Updated Name".to_string())); // Name updated + assert_eq!(updated.description, Some("Updated description".to_string())); + // Description updated + } + + #[test] + fn test_apply_update_clear_fields() { + let config = SignerConfig::AwsKms(AwsKmsSignerConfig { + region: Some("us-east-1".to_string()), + key_id: "test-key".to_string(), + }); + + let original = Signer::new( + "test-id".to_string(), + config, + Some("Original Name".to_string()), + Some("Original description".to_string()), + ); + + let update_request = SignerUpdateRequest { + name: Some("".to_string()), // Empty string clears the field + description: Some(" ".to_string()), // Whitespace clears the field + }; + + let result = original.apply_update(&update_request); + assert!(result.is_ok()); + + let updated = result.unwrap(); + assert_eq!(updated.name, None); // Name cleared + assert_eq!(updated.description, None); // Description cleared + } + + #[test] + fn test_signer_type_serialization() { + use serde_json::{from_str, to_string}; + + assert_eq!(to_string(&SignerType::Local).unwrap(), "\"local\""); + assert_eq!(to_string(&SignerType::AwsKms).unwrap(), "\"aws_kms\""); + assert_eq!( + to_string(&SignerType::GoogleCloudKms).unwrap(), + "\"google_cloud_kms\"" + ); + assert_eq!( + to_string(&SignerType::VaultTransit).unwrap(), + "\"vault_transit\"" + ); + + assert_eq!( + from_str::("\"local\"").unwrap(), + SignerType::Local + ); + assert_eq!( + from_str::("\"aws_kms\"").unwrap(), + SignerType::AwsKms + ); + } + + #[test] + fn test_config_accessor_methods() { + // Test Local config accessor + let local_config = LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), + }; + let config = SignerConfig::Local(local_config); + assert!(config.get_local().is_some()); + assert!(config.get_aws_kms().is_none()); + + // Test AWS KMS config accessor + let aws_config = AwsKmsSignerConfig { + region: Some("us-east-1".to_string()), + key_id: "test-key".to_string(), + }; + let config = SignerConfig::AwsKms(aws_config); + assert!(config.get_aws_kms().is_some()); + assert!(config.get_local().is_none()); + } + + #[test] + fn test_error_conversion_to_api_error() { + let error = SignerValidationError::InvalidIdFormat; + let api_error: crate::models::ApiError = error.into(); + + if let crate::models::ApiError::BadRequest(msg) = api_error { + assert!(msg.contains("ID must contain only letters, numbers, dashes and underscores")); + } else { + panic!("Expected BadRequest error"); + } + } +} diff --git a/src/repositories/signer/mod.rs b/src/repositories/signer/mod.rs index 484e02bd8..9d7a71bd4 100644 --- a/src/repositories/signer/mod.rs +++ b/src/repositories/signer/mod.rs @@ -139,10 +139,10 @@ mod tests { use crate::models::{LocalSignerConfig, SignerConfig}; use secrets::SecretVec; - fn create_test_signer(id: String) -> SignerRepoModel { + fn create_local_signer(id: String) -> SignerRepoModel { SignerRepoModel { id: id.clone(), - config: SignerConfig::Test(LocalSignerConfig { + config: SignerConfig::Local(LocalSignerConfig { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), } @@ -160,7 +160,7 @@ mod tests { #[actix_web::test] async fn test_in_memory_impl_operations() { let impl_repo = SignerRepositoryStorage::new_in_memory(); - let signer = create_test_signer("test-signer".to_string()); + let signer = create_local_signer("test-signer".to_string()); // Test create let created = impl_repo.create(signer.clone()).await.unwrap(); @@ -210,7 +210,7 @@ mod tests { #[actix_web::test] async fn test_duplicate_creation_error() { let impl_repo = SignerRepositoryStorage::new_in_memory(); - let signer = create_test_signer("duplicate-test".to_string()); + let signer = create_local_signer("duplicate-test".to_string()); // Create the signer first time impl_repo.create(signer.clone()).await.unwrap(); @@ -227,7 +227,7 @@ mod tests { #[actix_web::test] async fn test_update_operations() { let impl_repo = SignerRepositoryStorage::new_in_memory(); - let signer = create_test_signer("update-test".to_string()); + let signer = create_local_signer("update-test".to_string()); // Create the signer first impl_repo.create(signer.clone()).await.unwrap(); @@ -263,7 +263,7 @@ mod tests { #[actix_web::test] async fn test_delete_operations() { let impl_repo = SignerRepositoryStorage::new_in_memory(); - let signer = create_test_signer("delete-test".to_string()); + let signer = create_local_signer("delete-test".to_string()); // Create the signer first impl_repo.create(signer).await.unwrap(); @@ -291,7 +291,7 @@ mod tests { let repo = InMemorySignerRepository::new(); assert!(!repo.has_entries().await.unwrap()); - let signer = create_test_signer("test".to_string()); + let signer = create_local_signer("test".to_string()); repo.create(signer.clone()).await.unwrap(); assert!(repo.has_entries().await.unwrap()); } @@ -299,7 +299,7 @@ mod tests { #[actix_web::test] async fn test_drop_all_entries() { let repo = InMemorySignerRepository::new(); - let signer = create_test_signer("test".to_string()); + let signer = create_local_signer("test".to_string()); repo.create(signer.clone()).await.unwrap(); assert!(repo.has_entries().await.unwrap()); diff --git a/src/repositories/signer/signer_in_memory.rs b/src/repositories/signer/signer_in_memory.rs index d43fd466c..4514f3fd0 100644 --- a/src/repositories/signer/signer_in_memory.rs +++ b/src/repositories/signer/signer_in_memory.rs @@ -203,7 +203,7 @@ mod tests { // Update the signer let updated_signer = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Test(LocalSignerConfig { + config: SignerConfig::Local(LocalSignerConfig { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[2; 32])), }), }; diff --git a/src/repositories/signer/signer_redis.rs b/src/repositories/signer/signer_redis.rs index 404f997ad..1980829bd 100644 --- a/src/repositories/signer/signer_redis.rs +++ b/src/repositories/signer/signer_redis.rs @@ -474,10 +474,10 @@ mod tests { use secrets::SecretVec; use std::sync::Arc; - fn create_test_signer(id: &str) -> SignerRepoModel { + fn create_local_signer(id: &str) -> SignerRepoModel { SignerRepoModel { id: id.to_string(), - config: SignerConfig::Test(LocalSignerConfig { + config: SignerConfig::Local(LocalSignerConfig { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), } @@ -533,7 +533,7 @@ mod tests { #[ignore = "Requires active Redis instance"] async fn test_serialize_deserialize_signer() { let repo = setup_test_repo().await; - let signer = create_test_signer("test-signer"); + let signer = create_local_signer("test-signer"); let serialized = repo.serialize_entity(&signer, |s| &s.id, "signer").unwrap(); let deserialized: SignerRepoModel = repo @@ -541,8 +541,8 @@ mod tests { .unwrap(); assert_eq!(signer.id, deserialized.id); - assert!(matches!(signer.config, SignerConfig::Test(_))); - assert!(matches!(deserialized.config, SignerConfig::Test(_))); + assert!(matches!(signer.config, SignerConfig::Local(_))); + assert!(matches!(deserialized.config, SignerConfig::Local(_))); } #[tokio::test] @@ -550,7 +550,7 @@ mod tests { async fn test_create_signer() { let repo = setup_test_repo().await; let signer_name = uuid::Uuid::new_v4().to_string(); - let signer = create_test_signer(&signer_name); + let signer = create_local_signer(&signer_name); let result = repo.create(signer).await; assert!(result.is_ok()); @@ -564,7 +564,7 @@ mod tests { async fn test_get_signer() { let repo = setup_test_repo().await; let signer_name = uuid::Uuid::new_v4().to_string(); - let signer = create_test_signer(&signer_name); + let signer = create_local_signer(&signer_name); // Create the signer first repo.create(signer.clone()).await.unwrap(); @@ -572,7 +572,7 @@ mod tests { // Get the signer let retrieved = repo.get_by_id(signer_name.clone()).await.unwrap(); assert_eq!(retrieved.id, signer.id); - assert!(matches!(retrieved.config, SignerConfig::Test(_))); + assert!(matches!(retrieved.config, SignerConfig::Local(_))); } #[tokio::test] @@ -590,7 +590,7 @@ mod tests { async fn test_update_signer() { let repo = setup_test_repo().await; let signer_name = uuid::Uuid::new_v4().to_string(); - let signer = create_test_signer(&signer_name); + let signer = create_local_signer(&signer_name); // Create the signer first repo.create(signer.clone()).await.unwrap(); @@ -616,7 +616,7 @@ mod tests { async fn test_delete_signer() { let repo = setup_test_repo().await; let signer_name = uuid::Uuid::new_v4().to_string(); - let signer = create_test_signer(&signer_name); + let signer = create_local_signer(&signer_name); // Create the signer first repo.create(signer).await.unwrap(); @@ -640,8 +640,8 @@ mod tests { let repo = setup_test_repo().await; let signer1_name = uuid::Uuid::new_v4().to_string(); let signer2_name = uuid::Uuid::new_v4().to_string(); - let signer1 = create_test_signer(&signer1_name); - let signer2 = create_test_signer(&signer2_name); + let signer1 = create_local_signer(&signer1_name); + let signer2 = create_local_signer(&signer2_name); // Create signers repo.create(signer1).await.unwrap(); @@ -663,7 +663,7 @@ mod tests { let initial_count = repo.count().await.unwrap(); let signer_name = uuid::Uuid::new_v4().to_string(); - let signer = create_test_signer(&signer_name); + let signer = create_local_signer(&signer_name); // Create a signer repo.create(signer).await.unwrap(); @@ -679,8 +679,8 @@ mod tests { let repo = setup_test_repo().await; let signer1_name = uuid::Uuid::new_v4().to_string(); let signer2_name = uuid::Uuid::new_v4().to_string(); - let signer1 = create_test_signer(&signer1_name); - let signer2 = create_test_signer(&signer2_name); + let signer1 = create_local_signer(&signer1_name); + let signer2 = create_local_signer(&signer2_name); // Create signers repo.create(signer1).await.unwrap(); @@ -704,7 +704,7 @@ mod tests { async fn test_duplicate_signer_creation() { let repo = setup_test_repo().await; let signer_name = uuid::Uuid::new_v4().to_string(); - let signer = create_test_signer(&signer_name); + let signer = create_local_signer(&signer_name); // Create the signer first time repo.create(signer.clone()).await.unwrap(); @@ -746,7 +746,7 @@ mod tests { let repo = setup_test_repo().await; let signer = SignerRepoModel { id: "".to_string(), - config: SignerConfig::Test(LocalSignerConfig { + config: SignerConfig::Local(LocalSignerConfig { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), }; @@ -763,7 +763,7 @@ mod tests { #[ignore = "Requires active Redis instance"] async fn test_update_nonexistent_signer() { let repo = setup_test_repo().await; - let signer = create_test_signer("nonexistent-id"); + let signer = create_local_signer("nonexistent-id"); let result = repo.update("nonexistent-id".to_string(), signer).await; assert!(result.is_err()); @@ -785,13 +785,13 @@ mod tests { async fn test_update_with_mismatched_id() { let repo = setup_test_repo().await; let signer_name = uuid::Uuid::new_v4().to_string(); - let signer = create_test_signer(&signer_name); + let signer = create_local_signer(&signer_name); // Create the signer first repo.create(signer).await.unwrap(); // Try to update with different ID - let updated_signer = create_test_signer("different-id"); + let updated_signer = create_local_signer("different-id"); let result = repo.update(signer_name, updated_signer).await; assert!(result.is_err()); assert!(result @@ -806,7 +806,7 @@ mod tests { let repo = setup_test_repo().await; let signer_id = uuid::Uuid::new_v4().to_string(); - let signer = create_test_signer(&signer_id); + let signer = create_local_signer(&signer_id); repo.create(signer.clone()).await.unwrap(); assert!(repo.has_entries().await.unwrap()); } @@ -816,7 +816,7 @@ mod tests { async fn test_drop_all_entries() { let repo = setup_test_repo().await; let signer_id = uuid::Uuid::new_v4().to_string(); - let signer = create_test_signer(&signer_id); + let signer = create_local_signer(&signer_id); repo.create(signer.clone()).await.unwrap(); assert!(repo.has_entries().await.unwrap()); diff --git a/src/services/signer/evm/mod.rs b/src/services/signer/evm/mod.rs index 67831ab5b..4e0d31ea1 100644 --- a/src/services/signer/evm/mod.rs +++ b/src/services/signer/evm/mod.rs @@ -35,7 +35,7 @@ use crate::{ }, models::{ Address, NetworkTransactionData, SignerConfig, SignerRepoModel, SignerType, - TransactionRepoModel, + TransactionRepoModel, VaultCloudSignerConfig, VaultSignerConfig, }, services::{ turnkey::TurnkeyService, AwsKmsService, GoogleCloudKmsService, TurnkeyServiceTrait, @@ -129,10 +129,9 @@ impl EvmSignerFactory { signer_model: SignerRepoModel, ) -> Result { let signer = match signer_model.config { - SignerConfig::Local(_) - | SignerConfig::Test(_) - | SignerConfig::Vault(_) - | SignerConfig::VaultCloud(_) => EvmSigner::Local(LocalSigner::new(&signer_model)?), + SignerConfig::Local(_) | SignerConfig::Vault(_) | SignerConfig::VaultCloud(_) => { + EvmSigner::Local(LocalSigner::new(&signer_model)?) + } SignerConfig::AwsKms(ref config) => { let aws_service = AwsKmsService::new(config.clone()).await.map_err(|e| { SignerFactoryError::CreationFailed(format!("AWS KMS service error: {}", e)) @@ -211,7 +210,7 @@ mod tests { async fn test_create_evm_signer_test() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Test(LocalSignerConfig { + config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), }), }; @@ -227,8 +226,13 @@ mod tests { async fn test_create_evm_signer_vault() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Vault(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: crate::models::SecretString::new("test-role-id"), + secret_id: crate::models::SecretString::new("test-secret-id"), + key_name: "test-key".to_string(), + mount_point: Some("secret".to_string()), }), }; @@ -243,8 +247,13 @@ mod tests { async fn test_create_evm_signer_vault_cloud() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::VaultCloud(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::VaultCloud(VaultCloudSignerConfig { + client_id: "test-client-id".to_string(), + client_secret: crate::models::SecretString::new("test-client-secret"), + org_id: "test-org-id".to_string(), + project_id: "test-project-id".to_string(), + app_name: "test-app".to_string(), + key_name: "test-key".to_string(), }), }; @@ -340,7 +349,7 @@ mod tests { async fn test_address_evm_signer_test() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Test(LocalSignerConfig { + config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), }), }; @@ -357,8 +366,13 @@ mod tests { async fn test_address_evm_signer_vault() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Vault(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: crate::models::SecretString::new("test-role-id"), + secret_id: crate::models::SecretString::new("test-secret-id"), + key_name: "test-key".to_string(), + mount_point: Some("secret".to_string()), }), }; @@ -374,8 +388,13 @@ mod tests { async fn test_address_evm_signer_vault_cloud() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::VaultCloud(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::VaultCloud(VaultCloudSignerConfig { + client_id: "test-client-id".to_string(), + client_secret: crate::models::SecretString::new("test-client-secret"), + org_id: "test-org-id".to_string(), + project_id: "test-project-id".to_string(), + app_name: "test-app".to_string(), + key_name: "test-key".to_string(), }), }; @@ -565,8 +584,13 @@ mod tests { async fn test_sign_data_with_vault_signer() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Vault(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: crate::models::SecretString::new("test-role-id"), + secret_id: crate::models::SecretString::new("test-secret-id"), + key_name: "test-key".to_string(), + mount_point: Some("secret".to_string()), }), }; @@ -595,8 +619,13 @@ mod tests { async fn test_sign_transaction_with_vault_cloud_signer() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::VaultCloud(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::VaultCloud(VaultCloudSignerConfig { + client_id: "test-client-id".to_string(), + client_secret: crate::models::SecretString::new("test-client-secret"), + org_id: "test-org-id".to_string(), + project_id: "test-project-id".to_string(), + app_name: "test-app".to_string(), + key_name: "test-key".to_string(), }), }; @@ -649,15 +678,27 @@ mod tests { .unwrap(), EvmSignerFactory::create_evm_signer(SignerRepoModel { id: "vault".to_string(), - config: SignerConfig::Vault(LocalSignerConfig { - raw_key: key_bytes.clone(), + config: SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: crate::models::SecretString::new("test-role-id"), + secret_id: crate::models::SecretString::new("test-secret-id"), + key_name: "test-key".to_string(), + mount_point: Some("secret".to_string()), }), }) .await .unwrap(), EvmSignerFactory::create_evm_signer(SignerRepoModel { id: "vault_cloud".to_string(), - config: SignerConfig::VaultCloud(LocalSignerConfig { raw_key: key_bytes }), + config: SignerConfig::VaultCloud(VaultCloudSignerConfig { + client_id: "test-client-id".to_string(), + client_secret: crate::models::SecretString::new("test-client-secret"), + org_id: "test-org-id".to_string(), + project_id: "test-project-id".to_string(), + app_name: "test-app".to_string(), + key_name: "test-key".to_string(), + }), }) .await .unwrap(), @@ -697,14 +738,24 @@ mod tests { let vault_configs = vec![ ( "vault", - SignerConfig::Vault(LocalSignerConfig { - raw_key: test_key_bytes(), + SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: crate::models::SecretString::new("test-role-id"), + secret_id: crate::models::SecretString::new("test-secret-id"), + key_name: "test-key".to_string(), + mount_point: Some("secret".to_string()), }), ), ( "vault_cloud", - SignerConfig::VaultCloud(LocalSignerConfig { - raw_key: test_key_bytes(), + SignerConfig::VaultCloud(VaultCloudSignerConfig { + client_id: "test-client-id".to_string(), + client_secret: crate::models::SecretString::new("test-client-secret"), + org_id: "test-org-id".to_string(), + project_id: "test-project-id".to_string(), + app_name: "test-app".to_string(), + key_name: "test-key".to_string(), }), ), ]; diff --git a/src/services/signer/solana/mod.rs b/src/services/signer/solana/mod.rs index d7e7dd969..72411377d 100644 --- a/src/services/signer/solana/mod.rs +++ b/src/services/signer/solana/mod.rs @@ -37,7 +37,7 @@ use crate::{ }, models::{ Address, NetworkTransactionData, SignerConfig, SignerRepoModel, SignerType, - TransactionRepoModel, + TransactionRepoModel, VaultCloudSignerConfig, VaultSignerConfig, }, services::{GoogleCloudKmsService, TurnkeyService, VaultConfig, VaultService}, }; @@ -138,10 +138,9 @@ impl SolanaSignerFactory { signer_model: &SignerRepoModel, ) -> Result { let signer = match &signer_model.config { - SignerConfig::Local(_) - | SignerConfig::Test(_) - | SignerConfig::Vault(_) - | SignerConfig::VaultCloud(_) => SolanaSigner::Local(LocalSigner::new(signer_model)?), + SignerConfig::Local(_) | SignerConfig::Vault(_) | SignerConfig::VaultCloud(_) => { + SolanaSigner::Local(LocalSigner::new(signer_model)?) + } SignerConfig::VaultTransit(vault_transit_signer_config) => { let vault_service = VaultService::new(VaultConfig { address: vault_transit_signer_config.address.clone(), @@ -234,7 +233,7 @@ mod solana_signer_factory_tests { fn test_create_solana_signer_test() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Test(LocalSignerConfig { + config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), }), }; @@ -251,8 +250,13 @@ mod solana_signer_factory_tests { fn test_create_solana_signer_vault() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Vault(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: crate::models::SecretString::new("test-role-id"), + secret_id: crate::models::SecretString::new("test-secret-id"), + key_name: "test-key".to_string(), + mount_point: Some("secret".to_string()), }), }; @@ -268,8 +272,13 @@ mod solana_signer_factory_tests { fn test_create_solana_signer_vault_cloud() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::VaultCloud(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::VaultCloud(VaultCloudSignerConfig { + client_id: "test-client-id".to_string(), + client_secret: crate::models::SecretString::new("test-client-secret"), + org_id: "test-org-id".to_string(), + project_id: "test-project-id".to_string(), + app_name: "test-app".to_string(), + key_name: "test-key".to_string(), }), }; @@ -380,7 +389,7 @@ mod solana_signer_factory_tests { async fn test_address_solana_signer_test() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Test(LocalSignerConfig { + config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), }), }; @@ -397,8 +406,13 @@ mod solana_signer_factory_tests { async fn test_address_solana_signer_vault() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Vault(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: crate::models::SecretString::new("test-role-id"), + secret_id: crate::models::SecretString::new("test-secret-id"), + key_name: "test-key".to_string(), + mount_point: Some("secret".to_string()), }), }; @@ -414,8 +428,13 @@ mod solana_signer_factory_tests { async fn test_address_solana_signer_vault_cloud() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::VaultCloud(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::VaultCloud(VaultCloudSignerConfig { + client_id: "test-client-id".to_string(), + client_secret: crate::models::SecretString::new("test-client-secret"), + org_id: "test-org-id".to_string(), + project_id: "test-project-id".to_string(), + app_name: "test-app".to_string(), + key_name: "test-key".to_string(), }), }; @@ -531,7 +550,7 @@ mod solana_signer_factory_tests { async fn test_sign_solana_signer_test() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Test(LocalSignerConfig { + config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), }), }; @@ -547,8 +566,13 @@ mod solana_signer_factory_tests { async fn test_sign_solana_signer_vault() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Vault(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: crate::models::SecretString::new("test-role-id"), + secret_id: crate::models::SecretString::new("test-secret-id"), + key_name: "test-key".to_string(), + mount_point: Some("secret".to_string()), }), }; @@ -563,8 +587,13 @@ mod solana_signer_factory_tests { async fn test_sign_solana_signer_vault_cloud() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::VaultCloud(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::VaultCloud(VaultCloudSignerConfig { + client_id: "test-client-id".to_string(), + client_secret: crate::models::SecretString::new("test-client-secret"), + org_id: "test-org-id".to_string(), + project_id: "test-project-id".to_string(), + app_name: "test-app".to_string(), + key_name: "test-key".to_string(), }), }; @@ -580,8 +609,13 @@ mod solana_signer_factory_tests { async fn test_sign_transaction_not_implemented() { let signer_model = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::VaultCloud(LocalSignerConfig { - raw_key: test_key_bytes(), + config: SignerConfig::VaultCloud(VaultCloudSignerConfig { + client_id: "test-client-id".to_string(), + client_secret: crate::models::SecretString::new("test-client-secret"), + org_id: "test-org-id".to_string(), + project_id: "test-project-id".to_string(), + app_name: "test-app".to_string(), + key_name: "test-key".to_string(), }), }; diff --git a/src/services/signer/stellar/mod.rs b/src/services/signer/stellar/mod.rs index c6d04820f..0f0fa739b 100644 --- a/src/services/signer/stellar/mod.rs +++ b/src/services/signer/stellar/mod.rs @@ -43,10 +43,9 @@ pub struct StellarSignerFactory; impl StellarSignerFactory { pub fn create_stellar_signer(m: &SignerRepoModel) -> Result { let signer = match m.config { - SignerConfig::Local(_) - | SignerConfig::Test(_) - | SignerConfig::Vault(_) - | SignerConfig::VaultCloud(_) => StellarSigner::Local(LocalSigner::new(m)?), + SignerConfig::Local(_) | SignerConfig::Vault(_) | SignerConfig::VaultCloud(_) => { + StellarSigner::Local(LocalSigner::new(m)?) + } SignerConfig::AwsKms(_) => { return Err(SignerFactoryError::UnsupportedType("AWS KMS".into())) } diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index 6819b3a9f..9e9535ed9 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -59,7 +59,7 @@ pub mod mockutils { let raw_key = SecretVec::new(32, |v| v.copy_from_slice(&seed)); SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Test(LocalSignerConfig { raw_key }), + config: SignerConfig::Local(LocalSignerConfig { raw_key }), } } From 7e70191fa5bcc913c8adf8e78adc8423ab5f3572 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 16 Jul 2025 23:29:54 +0200 Subject: [PATCH 15/59] chore: improvements --- src/api/controllers/notifications.rs | 319 +++++++++++++ src/api/controllers/signers.rs | 433 ++++++++++++++---- src/bootstrap/config_processor.rs | 12 +- src/models/signer/config.rs | 46 +- src/models/signer/mod.rs | 23 +- src/models/signer/repository.rs | 26 +- src/models/signer/request.rs | 79 ++-- src/models/signer/response.rs | 149 +++--- src/models/signer/signer.rs | 156 +------ src/repositories/relayer/mod.rs | 34 ++ src/repositories/relayer/relayer_in_memory.rs | 330 +++++++++++++ src/repositories/relayer/relayer_redis.rs | 119 +++++ 12 files changed, 1287 insertions(+), 439 deletions(-) diff --git a/src/api/controllers/notifications.rs b/src/api/controllers/notifications.rs index 29f749c47..0c968571b 100644 --- a/src/api/controllers/notifications.rs +++ b/src/api/controllers/notifications.rs @@ -187,6 +187,12 @@ where /// # Returns /// /// A success response or an error if deletion fails. +/// +/// # Security +/// +/// This endpoint ensures that notifications cannot be deleted if they are still being +/// used by any relayers. This prevents breaking existing relayer configurations +/// and maintains system integrity. pub async fn delete_notification( notification_id: String, state: ThinDataAppState, @@ -201,6 +207,30 @@ where TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, { + // First check if the notification exists + let _notification = state + .notification_repository + .get_by_id(notification_id.clone()) + .await?; + + // Check if any relayers are using this notification + let connected_relayers = state + .relayer_repository + .list_by_notification_id(¬ification_id) + .await?; + + if !connected_relayers.is_empty() { + let relayer_names: Vec = + connected_relayers.iter().map(|r| r.name.clone()).collect(); + return Err(ApiError::BadRequest(format!( + "Cannot delete notification '{}' because it is being used by {} relayer(s): {}. Please remove or reconfigure these relayers before deleting the notification.", + notification_id, + connected_relayers.len(), + relayer_names.join(", ") + ))); + } + + // Safe to delete - no relayers are using this notification state .notification_repository .delete_by_id(notification_id) @@ -682,4 +712,293 @@ mod tests { panic!("Expected BadRequest error with validation messages"); } } + + #[actix_web::test] + async fn test_delete_notification_blocked_by_connected_relayers() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test notification + let notification = create_test_notification_model("connected-notification"); + app_state + .notification_repository + .create(notification) + .await + .unwrap(); + + // Create a relayer that uses this notification + let relayer = crate::models::RelayerRepoModel { + id: "test-relayer".to_string(), + name: "Test Relayer".to_string(), + network: "ethereum".to_string(), + paused: false, + network_type: crate::models::NetworkType::Evm, + signer_id: "test-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Evm( + crate::models::RelayerEvmPolicy::default(), + ), + address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(), + notification_id: Some("connected-notification".to_string()), // References our notification + system_disabled: false, + custom_rpc_urls: None, + }; + app_state.relayer_repository.create(relayer).await.unwrap(); + + // Try to delete the notification - should fail + let result = + delete_notification("connected-notification".to_string(), ThinData(app_state)).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + if let ApiError::BadRequest(msg) = error { + assert!(msg.contains("Cannot delete notification")); + assert!(msg.contains("being used by")); + assert!(msg.contains("Test Relayer")); + assert!(msg.contains("remove or reconfigure")); + } else { + panic!("Expected BadRequest error"); + } + } + + #[actix_web::test] + async fn test_delete_notification_after_relayer_removed() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test notification + let notification = create_test_notification_model("cleanup-notification"); + app_state + .notification_repository + .create(notification) + .await + .unwrap(); + + // Create a relayer that uses this notification + let relayer = crate::models::RelayerRepoModel { + id: "temp-relayer".to_string(), + name: "Temporary Relayer".to_string(), + network: "ethereum".to_string(), + paused: false, + network_type: crate::models::NetworkType::Evm, + signer_id: "test-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Evm( + crate::models::RelayerEvmPolicy::default(), + ), + address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(), + notification_id: Some("cleanup-notification".to_string()), + system_disabled: false, + custom_rpc_urls: None, + }; + app_state.relayer_repository.create(relayer).await.unwrap(); + + // First deletion attempt should fail + let result = + delete_notification("cleanup-notification".to_string(), ThinData(app_state)).await; + assert!(result.is_err()); + + // Create new app state for second test (since app_state was consumed) + let app_state2 = create_mock_app_state(None, None, None, None, None).await; + + // Re-create the notification in the new state + let notification2 = create_test_notification_model("cleanup-notification"); + app_state2 + .notification_repository + .create(notification2) + .await + .unwrap(); + + // Now notification deletion should succeed (no relayers in new state) + let result = + delete_notification("cleanup-notification".to_string(), ThinData(app_state2)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_delete_notification_with_multiple_relayers() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test notification + let notification = create_test_notification_model("multi-relayer-notification"); + app_state + .notification_repository + .create(notification) + .await + .unwrap(); + + // Create multiple relayers that use this notification + let relayers = vec![ + crate::models::RelayerRepoModel { + id: "relayer-1".to_string(), + name: "EVM Relayer".to_string(), + network: "ethereum".to_string(), + paused: false, + network_type: crate::models::NetworkType::Evm, + signer_id: "test-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Evm( + crate::models::RelayerEvmPolicy::default(), + ), + address: "0x1111111111111111111111111111111111111111".to_string(), + notification_id: Some("multi-relayer-notification".to_string()), + system_disabled: false, + custom_rpc_urls: None, + }, + crate::models::RelayerRepoModel { + id: "relayer-2".to_string(), + name: "Solana Relayer".to_string(), + network: "solana".to_string(), + paused: true, // Even paused relayers should block deletion + network_type: crate::models::NetworkType::Solana, + signer_id: "test-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Solana( + crate::models::RelayerSolanaPolicy::default(), + ), + address: "solana-address".to_string(), + notification_id: Some("multi-relayer-notification".to_string()), + system_disabled: false, + custom_rpc_urls: None, + }, + crate::models::RelayerRepoModel { + id: "relayer-3".to_string(), + name: "Stellar Relayer".to_string(), + network: "stellar".to_string(), + paused: false, + network_type: crate::models::NetworkType::Stellar, + signer_id: "test-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Stellar( + crate::models::RelayerStellarPolicy::default(), + ), + address: "stellar-address".to_string(), + notification_id: Some("multi-relayer-notification".to_string()), + system_disabled: true, // Even disabled relayers should block deletion + custom_rpc_urls: None, + }, + ]; + + // Create all relayers + for relayer in relayers { + app_state.relayer_repository.create(relayer).await.unwrap(); + } + + // Try to delete the notification - should fail with detailed error + let result = delete_notification( + "multi-relayer-notification".to_string(), + ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + if let ApiError::BadRequest(msg) = error { + assert!(msg.contains("Cannot delete notification 'multi-relayer-notification'")); + assert!(msg.contains("being used by 3 relayer(s)")); + assert!(msg.contains("EVM Relayer")); + assert!(msg.contains("Solana Relayer")); + assert!(msg.contains("Stellar Relayer")); + assert!(msg.contains("remove or reconfigure")); + } else { + panic!("Expected BadRequest error, got: {:?}", error); + } + } + + #[actix_web::test] + async fn test_delete_notification_with_some_relayers_using_different_notification() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create two test notifications + let notification1 = create_test_notification_model("notification-to-delete"); + let notification2 = create_test_notification_model("other-notification"); + app_state + .notification_repository + .create(notification1) + .await + .unwrap(); + app_state + .notification_repository + .create(notification2) + .await + .unwrap(); + + // Create relayers - only one uses the notification we want to delete + let relayer1 = crate::models::RelayerRepoModel { + id: "blocking-relayer".to_string(), + name: "Blocking Relayer".to_string(), + network: "ethereum".to_string(), + paused: false, + network_type: crate::models::NetworkType::Evm, + signer_id: "test-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Evm( + crate::models::RelayerEvmPolicy::default(), + ), + address: "0x1111111111111111111111111111111111111111".to_string(), + notification_id: Some("notification-to-delete".to_string()), // This one blocks deletion + system_disabled: false, + custom_rpc_urls: None, + }; + + let relayer2 = crate::models::RelayerRepoModel { + id: "non-blocking-relayer".to_string(), + name: "Non-blocking Relayer".to_string(), + network: "polygon".to_string(), + paused: false, + network_type: crate::models::NetworkType::Evm, + signer_id: "test-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Evm( + crate::models::RelayerEvmPolicy::default(), + ), + address: "0x2222222222222222222222222222222222222222".to_string(), + notification_id: Some("other-notification".to_string()), // This one uses different notification + system_disabled: false, + custom_rpc_urls: None, + }; + + let relayer3 = crate::models::RelayerRepoModel { + id: "no-notification-relayer".to_string(), + name: "No Notification Relayer".to_string(), + network: "bsc".to_string(), + paused: false, + network_type: crate::models::NetworkType::Evm, + signer_id: "test-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Evm( + crate::models::RelayerEvmPolicy::default(), + ), + address: "0x3333333333333333333333333333333333333333".to_string(), + notification_id: None, // This one has no notification + system_disabled: false, + custom_rpc_urls: None, + }; + + app_state.relayer_repository.create(relayer1).await.unwrap(); + app_state.relayer_repository.create(relayer2).await.unwrap(); + app_state.relayer_repository.create(relayer3).await.unwrap(); + + // Try to delete the first notification - should fail because of one relayer + let result = + delete_notification("notification-to-delete".to_string(), ThinData(app_state)).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + if let ApiError::BadRequest(msg) = error { + assert!(msg.contains("being used by 1 relayer(s)")); + assert!(msg.contains("Blocking Relayer")); + assert!(!msg.contains("Non-blocking Relayer")); // Should not mention the other relayer + assert!(!msg.contains("No Notification Relayer")); // Should not mention relayer with no notification + } else { + panic!("Expected BadRequest error"); + } + + // Try to delete the second notification - should succeed (no relayers using it in our test) + let app_state2 = create_mock_app_state(None, None, None, None, None).await; + let notification2_recreated = create_test_notification_model("other-notification"); + app_state2 + .notification_repository + .create(notification2_recreated) + .await + .unwrap(); + + let result = + delete_notification("other-notification".to_string(), ThinData(app_state2)).await; + + assert!(result.is_ok()); + } } diff --git a/src/api/controllers/signers.rs b/src/api/controllers/signers.rs index c358dfa34..71a40494a 100644 --- a/src/api/controllers/signers.rs +++ b/src/api/controllers/signers.rs @@ -143,16 +143,16 @@ where /// /// # Returns /// -/// The updated signer or an error if update fails. +/// An error indicating that updates are not allowed. /// /// # Note /// -/// Only metadata fields (name, description) can be updated through this endpoint. -/// Signer configuration changes require secure configuration channels for security reasons. +/// Signer updates are not supported for security reasons. To modify a signer, +/// delete the existing one and create a new signer with the desired configuration. pub async fn update_signer( - signer_id: String, - request: SignerUpdateRequest, - state: ThinDataAppState, + _signer_id: String, + _request: SignerUpdateRequest, + _state: ThinDataAppState, ) -> Result where J: JobProducerTrait + Send + Sync + 'static, @@ -164,26 +164,9 @@ where TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, { - // Get the existing signer from repository - let existing_repo_model = state.signer_repository.get_by_id(signer_id.clone()).await?; - - // Convert to domain model and apply update (only metadata changes) - let existing_signer = Signer::from(existing_repo_model.clone()); - let updated_signer = existing_signer.apply_update(&request)?; - - // Create updated repository model (preserving original config) - let updated_repo_model = SignerRepoModel { - id: updated_signer.id, - config: existing_repo_model.config, // Keep original config unchanged - }; - - let saved_signer = state - .signer_repository - .update(signer_id, updated_repo_model) - .await?; - - let response = SignerResponse::from(saved_signer); - Ok(HttpResponse::Ok().json(ApiResponse::success(response))) + Err(ApiError::BadRequest( + "Signer updates are not allowed for security reasons. Please delete the existing signer and create a new one with the desired configuration.".to_string() + )) } /// Deletes a signer by ID. @@ -197,10 +180,11 @@ where /// /// A success response or an error if deletion fails. /// -/// # Warning +/// # Security /// -/// Deleting a signer will prevent any relayers or services that depend on it -/// from functioning properly. Ensure the signer is not in use before deletion. +/// This endpoint ensures that signers cannot be deleted if they are still being +/// used by any relayers. This prevents breaking existing relayer configurations +/// and maintains system integrity. pub async fn delete_signer( signer_id: String, state: ThinDataAppState, @@ -215,6 +199,27 @@ where TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, { + // First check if the signer exists + let _signer = state.signer_repository.get_by_id(signer_id.clone()).await?; + + // Check if any relayers are using this signer + let connected_relayers = state + .relayer_repository + .list_by_signer_id(&signer_id) + .await?; + + if !connected_relayers.is_empty() { + let relayer_names: Vec = + connected_relayers.iter().map(|r| r.name.clone()).collect(); + return Err(ApiError::BadRequest(format!( + "Cannot delete signer '{}' because it is being used by {} relayer(s): {}. Please remove or reconfigure these relayers before deleting the signer.", + signer_id, + connected_relayers.len(), + relayer_names.join(", ") + ))); + } + + // Safe to delete - no relayers are using this signer state.signer_repository.delete_by_id(signer_id).await?; Ok(HttpResponse::Ok().json(ApiResponse::success("Signer deleted successfully"))) @@ -255,11 +260,16 @@ mod tests { id: Option, signer_type: SignerType, ) -> SignerCreateRequest { - use crate::models::{AwsKmsSignerRequestConfig, PlainSignerRequestConfig, SignerConfigRequest}; + use crate::models::{ + AwsKmsSignerRequestConfig, PlainSignerRequestConfig, SignerConfigRequest, + }; let config = match signer_type { - SignerType::Local => SignerConfigRequest::Local { - config: PlainSignerRequestConfig { key: "placeholder-key".to_string() } + SignerType::Local => SignerConfigRequest::Local { + config: PlainSignerRequestConfig { + key: "1111111111111111111111111111111111111111111111111111111111111111" + .to_string(), // Valid 32-byte hex key + }, }, SignerType::AwsKms => SignerConfigRequest::AwsKms { config: AwsKmsSignerRequestConfig { @@ -267,25 +277,19 @@ mod tests { key_id: "test-key-id".to_string(), }, }, - _ => SignerConfigRequest::Local { - config: PlainSignerRequestConfig { key: "placeholder-key".to_string() } + _ => SignerConfigRequest::Local { + config: PlainSignerRequestConfig { + key: "placeholder-key".to_string(), + }, }, // Use Local for other types in helper }; - SignerCreateRequest { - id, - config, - name: Some("Test Signer".to_string()), - description: Some("A test signer for development".to_string()), - } + SignerCreateRequest { id, config } } /// Helper function to create a test signer update request fn create_test_signer_update_request() -> SignerUpdateRequest { - SignerUpdateRequest { - name: Some("Updated Signer Name".to_string()), - description: Some("Updated signer description".to_string()), - } + SignerUpdateRequest {} } #[actix_web::test] @@ -424,40 +428,27 @@ mod tests { } #[actix_web::test] - async fn test_create_signer_production_types_require_config() { - let production_types = vec![ - SignerType::Local, - SignerType::AwsKms, - SignerType::GoogleCloudKms, - SignerType::Vault, - SignerType::VaultTransit, - SignerType::Turnkey, - ]; - - for signer_type in production_types { - let app_state = create_mock_app_state(None, None, None, None, None).await; - let request = - create_test_signer_create_request(Some("test".to_string()), signer_type.clone()); - let result = create_signer(request, actix_web::web::ThinData(app_state)).await; - - assert!( - result.is_err(), - "Should fail for signer type: {:?}", - signer_type - ); - if let Err(ApiError::BadRequest(msg)) = result { - assert!(msg.contains("require secure configuration setup")); - } else { - panic!( - "Expected BadRequest error for signer type: {:?}", - signer_type - ); - } - } + async fn test_create_signer_with_valid_configs() { + // Test Local signer with valid key + let app_state1 = create_mock_app_state(None, None, None, None, None).await; + let request = + create_test_signer_create_request(Some("local-test".to_string()), SignerType::Local); + let result = create_signer(request, actix_web::web::ThinData(app_state1)).await; + assert!(result.is_ok(), "Local signer with valid key should succeed"); + + // Test AWS KMS signer with valid config + let app_state2 = create_mock_app_state(None, None, None, None, None).await; + let request = + create_test_signer_create_request(Some("aws-test".to_string()), SignerType::AwsKms); + let result = create_signer(request, actix_web::web::ThinData(app_state2)).await; + assert!( + result.is_ok(), + "AWS KMS signer with valid config should succeed" + ); } #[actix_web::test] - async fn test_update_signer_success() { + async fn test_update_signer_not_allowed() { let app_state = create_mock_app_state(None, None, None, None, None).await; // Create a test signer @@ -473,24 +464,18 @@ mod tests { ) .await; - assert!(result.is_ok()); - let response = result.unwrap(); - assert_eq!(response.status(), 200); - - let body = actix_web::body::to_bytes(response.into_body()) - .await - .unwrap(); - let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); - - assert!(api_response.success); - let data = api_response.data.unwrap(); - assert_eq!(data.id, "test-signer"); - // Note: name and description won't be updated in the response since - // the repository model doesn't store metadata currently + assert!(result.is_err()); + let error = result.unwrap_err(); + if let ApiError::BadRequest(msg) = error { + assert!(msg.contains("Signer updates are not allowed")); + assert!(msg.contains("delete the existing signer and create a new one")); + } else { + panic!("Expected BadRequest error"); + } } #[actix_web::test] - async fn test_update_signer_not_found() { + async fn test_update_signer_always_fails() { let app_state = create_mock_app_state(None, None, None, None, None).await; let update_request = create_test_signer_update_request(); @@ -504,7 +489,11 @@ mod tests { assert!(result.is_err()); let error = result.unwrap_err(); - assert!(matches!(error, ApiError::NotFound(_))); + if let ApiError::BadRequest(msg) = error { + assert!(msg.contains("Signer updates are not allowed")); + } else { + panic!("Expected BadRequest error"); + } } #[actix_web::test] @@ -534,6 +523,51 @@ mod tests { assert_eq!(api_response.data.unwrap(), "Signer deleted successfully"); } + #[actix_web::test] + async fn test_delete_signer_blocked_by_connected_relayers() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test signer + let signer = create_test_signer_model("connected-signer", SignerType::Local); + app_state.signer_repository.create(signer).await.unwrap(); + + // Create a relayer that uses this signer + let relayer = crate::models::RelayerRepoModel { + id: "test-relayer".to_string(), + name: "Test Relayer".to_string(), + network: "ethereum".to_string(), + paused: false, + network_type: crate::models::NetworkType::Evm, + signer_id: "connected-signer".to_string(), // References our signer + policies: crate::models::RelayerNetworkPolicy::Evm( + crate::models::RelayerEvmPolicy::default(), + ), + address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(), + notification_id: None, + system_disabled: false, + custom_rpc_urls: None, + }; + app_state.relayer_repository.create(relayer).await.unwrap(); + + // Try to delete the signer - should fail + let result = delete_signer( + "connected-signer".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + if let ApiError::BadRequest(msg) = error { + assert!(msg.contains("Cannot delete signer")); + assert!(msg.contains("being used by")); + assert!(msg.contains("Test Relayer")); + assert!(msg.contains("remove or reconfigure")); + } else { + panic!("Expected BadRequest error"); + } + } + #[actix_web::test] async fn test_delete_signer_not_found() { let app_state = create_mock_app_state(None, None, None, None, None).await; @@ -549,6 +583,59 @@ mod tests { assert!(matches!(error, ApiError::NotFound(_))); } + #[actix_web::test] + async fn test_delete_signer_after_relayer_removed() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test signer + let signer = create_test_signer_model("cleanup-signer", SignerType::Local); + app_state.signer_repository.create(signer).await.unwrap(); + + // Create a relayer that uses this signer + let relayer = crate::models::RelayerRepoModel { + id: "temp-relayer".to_string(), + name: "Temporary Relayer".to_string(), + network: "ethereum".to_string(), + paused: false, + network_type: crate::models::NetworkType::Evm, + signer_id: "cleanup-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Evm( + crate::models::RelayerEvmPolicy::default(), + ), + address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(), + notification_id: None, + system_disabled: false, + custom_rpc_urls: None, + }; + app_state.relayer_repository.create(relayer).await.unwrap(); + + // First deletion attempt should fail + let result = delete_signer( + "cleanup-signer".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + assert!(result.is_err()); + + // Create new app state for second test (since app_state was consumed) + let app_state2 = create_mock_app_state(None, None, None, None, None).await; + + // Re-create the signer in the new state + let signer2 = create_test_signer_model("cleanup-signer", SignerType::Local); + app_state2.signer_repository.create(signer2).await.unwrap(); + + // Now signer deletion should succeed (no relayers in new state) + let result = delete_signer( + "cleanup-signer".to_string(), + actix_web::web::ThinData(app_state2), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + #[actix_web::test] async fn test_signer_response_conversion() { let signer_model = SignerRepoModel { @@ -563,4 +650,168 @@ mod tests { assert_eq!(response.id, "test-id"); assert_eq!(response.r#type, SignerType::Local); } + + #[actix_web::test] + async fn test_delete_signer_with_multiple_relayers() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create a test signer + let signer = create_test_signer_model("multi-relayer-signer", SignerType::AwsKms); + app_state.signer_repository.create(signer).await.unwrap(); + + // Create multiple relayers that use this signer + let relayers = vec![ + crate::models::RelayerRepoModel { + id: "relayer-1".to_string(), + name: "EVM Relayer".to_string(), + network: "ethereum".to_string(), + paused: false, + network_type: crate::models::NetworkType::Evm, + signer_id: "multi-relayer-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Evm( + crate::models::RelayerEvmPolicy::default(), + ), + address: "0x1111111111111111111111111111111111111111".to_string(), + notification_id: None, + system_disabled: false, + custom_rpc_urls: None, + }, + crate::models::RelayerRepoModel { + id: "relayer-2".to_string(), + name: "Solana Relayer".to_string(), + network: "solana".to_string(), + paused: true, // Even paused relayers should block deletion + network_type: crate::models::NetworkType::Solana, + signer_id: "multi-relayer-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Solana( + crate::models::RelayerSolanaPolicy::default(), + ), + address: "solana-address".to_string(), + notification_id: None, + system_disabled: false, + custom_rpc_urls: None, + }, + crate::models::RelayerRepoModel { + id: "relayer-3".to_string(), + name: "Stellar Relayer".to_string(), + network: "stellar".to_string(), + paused: false, + network_type: crate::models::NetworkType::Stellar, + signer_id: "multi-relayer-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Stellar( + crate::models::RelayerStellarPolicy::default(), + ), + address: "stellar-address".to_string(), + notification_id: None, + system_disabled: true, // Even disabled relayers should block deletion + custom_rpc_urls: None, + }, + ]; + + // Create all relayers + for relayer in relayers { + app_state.relayer_repository.create(relayer).await.unwrap(); + } + + // Try to delete the signer - should fail with detailed error + let result = delete_signer( + "multi-relayer-signer".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + if let ApiError::BadRequest(msg) = error { + assert!(msg.contains("Cannot delete signer 'multi-relayer-signer'")); + assert!(msg.contains("being used by 3 relayer(s)")); + assert!(msg.contains("EVM Relayer")); + assert!(msg.contains("Solana Relayer")); + assert!(msg.contains("Stellar Relayer")); + assert!(msg.contains("remove or reconfigure")); + } else { + panic!("Expected BadRequest error, got: {:?}", error); + } + } + + #[actix_web::test] + async fn test_delete_signer_with_some_relayers_using_different_signer() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + // Create two test signers + let signer1 = create_test_signer_model("signer-to-delete", SignerType::Local); + let signer2 = create_test_signer_model("other-signer", SignerType::AwsKms); + app_state.signer_repository.create(signer1).await.unwrap(); + app_state.signer_repository.create(signer2).await.unwrap(); + + // Create relayers - only one uses the signer we want to delete + let relayer1 = crate::models::RelayerRepoModel { + id: "blocking-relayer".to_string(), + name: "Blocking Relayer".to_string(), + network: "ethereum".to_string(), + paused: false, + network_type: crate::models::NetworkType::Evm, + signer_id: "signer-to-delete".to_string(), // This one blocks deletion + policies: crate::models::RelayerNetworkPolicy::Evm( + crate::models::RelayerEvmPolicy::default(), + ), + address: "0x1111111111111111111111111111111111111111".to_string(), + notification_id: None, + system_disabled: false, + custom_rpc_urls: None, + }; + + let relayer2 = crate::models::RelayerRepoModel { + id: "non-blocking-relayer".to_string(), + name: "Non-blocking Relayer".to_string(), + network: "polygon".to_string(), + paused: false, + network_type: crate::models::NetworkType::Evm, + signer_id: "other-signer".to_string(), // This one uses different signer + policies: crate::models::RelayerNetworkPolicy::Evm( + crate::models::RelayerEvmPolicy::default(), + ), + address: "0x2222222222222222222222222222222222222222".to_string(), + notification_id: None, + system_disabled: false, + custom_rpc_urls: None, + }; + + app_state.relayer_repository.create(relayer1).await.unwrap(); + app_state.relayer_repository.create(relayer2).await.unwrap(); + + // Try to delete the first signer - should fail because of one relayer + let result = delete_signer( + "signer-to-delete".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + if let ApiError::BadRequest(msg) = error { + assert!(msg.contains("being used by 1 relayer(s)")); + assert!(msg.contains("Blocking Relayer")); + assert!(!msg.contains("Non-blocking Relayer")); // Should not mention the other relayer + } else { + panic!("Expected BadRequest error"); + } + + // Try to delete the second signer - should succeed (no relayers using it in our test) + let app_state2 = create_mock_app_state(None, None, None, None, None).await; + let signer2_recreated = create_test_signer_model("other-signer", SignerType::AwsKms); + app_state2 + .signer_repository + .create(signer2_recreated) + .await + .unwrap(); + + let result = delete_signer( + "other-signer".to_string(), + actix_web::web::ThinData(app_state2), + ) + .await; + + assert!(result.is_ok()); + } } diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index 6c9692aee..c0abb13cd 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -344,11 +344,11 @@ mod tests { constants::DEFAULT_PLUGIN_TIMEOUT_SECONDS, jobs::MockJobProducerTrait, models::{ - AppState, AwsKmsSignerFileConfig, GoogleCloudKmsSignerFileConfig, KmsKeyFileConfig, + AppState, AwsKmsSignerFileConfig, GoogleCloudKmsKeyFileConfig, + GoogleCloudKmsServiceAccountFileConfig, GoogleCloudKmsSignerFileConfig, LocalSignerFileConfig, NetworkType, NotificationConfig, NotificationType, - PlainOrEnvValue, SecretString, ServiceAccountFileConfig, SignerConfig, - SignerFileConfig, SignerFileConfigEnum, VaultSignerFileConfig, - VaultTransitSignerFileConfig, + PlainOrEnvValue, SecretString, SignerConfig, SignerFileConfig, SignerFileConfigEnum, + VaultSignerFileConfig, VaultTransitSignerFileConfig, }, repositories::{ InMemoryNetworkRepository, InMemoryNotificationRepository, InMemoryPluginRepository, @@ -1181,7 +1181,7 @@ mod tests { let signer = SignerFileConfig { id: "gcp-kms-signer".to_string(), config: SignerFileConfigEnum::GoogleCloudKms(GoogleCloudKmsSignerFileConfig { - service_account: ServiceAccountFileConfig { + service_account: GoogleCloudKmsServiceAccountFileConfig { private_key: PlainOrEnvValue::Plain { value: SecretString::new("-----BEGIN EXAMPLE PRIVATE KEY-----\nFAKEKEYDATA\n-----END EXAMPLE PRIVATE KEY-----\n"), }, @@ -1199,7 +1199,7 @@ mod tests { auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs".to_string(), universe_domain: "googleapis.com".to_string(), }, - key: KmsKeyFileConfig { + key: GoogleCloudKmsKeyFileConfig { location: "global".to_string(), key_id: "fake-key-id".to_string(), key_ring_id: "fake-key-ring-id".to_string(), diff --git a/src/models/signer/config.rs b/src/models/signer/config.rs index c9d405e2b..b825d82e2 100644 --- a/src/models/signer/config.rs +++ b/src/models/signer/config.rs @@ -80,70 +80,70 @@ pub struct VaultTransitSignerFileConfig { pub namespace: Option, } -fn default_auth_uri() -> String { +fn google_cloud_default_auth_uri() -> String { "https://accounts.google.com/o/oauth2/auth".to_string() } -fn default_token_uri() -> String { +fn google_cloud_default_token_uri() -> String { "https://oauth2.googleapis.com/token".to_string() } -fn default_auth_provider_x509_cert_url() -> String { +fn google_cloud_default_auth_provider_x509_cert_url() -> String { "https://www.googleapis.com/oauth2/v1/certs".to_string() } -fn default_client_x509_cert_url() -> String { +fn google_cloud_default_client_x509_cert_url() -> String { "https://www.googleapis.com/robot/v1/metadata/x509/solana-signer%40forward-emitter-459820-r7.iam.gserviceaccount.com".to_string() } -fn default_universe_domain() -> String { +fn google_cloud_default_universe_domain() -> String { "googleapis.com".to_string() } -fn default_key_version() -> u32 { +fn google_cloud_default_key_version() -> u32 { 1 } -fn default_location() -> String { +fn google_cloud_default_location() -> String { "global".to_string() } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(deny_unknown_fields)] -pub struct ServiceAccountFileConfig { +pub struct GoogleCloudKmsServiceAccountFileConfig { pub project_id: String, pub private_key_id: PlainOrEnvValue, pub private_key: PlainOrEnvValue, pub client_email: PlainOrEnvValue, pub client_id: String, - #[serde(default = "default_auth_uri")] + #[serde(default = "google_cloud_default_auth_uri")] pub auth_uri: String, - #[serde(default = "default_token_uri")] + #[serde(default = "google_cloud_default_token_uri")] pub token_uri: String, - #[serde(default = "default_auth_provider_x509_cert_url")] + #[serde(default = "google_cloud_default_auth_provider_x509_cert_url")] pub auth_provider_x509_cert_url: String, - #[serde(default = "default_client_x509_cert_url")] + #[serde(default = "google_cloud_default_client_x509_cert_url")] pub client_x509_cert_url: String, - #[serde(default = "default_universe_domain")] + #[serde(default = "google_cloud_default_universe_domain")] pub universe_domain: String, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(deny_unknown_fields)] -pub struct KmsKeyFileConfig { - #[serde(default = "default_location")] +pub struct GoogleCloudKmsKeyFileConfig { + #[serde(default = "google_cloud_default_location")] pub location: String, pub key_ring_id: String, pub key_id: String, - #[serde(default = "default_key_version")] + #[serde(default = "google_cloud_default_key_version")] pub key_version: u32, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct GoogleCloudKmsSignerFileConfig { - pub service_account: ServiceAccountFileConfig, - pub key: KmsKeyFileConfig, + pub service_account: GoogleCloudKmsServiceAccountFileConfig, + pub key: GoogleCloudKmsKeyFileConfig, } /// Main enum for all signer config types @@ -211,8 +211,6 @@ impl SignersFileConfig { } } -// ===== CONVERSION IMPLEMENTATIONS ===== - impl TryFrom for LocalSignerConfig { type Error = ConfigFileError; @@ -445,7 +443,7 @@ impl TryFrom for Signer { let signer_config = SignerConfig::try_from(config.config)?; // Create core signer with configuration - let signer = Signer::new(config.id, signer_config, None, None); + let signer = Signer::new(config.id, signer_config); // Validate using domain model validation logic signer.validate().map_err(|e| match e { @@ -455,12 +453,6 @@ impl TryFrom for Signer { crate::models::signer::signer::SignerValidationError::InvalidIdFormat => { ConfigFileError::InvalidFormat("Invalid signer ID format".into()) } - crate::models::signer::signer::SignerValidationError::EmptyName => { - ConfigFileError::InvalidFormat("Signer name cannot be empty".into()) - } - crate::models::signer::signer::SignerValidationError::EmptyDescription => { - ConfigFileError::InvalidFormat("Signer description cannot be empty".into()) - } crate::models::signer::signer::SignerValidationError::InvalidConfig(msg) => { ConfigFileError::InvalidFormat(format!("Invalid signer configuration: {}", msg)) } diff --git a/src/models/signer/mod.rs b/src/models/signer/mod.rs index 44d862968..669f9298d 100644 --- a/src/models/signer/mod.rs +++ b/src/models/signer/mod.rs @@ -1,25 +1,18 @@ +//! Signer models mod repository; pub use repository::{ - AwsKmsSignerConfigStorage, - GoogleCloudKmsSignerConfigStorage, - GoogleCloudKmsSignerKeyConfigStorage, - GoogleCloudKmsSignerServiceAccountConfigStorage, - // Don't re-export SignerConfig or config structs from repository to avoid conflicts with domain - LocalSignerConfigStorage, - SignerConfigStorage, - SignerRepoModel, - SignerRepoModelStorage, - TurnkeySignerConfigStorage, - VaultCloudSignerConfigStorage, - VaultSignerConfigStorage, + AwsKmsSignerConfigStorage, GoogleCloudKmsSignerConfigStorage, + GoogleCloudKmsSignerKeyConfigStorage, GoogleCloudKmsSignerServiceAccountConfigStorage, + LocalSignerConfigStorage, SignerConfigStorage, SignerRepoModel, SignerRepoModelStorage, + TurnkeySignerConfigStorage, VaultCloudSignerConfigStorage, VaultSignerConfigStorage, VaultTransitSignerConfigStorage, }; mod config; -pub use config::*; // Export config file models +pub use config::*; -pub mod signer; // Make public for access from other modules -pub use signer::*; // This exports domain models including Signer, SignerConfig, SignerType, etc. +pub mod signer; +pub use signer::*; mod request; pub use request::*; diff --git a/src/models/signer/repository.rs b/src/models/signer/repository.rs index 14a8933b7..d14e939a4 100644 --- a/src/models/signer/repository.rs +++ b/src/models/signer/repository.rs @@ -8,9 +8,18 @@ //! //! Acts as the bridge between the domain layer and actual data storage implementations //! (in-memory, Redis, etc.), ensuring consistent data representation across repositories. +//! use crate::{ - models::signer::signer::{Signer, SignerConfig, SignerValidationError}, + models::{ + signer::signer::{ + AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, + GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, Signer, SignerConfig, + SignerValidationError, TurnkeySignerConfig, VaultCloudSignerConfig, VaultSignerConfig, + VaultTransitSignerConfig, + }, + SecretString, + }, utils::{base64_decode, base64_encode}, }; use secrets::SecretVec; @@ -178,8 +187,6 @@ impl From for Signer { Self { id: repo_model.id, config: repo_model.config, - name: None, // Repository doesn't store metadata - description: None, // Repository doesn't store metadata } } } @@ -222,14 +229,6 @@ impl From for SignerConfig { } } -// Individual config type conversions - these handle the mapping between domain and storage representations -use crate::models::signer::signer::{ - AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, - GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, TurnkeySignerConfig, - VaultCloudSignerConfig, VaultSignerConfig, VaultTransitSignerConfig, -}; -use crate::models::SecretString; - impl From for LocalSignerConfigStorage { fn from(config: LocalSignerConfig) -> Self { Self { @@ -469,8 +468,6 @@ mod tests { let core = crate::models::signer::signer::Signer::new( "test-id".to_string(), SignerConfig::Local(config), - Some("Test Signer".to_string()), - Some("A test signer".to_string()), ); let repo_model = SignerRepoModel::from(core); @@ -498,9 +495,6 @@ mod tests { core.signer_type(), crate::models::signer::signer::SignerType::AwsKms ); - // Note: metadata (name, description) is None when coming from repository - assert_eq!(core.name, None); - assert_eq!(core.description, None); } #[test] diff --git a/src/models/signer/request.rs b/src/models/signer/request.rs index 5b384ebd6..1090673a3 100644 --- a/src/models/signer/request.rs +++ b/src/models/signer/request.rs @@ -20,7 +20,6 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use zeroize::Zeroize; - /// AWS KMS signer configuration for API requests #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] pub struct PlainSignerRequestConfig { @@ -149,21 +148,12 @@ pub struct SignerCreateRequest { /// The signer configuration including type and config data #[serde(flatten)] pub config: SignerConfigRequest, - /// Optional human-readable name for the signer - pub name: Option, - /// Optional description of the signer's purpose - pub description: Option, } /// Request model for updating an existing signer +/// At the moment, we don't allow updating signers #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] -pub struct SignerUpdateRequest { - /// Optional updated name for the signer - pub name: Option, - /// Optional updated description for the signer - pub description: Option, - // Note: signer_type and config are immutable after creation for security -} +pub struct SignerUpdateRequest {} impl From for AwsKmsSignerConfig { fn from(config: AwsKmsSignerRequestConfig) -> Self { @@ -276,14 +266,12 @@ impl TryFrom for SignerConfig { .map_err(|e| ApiError::BadRequest(format!( "Invalid hex key format: {}. Key must be a 64-character hex string (32 bytes).", e )))?; - + let raw_key = SecretVec::new(key_bytes.len(), |buffer| { buffer.copy_from_slice(&key_bytes); }); - - SignerConfig::Local(LocalSignerConfig { - raw_key, - }) + + SignerConfig::Local(LocalSignerConfig { raw_key }) } SignerConfigRequest::AwsKms { config } => SignerConfig::AwsKms(config.into()), SignerConfigRequest::Vault { config } => SignerConfig::Vault(config.into()), @@ -317,7 +305,7 @@ impl TryFrom for Signer { let config = SignerConfig::try_from(request.config)?; // Create the signer - let signer = Signer::new(id, config, request.name, request.description); + let signer = Signer::new(id, config); // Validate using domain model validation (this will also validate the config) signer.validate().map_err(ApiError::from)?; @@ -341,8 +329,6 @@ mod tests { key_id: "test-key-id".to_string(), }, }, - name: Some("Test AWS KMS Signer".to_string()), - description: Some("A test AWS KMS signer".to_string()), }; let result = Signer::try_from(request); @@ -351,7 +337,6 @@ mod tests { let signer = result.unwrap(); assert_eq!(signer.id, "test-aws-signer"); assert_eq!(signer.signer_type(), SignerType::AwsKms); - assert_eq!(signer.name, Some("Test AWS KMS Signer".to_string())); // Verify the config was properly converted if let Some(aws_config) = signer.config.get_aws_kms() { @@ -376,8 +361,6 @@ mod tests { mount_point: None, }, }, - name: Some("Test Vault Signer".to_string()), - description: None, }; let result = Signer::try_from(request); @@ -398,8 +381,6 @@ mod tests { key_id: "".to_string(), // Empty key ID should fail validation }, }, - name: None, - description: None, }; let result = Signer::try_from(request); @@ -426,8 +407,6 @@ mod tests { mount_point: None, }, }, - name: None, - description: None, }; let result = Signer::try_from(request); @@ -448,8 +427,6 @@ mod tests { mount_point: None, }, }, - name: None, - description: None, }; let result = Signer::try_from(request); @@ -466,11 +443,12 @@ mod tests { fn test_create_request_generates_uuid_when_no_id() { let request = SignerCreateRequest { id: None, - config: SignerConfigRequest::Local { config: PlainSignerRequestConfig { - key: "1111111111111111111111111111111111111111111111111111111111111111".to_string() // 32 bytes as hex - } }, - name: Some("Test Signer".to_string()), - description: None, + config: SignerConfigRequest::Local { + config: PlainSignerRequestConfig { + key: "1111111111111111111111111111111111111111111111111111111111111111" + .to_string(), // 32 bytes as hex + }, + }, }; let result = Signer::try_from(request); @@ -488,11 +466,12 @@ mod tests { fn test_invalid_id_format() { let request = SignerCreateRequest { id: Some("invalid@id".to_string()), // Invalid characters - config: SignerConfigRequest::Local { config: PlainSignerRequestConfig { - key: "2222222222222222222222222222222222222222222222222222222222222222".to_string() // 32 bytes as hex - } }, - name: None, - description: None, + config: SignerConfigRequest::Local { + config: PlainSignerRequestConfig { + key: "2222222222222222222222222222222222222222222222222222222222222222" + .to_string(), // 32 bytes as hex + }, + }, }; let result = Signer::try_from(request); @@ -509,11 +488,12 @@ mod tests { fn test_test_signer_creation() { let request = SignerCreateRequest { id: Some("test-signer".to_string()), - config: SignerConfigRequest::Local { config: PlainSignerRequestConfig { - key: "3333333333333333333333333333333333333333333333333333333333333333".to_string() // 32 bytes as hex - } }, - name: None, - description: None, + config: SignerConfigRequest::Local { + config: PlainSignerRequestConfig { + key: "3333333333333333333333333333333333333333333333333333333333333333" + .to_string(), // 32 bytes as hex + }, + }, }; let result = Signer::try_from(request); @@ -528,11 +508,12 @@ mod tests { fn test_local_signer_creation() { let request = SignerCreateRequest { id: Some("local-signer".to_string()), - config: SignerConfigRequest::Local { config: PlainSignerRequestConfig { - key: "4444444444444444444444444444444444444444444444444444444444444444".to_string() // 32 bytes as hex - }}, - name: Some("Local Test Signer".to_string()), - description: None, + config: SignerConfigRequest::Local { + config: PlainSignerRequestConfig { + key: "4444444444444444444444444444444444444444444444444444444444444444" + .to_string(), // 32 bytes as hex + }, + }, }; let result = Signer::try_from(request); diff --git a/src/models/signer/response.rs b/src/models/signer/response.rs index 39ec2e750..944192ff6 100644 --- a/src/models/signer/response.rs +++ b/src/models/signer/response.rs @@ -20,9 +20,7 @@ use utoipa::ToSchema; #[serde(rename_all = "lowercase")] pub enum SignerConfigResponse { #[serde(rename = "plain")] - Plain { - has_key: bool, - }, + Plain { has_key: bool }, Vault { address: String, namespace: Option, @@ -105,64 +103,54 @@ impl From for SignerConfigResponse { has_role_id: !c.role_id.is_empty(), has_secret_id: !c.secret_id.is_empty(), }, - SignerConfig::VaultCloud(c) => { - SignerConfigResponse::VaultCloud { - client_id: c.client_id, - org_id: c.org_id, - project_id: c.project_id, - app_name: c.app_name, - key_name: c.key_name, - has_client_secret: !c.client_secret.is_empty(), - } - } - SignerConfig::VaultTransit(c) => { - SignerConfigResponse::VaultTransit { - key_name: c.key_name, - address: c.address, - namespace: c.namespace, - pubkey: c.pubkey, - mount_point: c.mount_point, - has_role_id: !c.role_id.is_empty(), - has_secret_id: !c.secret_id.is_empty(), - } - } - SignerConfig::AwsKms(c) => { - SignerConfigResponse::AwsKms { - region: c.region, - key_id: c.key_id, - } - } - SignerConfig::Turnkey(c) => { - SignerConfigResponse::Turnkey { - api_public_key: c.api_public_key, - organization_id: c.organization_id, - private_key_id: c.private_key_id, - public_key: c.public_key, - has_api_private_key: !c.api_private_key.is_empty(), - } - } - SignerConfig::GoogleCloudKms(c) => { - SignerConfigResponse::GoogleCloudKms { - service_account: GoogleCloudKmsSignerServiceAccountResponseConfig { - project_id: c.service_account.project_id, - client_id: c.service_account.client_id, - auth_uri: c.service_account.auth_uri, - token_uri: c.service_account.token_uri, - auth_provider_x509_cert_url: c.service_account.auth_provider_x509_cert_url, - client_x509_cert_url: c.service_account.client_x509_cert_url, - universe_domain: c.service_account.universe_domain, - has_private_key: !c.service_account.private_key.is_empty(), - has_private_key_id: !c.service_account.private_key_id.is_empty(), - has_client_email: !c.service_account.client_email.is_empty(), - }, - key: GoogleCloudKmsSignerKeyResponseConfig { - location: c.key.location, - key_ring_id: c.key.key_ring_id, - key_id: c.key.key_id, - key_version: c.key.key_version, - }, - } - } + SignerConfig::VaultCloud(c) => SignerConfigResponse::VaultCloud { + client_id: c.client_id, + org_id: c.org_id, + project_id: c.project_id, + app_name: c.app_name, + key_name: c.key_name, + has_client_secret: !c.client_secret.is_empty(), + }, + SignerConfig::VaultTransit(c) => SignerConfigResponse::VaultTransit { + key_name: c.key_name, + address: c.address, + namespace: c.namespace, + pubkey: c.pubkey, + mount_point: c.mount_point, + has_role_id: !c.role_id.is_empty(), + has_secret_id: !c.secret_id.is_empty(), + }, + SignerConfig::AwsKms(c) => SignerConfigResponse::AwsKms { + region: c.region, + key_id: c.key_id, + }, + SignerConfig::Turnkey(c) => SignerConfigResponse::Turnkey { + api_public_key: c.api_public_key, + organization_id: c.organization_id, + private_key_id: c.private_key_id, + public_key: c.public_key, + has_api_private_key: !c.api_private_key.is_empty(), + }, + SignerConfig::GoogleCloudKms(c) => SignerConfigResponse::GoogleCloudKms { + service_account: GoogleCloudKmsSignerServiceAccountResponseConfig { + project_id: c.service_account.project_id, + client_id: c.service_account.client_id, + auth_uri: c.service_account.auth_uri, + token_uri: c.service_account.token_uri, + auth_provider_x509_cert_url: c.service_account.auth_provider_x509_cert_url, + client_x509_cert_url: c.service_account.client_x509_cert_url, + universe_domain: c.service_account.universe_domain, + has_private_key: !c.service_account.private_key.is_empty(), + has_private_key_id: !c.service_account.private_key_id.is_empty(), + has_client_email: !c.service_account.client_email.is_empty(), + }, + key: GoogleCloudKmsSignerKeyResponseConfig { + location: c.key.location, + key_ring_id: c.key.key_ring_id, + key_id: c.key.key_id, + key_version: c.key.key_version, + }, + }, } } } @@ -173,10 +161,6 @@ pub struct SignerResponse { pub id: String, /// The type of signer (local, aws_kms, google_cloud_kms, vault, etc.) pub r#type: SignerType, - /// Optional human-readable name for the signer - pub name: Option, - /// Optional description of the signer's purpose - pub description: Option, /// Non-secret configuration details pub config: SignerConfigResponse, } @@ -189,8 +173,6 @@ impl From for SignerResponse { Self { id: domain_signer.id.clone(), r#type: domain_signer.signer_type(), - name: domain_signer.name, - description: domain_signer.description, config: SignerConfigResponse::from(domain_signer.config), } } @@ -201,8 +183,6 @@ impl From for SignerResponse { Self { id: signer.id.clone(), r#type: signer.signer_type(), - name: signer.name, - description: signer.description, config: SignerConfigResponse::from(signer.config), } } @@ -227,11 +207,10 @@ mod tests { assert_eq!(response.id, "test-signer"); assert_eq!(response.r#type, SignerType::Local); - assert_eq!(response.name, None); - assert_eq!(response.description, None); - assert_eq!(response.config, SignerConfigResponse::Plain { - has_key: true, - }); + assert_eq!( + response.config, + SignerConfigResponse::Plain { has_key: true } + ); } #[test] @@ -246,19 +225,12 @@ mod tests { let signer = crate::models::Signer::new( "domain-signer".to_string(), SignerConfig::AwsKms(aws_config), - Some("AWS KMS Signer".to_string()), - Some("Production AWS KMS signer".to_string()), ); let response = SignerResponse::from(signer); assert_eq!(response.id, "domain-signer"); assert_eq!(response.r#type, SignerType::AwsKms); - assert_eq!(response.name, Some("AWS KMS Signer".to_string())); - assert_eq!( - response.description, - Some("Production AWS KMS signer".to_string()) - ); assert_eq!( response.config, SignerConfigResponse::AwsKms { @@ -276,9 +248,7 @@ mod tests { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), SignerType::Local, - SignerConfigResponse::Plain { - has_key: true, - }, + SignerConfigResponse::Plain { has_key: true }, ), ( SignerConfig::AwsKms(crate::models::AwsKmsSignerConfig { @@ -314,17 +284,12 @@ mod tests { let response = SignerResponse { id: "test-signer".to_string(), r#type: SignerType::Local, - name: Some("Test Signer".to_string()), - description: Some("A test signer".to_string()), - config: SignerConfigResponse::Plain { - has_key: true, - }, + config: SignerConfigResponse::Plain { has_key: true }, }; let json = serde_json::to_string(&response).unwrap(); assert!(json.contains("\"id\":\"test-signer\"")); assert!(json.contains("\"type\":\"local\"")); - assert!(json.contains("\"name\":\"Test Signer\"")); assert!(json.contains("\"has_key\":true")); // Updated to match actual format } @@ -333,8 +298,6 @@ mod tests { let json = r#"{ "id": "test-signer", "type": "aws_kms", - "name": "AWS KMS Signer", - "description": "Production signer", "config": { "region": "us-east-1", "key_id": "test-key-id" @@ -344,8 +307,6 @@ mod tests { let response: SignerResponse = serde_json::from_str(json).unwrap(); assert_eq!(response.id, "test-signer"); assert_eq!(response.r#type, SignerType::AwsKms); - assert_eq!(response.name, Some("AWS KMS Signer".to_string())); - assert_eq!(response.description, Some("Production signer".to_string())); assert_eq!( response.config, SignerConfigResponse::AwsKms { diff --git a/src/models/signer/signer.rs b/src/models/signer/signer.rs index 1980cf164..fea5cdb2b 100644 --- a/src/models/signer/signer.rs +++ b/src/models/signer/signer.rs @@ -11,10 +11,7 @@ //! The signer model supports multiple signer types including local keys, AWS KMS, //! Google Cloud KMS, Vault, and Turnkey service integrations. -use crate::{ - constants::ID_REGEX, - models::{SecretString, SignerUpdateRequest}, -}; +use crate::{constants::ID_REGEX, models::SecretString}; use secrets::SecretVec; use serde::{Deserialize, Serialize, Serializer}; use utoipa::ToSchema; @@ -39,11 +36,11 @@ impl LocalSignerConfig { /// Validates the raw key for cryptographic requirements pub fn validate(&self) -> Result<(), SignerValidationError> { let key_bytes = self.raw_key.borrow(); - + // Check key length - must be exactly 32 bytes for crypto operations if key_bytes.len() != 32 { return Err(SignerValidationError::InvalidConfig(format!( - "Raw key must be exactly 32 bytes, got {} bytes", + "Raw key must be exactly 32 bytes, got {} bytes", key_bytes.len() ))); } @@ -51,10 +48,10 @@ impl LocalSignerConfig { // Check if key is all zeros (cryptographically invalid) if key_bytes.iter().all(|&b| b == 0) { return Err(SignerValidationError::InvalidConfig( - "Raw key cannot be all zeros".to_string() + "Raw key cannot be all zeros".to_string(), )); } - + Ok(()) } } @@ -397,8 +394,6 @@ pub struct Signer { )] pub id: String, pub config: SignerConfig, - pub name: Option, - pub description: Option, } /// Signer type enum used for validation and API responses @@ -420,18 +415,8 @@ pub enum SignerType { impl Signer { /// Creates a new signer with configuration - pub fn new( - id: String, - config: SignerConfig, - name: Option, - description: Option, - ) -> Self { - Self { - id, - config, - name, - description, - } + pub fn new(id: String, config: SignerConfig) -> Self { + Self { id, config } } /// Gets the signer type from the configuration @@ -464,50 +449,6 @@ impl Signer { Ok(()) } - - /// Applies an update request to create a new validated signer - /// - /// This method provides a domain-first approach where the core model handles - /// its own business rules and validation rather than having update logic - /// scattered across request models. - /// - /// # Arguments - /// * `request` - The update request containing partial data to apply - /// - /// # Returns - /// * `Ok(Signer)` - A new validated signer with updates applied - /// * `Err(SignerValidationError)` - If the resulting signer would be invalid - pub fn apply_update( - &self, - request: &SignerUpdateRequest, - ) -> Result { - let mut updated = self.clone(); - - // Apply updates from request (only metadata fields can be updated) - if let Some(name) = &request.name { - updated.name = if name.trim().is_empty() { - None - } else { - Some(name.clone()) - }; - } - - if let Some(description) = &request.description { - updated.description = if description.trim().is_empty() { - None - } else { - Some(description.clone()) - }; - } - - // Note: config is immutable after creation for security reasons - // If someone needs to change the signer config, they should create a new signer - - // Validate the updated model - updated.validate()?; - - Ok(updated) - } } /// Validation errors for signers @@ -517,10 +458,6 @@ pub enum SignerValidationError { EmptyId, #[error("Signer ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long")] InvalidIdFormat, - #[error("Signer name cannot be empty when provided")] - EmptyName, - #[error("Signer description cannot be empty when provided")] - EmptyDescription, #[error("Invalid signer configuration: {0}")] InvalidConfig(String), } @@ -535,8 +472,6 @@ impl From for crate::models::ApiError { SignerValidationError::InvalidIdFormat => { "ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long".to_string() } - SignerValidationError::EmptyName => "Name cannot be empty when provided".to_string(), - SignerValidationError::EmptyDescription => "Description cannot be empty when provided".to_string(), SignerValidationError::InvalidConfig(msg) => format!("Invalid signer configuration: {}", msg), }) } @@ -552,12 +487,7 @@ mod tests { raw_key: SecretVec::new(32, |v| v.fill(1)), }); - let signer = Signer::new( - "valid-id".to_string(), - config, - Some("Test Signer".to_string()), - Some("A test signer for development".to_string()), - ); + let signer = Signer::new("valid-id".to_string(), config); assert!(signer.validate().is_ok()); assert_eq!(signer.signer_type(), SignerType::Local); @@ -570,7 +500,7 @@ mod tests { key_id: "test-key-id".to_string(), }); - let signer = Signer::new("aws-signer".to_string(), config, None, None); + let signer = Signer::new("aws-signer".to_string(), config); assert!(signer.validate().is_ok()); assert_eq!(signer.signer_type(), SignerType::AwsKms); @@ -582,7 +512,7 @@ mod tests { raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key }); - let signer = Signer::new("".to_string(), config, None, None); + let signer = Signer::new("".to_string(), config); assert!(matches!( signer.validate(), @@ -596,7 +526,7 @@ mod tests { raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key }); - let signer = Signer::new("a".repeat(37), config, None, None); + let signer = Signer::new("a".repeat(37), config); assert!(matches!( signer.validate(), @@ -610,7 +540,7 @@ mod tests { raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key }); - let signer = Signer::new("invalid@id".to_string(), config, None, None); + let signer = Signer::new("invalid@id".to_string(), config); assert!(matches!( signer.validate(), @@ -624,7 +554,7 @@ mod tests { raw_key: SecretVec::new(16, |v| v.fill(1)), // Invalid length: 16 bytes instead of 32 }); - let signer = Signer::new("valid-id".to_string(), config, None, None); + let signer = Signer::new("valid-id".to_string(), config); let result = signer.validate(); assert!(result.is_err()); @@ -642,7 +572,7 @@ mod tests { raw_key: SecretVec::new(32, |v| v.fill(0)), // Invalid: all zeros }); - let signer = Signer::new("valid-id".to_string(), config, None, None); + let signer = Signer::new("valid-id".to_string(), config); let result = signer.validate(); assert!(result.is_err()); @@ -659,67 +589,11 @@ mod tests { raw_key: SecretVec::new(32, |v| v.fill(1)), // Valid: 32 bytes, non-zero }); - let signer = Signer::new("valid-id".to_string(), config, None, None); + let signer = Signer::new("valid-id".to_string(), config); assert!(signer.validate().is_ok()); } - #[test] - fn test_apply_update_success() { - let config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(32, |v| v.fill(1)), - }); - - let original = Signer::new( - "test-id".to_string(), - config, - Some("Original Name".to_string()), - None, - ); - - let update_request = SignerUpdateRequest { - name: Some("Updated Name".to_string()), - description: Some("Updated description".to_string()), - }; - - let result = original.apply_update(&update_request); - assert!(result.is_ok()); - - let updated = result.unwrap(); - assert_eq!(updated.id, "test-id"); // ID should remain unchanged - assert_eq!(updated.signer_type(), SignerType::Local); // Type should remain unchanged - assert_eq!(updated.name, Some("Updated Name".to_string())); // Name updated - assert_eq!(updated.description, Some("Updated description".to_string())); - // Description updated - } - - #[test] - fn test_apply_update_clear_fields() { - let config = SignerConfig::AwsKms(AwsKmsSignerConfig { - region: Some("us-east-1".to_string()), - key_id: "test-key".to_string(), - }); - - let original = Signer::new( - "test-id".to_string(), - config, - Some("Original Name".to_string()), - Some("Original description".to_string()), - ); - - let update_request = SignerUpdateRequest { - name: Some("".to_string()), // Empty string clears the field - description: Some(" ".to_string()), // Whitespace clears the field - }; - - let result = original.apply_update(&update_request); - assert!(result.is_ok()); - - let updated = result.unwrap(); - assert_eq!(updated.name, None); // Name cleared - assert_eq!(updated.description, None); // Description cleared - } - #[test] fn test_signer_type_serialization() { use serde_json::{from_str, to_string}; diff --git a/src/repositories/relayer/mod.rs b/src/repositories/relayer/mod.rs index 7547ea576..109bed663 100644 --- a/src/repositories/relayer/mod.rs +++ b/src/repositories/relayer/mod.rs @@ -37,6 +37,14 @@ use std::sync::Arc; #[async_trait] pub trait RelayerRepository: Repository + Send + Sync { async fn list_active(&self) -> Result, RepositoryError>; + async fn list_by_signer_id( + &self, + signer_id: &str, + ) -> Result, RepositoryError>; + async fn list_by_notification_id( + &self, + notification_id: &str, + ) -> Result, RepositoryError>; async fn partial_update( &self, id: String, @@ -166,6 +174,30 @@ impl RelayerRepository for RelayerRepositoryStorage { } } + async fn list_by_signer_id( + &self, + signer_id: &str, + ) -> Result, RepositoryError> { + match self { + RelayerRepositoryStorage::InMemory(repo) => repo.list_by_signer_id(signer_id).await, + RelayerRepositoryStorage::Redis(repo) => repo.list_by_signer_id(signer_id).await, + } + } + + async fn list_by_notification_id( + &self, + notification_id: &str, + ) -> Result, RepositoryError> { + match self { + RelayerRepositoryStorage::InMemory(repo) => { + repo.list_by_notification_id(notification_id).await + } + RelayerRepositoryStorage::Redis(repo) => { + repo.list_by_notification_id(notification_id).await + } + } + } + async fn partial_update( &self, id: String, @@ -426,6 +458,8 @@ mockall::mock! { #[async_trait] impl RelayerRepository for RelayerRepository { async fn list_active(&self) -> Result, RepositoryError>; + async fn list_by_signer_id(&self, signer_id: &str) -> Result, RepositoryError>; + async fn list_by_notification_id(&self, notification_id: &str) -> Result, RepositoryError>; async fn partial_update(&self, id: String, update: RelayerUpdateRequest) -> Result; async fn enable_relayer(&self, relayer_id: String) -> Result; async fn disable_relayer(&self, relayer_id: String) -> Result; diff --git a/src/repositories/relayer/relayer_in_memory.rs b/src/repositories/relayer/relayer_in_memory.rs index 18f791086..94dcbbb62 100644 --- a/src/repositories/relayer/relayer_in_memory.rs +++ b/src/repositories/relayer/relayer_in_memory.rs @@ -68,6 +68,37 @@ impl RelayerRepository for InMemoryRelayerRepository { Ok(active_relayers) } + async fn list_by_signer_id( + &self, + signer_id: &str, + ) -> Result, RepositoryError> { + let store = Self::acquire_lock(&self.store).await?; + let relayers_with_signer: Vec = store + .values() + .filter(|&relayer| relayer.signer_id == signer_id) + .cloned() + .collect(); + Ok(relayers_with_signer) + } + + async fn list_by_notification_id( + &self, + notification_id: &str, + ) -> Result, RepositoryError> { + let store = Self::acquire_lock(&self.store).await?; + let relayers_with_notification: Vec = store + .values() + .filter(|&relayer| { + relayer + .notification_id + .as_ref() + .map_or(false, |id| id == notification_id) + }) + .cloned() + .collect(); + Ok(relayers_with_notification) + } + async fn partial_update( &self, id: String, @@ -462,4 +493,303 @@ mod tests { repo.drop_all_entries().await.unwrap(); assert!(!repo.has_entries().await.unwrap()); } + + #[actix_web::test] + async fn test_list_by_signer_id() { + let repo = InMemoryRelayerRepository::new(); + + // Create test relayers with different signers + let relayer1 = RelayerRepoModel { + id: "relayer-1".to_string(), + name: "Relayer 1".to_string(), + network: "ethereum".to_string(), + paused: false, + network_type: NetworkType::Evm, + signer_id: "signer-alpha".to_string(), + policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()), + address: "0x1111".to_string(), + notification_id: None, + system_disabled: false, + custom_rpc_urls: None, + }; + + let relayer2 = RelayerRepoModel { + id: "relayer-2".to_string(), + name: "Relayer 2".to_string(), + network: "polygon".to_string(), + paused: true, + network_type: NetworkType::Evm, + signer_id: "signer-alpha".to_string(), // Same signer as relayer1 + policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()), + address: "0x2222".to_string(), + notification_id: None, + system_disabled: false, + custom_rpc_urls: None, + }; + + let relayer3 = RelayerRepoModel { + id: "relayer-3".to_string(), + name: "Relayer 3".to_string(), + network: "solana".to_string(), + paused: false, + network_type: NetworkType::Solana, + signer_id: "signer-beta".to_string(), // Different signer + policies: RelayerNetworkPolicy::Solana(crate::models::RelayerSolanaPolicy::default()), + address: "solana-addr".to_string(), + notification_id: None, + system_disabled: false, + custom_rpc_urls: None, + }; + + let relayer4 = RelayerRepoModel { + id: "relayer-4".to_string(), + name: "Relayer 4".to_string(), + network: "stellar".to_string(), + paused: false, + network_type: NetworkType::Stellar, + signer_id: "signer-alpha".to_string(), // Same signer as relayer1 and relayer2 + policies: RelayerNetworkPolicy::Stellar(crate::models::RelayerStellarPolicy::default()), + address: "stellar-addr".to_string(), + notification_id: Some("notification-1".to_string()), + system_disabled: true, + custom_rpc_urls: None, + }; + + // Add all relayers to the repository + repo.create(relayer1).await.unwrap(); + repo.create(relayer2).await.unwrap(); + repo.create(relayer3).await.unwrap(); + repo.create(relayer4).await.unwrap(); + + // Test: Find relayers with signer-alpha (should return 3: relayer-1, relayer-2, relayer-4) + let relayers_with_alpha = repo.list_by_signer_id("signer-alpha").await.unwrap(); + assert_eq!(relayers_with_alpha.len(), 3); + + let alpha_ids: Vec = relayers_with_alpha.iter().map(|r| r.id.clone()).collect(); + assert!(alpha_ids.contains(&"relayer-1".to_string())); + assert!(alpha_ids.contains(&"relayer-2".to_string())); + assert!(alpha_ids.contains(&"relayer-4".to_string())); + assert!(!alpha_ids.contains(&"relayer-3".to_string())); + + // Verify the relayers have different states (paused, system_disabled) + let relayer2_found = relayers_with_alpha + .iter() + .find(|r| r.id == "relayer-2") + .unwrap(); + let relayer4_found = relayers_with_alpha + .iter() + .find(|r| r.id == "relayer-4") + .unwrap(); + assert!(relayer2_found.paused); // Should be paused + assert!(relayer4_found.system_disabled); // Should be disabled + + // Test: Find relayers with signer-beta (should return 1: relayer-3) + let relayers_with_beta = repo.list_by_signer_id("signer-beta").await.unwrap(); + assert_eq!(relayers_with_beta.len(), 1); + assert_eq!(relayers_with_beta[0].id, "relayer-3"); + assert_eq!(relayers_with_beta[0].network_type, NetworkType::Solana); + + // Test: Find relayers with non-existent signer (should return empty) + let relayers_with_gamma = repo.list_by_signer_id("signer-gamma").await.unwrap(); + assert_eq!(relayers_with_gamma.len(), 0); + + // Test: Find relayers with empty signer ID (should return empty) + let relayers_with_empty = repo.list_by_signer_id("").await.unwrap(); + assert_eq!(relayers_with_empty.len(), 0); + + // Test: Verify total count hasn't changed + assert_eq!(repo.count().await.unwrap(), 4); + + // Test: Remove one relayer and verify list_by_signer_id updates correctly + repo.delete_by_id("relayer-2".to_string()).await.unwrap(); + + let relayers_with_alpha_after_delete = + repo.list_by_signer_id("signer-alpha").await.unwrap(); + assert_eq!(relayers_with_alpha_after_delete.len(), 2); // Should now be 2 instead of 3 + + let alpha_ids_after: Vec = relayers_with_alpha_after_delete + .iter() + .map(|r| r.id.clone()) + .collect(); + assert!(alpha_ids_after.contains(&"relayer-1".to_string())); + assert!(!alpha_ids_after.contains(&"relayer-2".to_string())); // Deleted + assert!(alpha_ids_after.contains(&"relayer-4".to_string())); + } + + #[actix_web::test] + async fn test_list_by_notification_id() { + let repo = InMemoryRelayerRepository::new(); + + // Create test relayers with different notifications + let relayer1 = RelayerRepoModel { + id: "relayer-1".to_string(), + name: "Relayer 1".to_string(), + network: "ethereum".to_string(), + paused: false, + network_type: NetworkType::Evm, + signer_id: "test-signer".to_string(), + policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()), + address: "0x1111".to_string(), + notification_id: Some("notification-alpha".to_string()), + system_disabled: false, + custom_rpc_urls: None, + }; + + let relayer2 = RelayerRepoModel { + id: "relayer-2".to_string(), + name: "Relayer 2".to_string(), + network: "polygon".to_string(), + paused: true, + network_type: NetworkType::Evm, + signer_id: "test-signer".to_string(), + policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()), + address: "0x2222".to_string(), + notification_id: Some("notification-alpha".to_string()), // Same notification as relayer1 + system_disabled: false, + custom_rpc_urls: None, + }; + + let relayer3 = RelayerRepoModel { + id: "relayer-3".to_string(), + name: "Relayer 3".to_string(), + network: "solana".to_string(), + paused: false, + network_type: NetworkType::Solana, + signer_id: "test-signer".to_string(), + policies: RelayerNetworkPolicy::Solana(crate::models::RelayerSolanaPolicy::default()), + address: "solana-addr".to_string(), + notification_id: Some("notification-beta".to_string()), // Different notification + system_disabled: false, + custom_rpc_urls: None, + }; + + let relayer4 = RelayerRepoModel { + id: "relayer-4".to_string(), + name: "Relayer 4".to_string(), + network: "stellar".to_string(), + paused: false, + network_type: NetworkType::Stellar, + signer_id: "test-signer".to_string(), + policies: RelayerNetworkPolicy::Stellar(crate::models::RelayerStellarPolicy::default()), + address: "stellar-addr".to_string(), + notification_id: None, // No notification + system_disabled: true, + custom_rpc_urls: None, + }; + + let relayer5 = RelayerRepoModel { + id: "relayer-5".to_string(), + name: "Relayer 5".to_string(), + network: "bsc".to_string(), + paused: false, + network_type: NetworkType::Evm, + signer_id: "test-signer".to_string(), + policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()), + address: "0x5555".to_string(), + notification_id: Some("notification-alpha".to_string()), // Same notification as relayer1 and relayer2 + system_disabled: false, + custom_rpc_urls: None, + }; + + // Add all relayers to the repository + repo.create(relayer1).await.unwrap(); + repo.create(relayer2).await.unwrap(); + repo.create(relayer3).await.unwrap(); + repo.create(relayer4).await.unwrap(); + repo.create(relayer5).await.unwrap(); + + // Test: Find relayers with notification-alpha (should return 3: relayer-1, relayer-2, relayer-5) + let relayers_with_alpha = repo + .list_by_notification_id("notification-alpha") + .await + .unwrap(); + assert_eq!(relayers_with_alpha.len(), 3); + + let alpha_ids: Vec = relayers_with_alpha.iter().map(|r| r.id.clone()).collect(); + assert!(alpha_ids.contains(&"relayer-1".to_string())); + assert!(alpha_ids.contains(&"relayer-2".to_string())); + assert!(alpha_ids.contains(&"relayer-5".to_string())); + assert!(!alpha_ids.contains(&"relayer-3".to_string())); + assert!(!alpha_ids.contains(&"relayer-4".to_string())); + + // Verify the relayers have different states (paused, different networks) + let relayer2_found = relayers_with_alpha + .iter() + .find(|r| r.id == "relayer-2") + .unwrap(); + let relayer5_found = relayers_with_alpha + .iter() + .find(|r| r.id == "relayer-5") + .unwrap(); + assert!(relayer2_found.paused); // Should be paused + assert_eq!(relayer5_found.network, "bsc"); // Should be on BSC network + + // Test: Find relayers with notification-beta (should return 1: relayer-3) + let relayers_with_beta = repo + .list_by_notification_id("notification-beta") + .await + .unwrap(); + assert_eq!(relayers_with_beta.len(), 1); + assert_eq!(relayers_with_beta[0].id, "relayer-3"); + assert_eq!(relayers_with_beta[0].network_type, NetworkType::Solana); + + // Test: Find relayers with non-existent notification (should return empty) + let relayers_with_gamma = repo + .list_by_notification_id("notification-gamma") + .await + .unwrap(); + assert_eq!(relayers_with_gamma.len(), 0); + + // Test: Find relayers with empty string notification (should return empty) + let relayers_with_empty = repo.list_by_notification_id("").await.unwrap(); + assert_eq!(relayers_with_empty.len(), 0); + + // Test: Verify total count hasn't changed + assert_eq!(repo.count().await.unwrap(), 5); + + // Test: Remove one relayer and verify list_by_notification_id updates correctly + repo.delete_by_id("relayer-2".to_string()).await.unwrap(); + + let relayers_with_alpha_after_delete = repo + .list_by_notification_id("notification-alpha") + .await + .unwrap(); + assert_eq!(relayers_with_alpha_after_delete.len(), 2); // Should now be 2 instead of 3 + + let alpha_ids_after: Vec = relayers_with_alpha_after_delete + .iter() + .map(|r| r.id.clone()) + .collect(); + assert!(alpha_ids_after.contains(&"relayer-1".to_string())); + assert!(!alpha_ids_after.contains(&"relayer-2".to_string())); // Deleted + assert!(alpha_ids_after.contains(&"relayer-5".to_string())); + + // Test: Update a relayer's notification and verify the lists update correctly + let mut updated_relayer = repo.get_by_id("relayer-5".to_string()).await.unwrap(); + updated_relayer.notification_id = Some("notification-beta".to_string()); + repo.update("relayer-5".to_string(), updated_relayer) + .await + .unwrap(); + + // Check notification-alpha list again (should now have only relayer-1) + let relayers_with_alpha_final = repo + .list_by_notification_id("notification-alpha") + .await + .unwrap(); + assert_eq!(relayers_with_alpha_final.len(), 1); + assert_eq!(relayers_with_alpha_final[0].id, "relayer-1"); + + // Check notification-beta list (should now have relayer-3 and relayer-5) + let relayers_with_beta_final = repo + .list_by_notification_id("notification-beta") + .await + .unwrap(); + assert_eq!(relayers_with_beta_final.len(), 2); + let beta_ids_final: Vec = relayers_with_beta_final + .iter() + .map(|r| r.id.clone()) + .collect(); + assert!(beta_ids_final.contains(&"relayer-3".to_string())); + assert!(beta_ids_final.contains(&"relayer-5".to_string())); + } } diff --git a/src/repositories/relayer/relayer_redis.rs b/src/repositories/relayer/relayer_redis.rs index 7b28d5521..fb997bb49 100644 --- a/src/repositories/relayer/relayer_redis.rs +++ b/src/repositories/relayer/relayer_redis.rs @@ -438,6 +438,47 @@ impl RelayerRepository for RedisRelayerRepository { Ok(active_relayers) } + async fn list_by_signer_id( + &self, + signer_id: &str, + ) -> Result, RepositoryError> { + let all_relayers = self.list_all().await?; + let relayers_with_signer: Vec = all_relayers + .into_iter() + .filter(|relayer| relayer.signer_id == signer_id) + .collect(); + + debug!( + "Found {} relayers using signer '{}'", + relayers_with_signer.len(), + signer_id + ); + Ok(relayers_with_signer) + } + + async fn list_by_notification_id( + &self, + notification_id: &str, + ) -> Result, RepositoryError> { + let all_relayers = self.list_all().await?; + let relayers_with_notification: Vec = all_relayers + .into_iter() + .filter(|relayer| { + relayer + .notification_id + .as_ref() + .map_or(false, |id| id == notification_id) + }) + .collect(); + + debug!( + "Found {} relayers using notification '{}'", + relayers_with_notification.len(), + notification_id + ); + Ok(relayers_with_notification) + } + async fn partial_update( &self, id: String, @@ -956,4 +997,82 @@ mod tests { repo.drop_all_entries().await.unwrap(); assert!(!repo.has_entries().await.unwrap()); } + + #[ignore = "Requires active Redis instance"] + #[tokio::test] + async fn test_list_by_signer_id() { + let repo = setup_test_repo().await; + + let relayer1_id = uuid::Uuid::new_v4().to_string(); + let relayer2_id = uuid::Uuid::new_v4().to_string(); + let relayer3_id = uuid::Uuid::new_v4().to_string(); + let signer1_id = uuid::Uuid::new_v4().to_string(); + let signer2_id = uuid::Uuid::new_v4().to_string(); + + let mut relayer1 = create_test_relayer(&relayer1_id); + relayer1.signer_id = signer1_id.clone(); + repo.create(relayer1).await.unwrap(); + + let mut relayer2 = create_test_relayer(&relayer2_id); + + relayer2.signer_id = signer2_id.clone(); + repo.create(relayer2).await.unwrap(); + + let mut relayer3 = create_test_relayer(&relayer3_id); + relayer3.signer_id = signer1_id.clone(); + repo.create(relayer3).await.unwrap(); + + let result = repo.list_by_signer_id(&signer1_id).await.unwrap(); + assert_eq!(result.len(), 2); + let ids: Vec<_> = result.iter().map(|r| r.id.clone()).collect(); + assert!(ids.contains(&relayer1_id)); + assert!(ids.contains(&relayer3_id)); + + let result = repo.list_by_signer_id(&signer2_id).await.unwrap(); + assert_eq!(result.len(), 1); + + let result = repo + .list_by_signer_id(&"nonexistent".to_string()) + .await + .unwrap(); + assert_eq!(result.len(), 0); + } + + #[ignore = "Requires active Redis instance"] + #[tokio::test] + async fn test_list_by_notification_id() { + let repo = setup_test_repo().await; + + let relayer1_id = uuid::Uuid::new_v4().to_string(); + let mut relayer1 = create_test_relayer(&relayer1_id); + relayer1.notification_id = Some("notif1".to_string()); + repo.create(relayer1).await.unwrap(); + + let relayer2_id = uuid::Uuid::new_v4().to_string(); + let mut relayer2 = create_test_relayer(&relayer2_id); + relayer2.notification_id = Some("notif2".to_string()); + repo.create(relayer2).await.unwrap(); + + let relayer3_id = uuid::Uuid::new_v4().to_string(); + let mut relayer3 = create_test_relayer(&relayer3_id); + relayer3.notification_id = Some("notif1".to_string()); + repo.create(relayer3).await.unwrap(); + + let relayer4_id = uuid::Uuid::new_v4().to_string(); + let mut relayer4 = create_test_relayer(&relayer4_id); + relayer4.notification_id = None; + repo.create(relayer4).await.unwrap(); + + let result = repo.list_by_notification_id("notif1").await.unwrap(); + assert_eq!(result.len(), 2); + let ids: Vec<_> = result.iter().map(|r| r.id.clone()).collect(); + assert!(ids.contains(&relayer1_id)); + assert!(ids.contains(&relayer3_id)); + + let result = repo.list_by_notification_id("notif2").await.unwrap(); + assert_eq!(result.len(), 1); + + let result = repo.list_by_notification_id("nonexistent").await.unwrap(); + assert_eq!(result.len(), 0); + } } From f7163933ec10edc496bbce1f24195886a60fdc3d Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 16 Jul 2025 23:35:54 +0200 Subject: [PATCH 16/59] chore: refactor --- src/api/controllers/mod.rs | 4 ++-- .../{notifications.rs => notification.rs} | 0 src/api/controllers/{signers.rs => signer.rs} | 0 src/api/routes/mod.rs | 10 +++++----- src/api/routes/{networks.rs => network.rs} | 0 src/api/routes/{notifications.rs => notification.rs} | 12 ++++++------ src/api/routes/{signers.rs => signer.rs} | 12 ++++++------ 7 files changed, 19 insertions(+), 19 deletions(-) rename src/api/controllers/{notifications.rs => notification.rs} (100%) rename src/api/controllers/{signers.rs => signer.rs} (100%) rename src/api/routes/{networks.rs => network.rs} (100%) rename src/api/routes/{notifications.rs => notification.rs} (91%) rename src/api/routes/{signers.rs => signer.rs} (95%) diff --git a/src/api/controllers/mod.rs b/src/api/controllers/mod.rs index d762acfa0..3bbe9e932 100644 --- a/src/api/controllers/mod.rs +++ b/src/api/controllers/mod.rs @@ -9,7 +9,7 @@ //! * `notifications` - Notification management endpoints //! * `signers` - Signer management endpoints -pub mod notifications; +pub mod notification; pub mod plugin; pub mod relayer; -pub mod signers; +pub mod signer; diff --git a/src/api/controllers/notifications.rs b/src/api/controllers/notification.rs similarity index 100% rename from src/api/controllers/notifications.rs rename to src/api/controllers/notification.rs diff --git a/src/api/controllers/signers.rs b/src/api/controllers/signer.rs similarity index 100% rename from src/api/controllers/signers.rs rename to src/api/controllers/signer.rs diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs index 95e267647..f5346f2a0 100644 --- a/src/api/routes/mod.rs +++ b/src/api/routes/mod.rs @@ -12,11 +12,11 @@ pub mod docs; pub mod health; pub mod metrics; -pub mod networks; -pub mod notifications; +pub mod network; +pub mod notification; pub mod plugin; pub mod relayer; -pub mod signers; +pub mod signer; use actix_web::web; pub fn configure_routes(cfg: &mut web::ServiceConfig) { @@ -24,6 +24,6 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { .configure(relayer::init) .configure(plugin::init) .configure(metrics::init) - .configure(notifications::init) - .configure(signers::init); + .configure(notification::init) + .configure(signer::init); } diff --git a/src/api/routes/networks.rs b/src/api/routes/network.rs similarity index 100% rename from src/api/routes/networks.rs rename to src/api/routes/network.rs diff --git a/src/api/routes/notifications.rs b/src/api/routes/notification.rs similarity index 91% rename from src/api/routes/notifications.rs rename to src/api/routes/notification.rs index ccb6971e3..e8dcee1b3 100644 --- a/src/api/routes/notifications.rs +++ b/src/api/routes/notification.rs @@ -3,7 +3,7 @@ //! The routes are integrated with the Actix-web framework and interact with the notification controller. use crate::{ - api::controllers::notifications, + api::controllers::notification, models::{ DefaultAppState, NotificationCreateRequest, NotificationUpdateRequest, PaginationQuery, }, @@ -16,7 +16,7 @@ async fn list_notifications( query: web::Query, data: web::ThinData, ) -> impl Responder { - notifications::list_notifications(query.into_inner(), data).await + notification::list_notifications(query.into_inner(), data).await } /// Retrieves details of a specific notification by ID. @@ -25,7 +25,7 @@ async fn get_notification( notification_id: web::Path, data: web::ThinData, ) -> impl Responder { - notifications::get_notification(notification_id.into_inner(), data).await + notification::get_notification(notification_id.into_inner(), data).await } /// Creates a new notification. @@ -34,7 +34,7 @@ async fn create_notification( request: web::Json, data: web::ThinData, ) -> impl Responder { - notifications::create_notification(request.into_inner(), data).await + notification::create_notification(request.into_inner(), data).await } /// Updates an existing notification. @@ -44,7 +44,7 @@ async fn update_notification( request: web::Json, data: web::ThinData, ) -> impl Responder { - notifications::update_notification(notification_id.into_inner(), request.into_inner(), data) + notification::update_notification(notification_id.into_inner(), request.into_inner(), data) .await } @@ -54,7 +54,7 @@ async fn delete_notification( notification_id: web::Path, data: web::ThinData, ) -> impl Responder { - notifications::delete_notification(notification_id.into_inner(), data).await + notification::delete_notification(notification_id.into_inner(), data).await } /// Configures the notification routes. diff --git a/src/api/routes/signers.rs b/src/api/routes/signer.rs similarity index 95% rename from src/api/routes/signers.rs rename to src/api/routes/signer.rs index 49d9b58f3..28c982f86 100644 --- a/src/api/routes/signers.rs +++ b/src/api/routes/signer.rs @@ -3,7 +3,7 @@ //! The routes are integrated with the Actix-web framework and interact with the signer controller. use crate::{ - api::controllers::signers, + api::controllers::signer, models::{DefaultAppState, PaginationQuery, SignerCreateRequest, SignerUpdateRequest}, }; use actix_web::{delete, get, patch, post, web, Responder}; @@ -14,7 +14,7 @@ async fn list_signers( query: web::Query, data: web::ThinData, ) -> impl Responder { - signers::list_signers(query.into_inner(), data).await + signer::list_signers(query.into_inner(), data).await } /// Retrieves details of a specific signer by ID. @@ -23,7 +23,7 @@ async fn get_signer( signer_id: web::Path, data: web::ThinData, ) -> impl Responder { - signers::get_signer(signer_id.into_inner(), data).await + signer::get_signer(signer_id.into_inner(), data).await } /// Creates a new signer. @@ -32,7 +32,7 @@ async fn create_signer( request: web::Json, data: web::ThinData, ) -> impl Responder { - signers::create_signer(request.into_inner(), data).await + signer::create_signer(request.into_inner(), data).await } /// Updates an existing signer. @@ -42,7 +42,7 @@ async fn update_signer( request: web::Json, data: web::ThinData, ) -> impl Responder { - signers::update_signer(signer_id.into_inner(), request.into_inner(), data).await + signer::update_signer(signer_id.into_inner(), request.into_inner(), data).await } /// Deletes a signer by ID. @@ -51,7 +51,7 @@ async fn delete_signer( signer_id: web::Path, data: web::ThinData, ) -> impl Responder { - signers::delete_signer(signer_id.into_inner(), data).await + signer::delete_signer(signer_id.into_inner(), data).await } /// Configures the signer routes. From 9510190f6099bcf90cac6741018a91166944e1f6 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Thu, 17 Jul 2025 09:03:17 +0200 Subject: [PATCH 17/59] chore: impr --- src/api/controllers/signer.rs | 2 +- src/api/routes/signer.rs | 108 ------------ src/bootstrap/config_processor.rs | 8 +- src/config/config_file/mod.rs | 17 +- src/models/signer/config.rs | 81 ++++++++- src/models/signer/request.rs | 155 +++++++++++++++++ src/models/signer/response.rs | 51 +++--- src/models/signer/signer.rs | 278 +++++++++++++++++++++++++++++- src/services/signer/evm/mod.rs | 2 +- src/services/signer/solana/mod.rs | 18 -- 10 files changed, 540 insertions(+), 180 deletions(-) diff --git a/src/api/controllers/signer.rs b/src/api/controllers/signer.rs index 71a40494a..0451c01a5 100644 --- a/src/api/controllers/signer.rs +++ b/src/api/controllers/signer.rs @@ -347,7 +347,7 @@ mod tests { let data = api_response.data.unwrap(); assert_eq!(data.len(), 2); - // Check that both signers are present (order not guaranteed) + // Check that both signers are present let ids: Vec<&String> = data.iter().map(|s| &s.id).collect(); assert!(ids.contains(&&"test-1".to_string())); assert!(ids.contains(&&"test-2".to_string())); diff --git a/src/api/routes/signer.rs b/src/api/routes/signer.rs index 28c982f86..f839a42bc 100644 --- a/src/api/routes/signer.rs +++ b/src/api/routes/signer.rs @@ -140,112 +140,4 @@ mod tests { "DELETE /signers/{{id}} route not registered" ); } - - #[actix_web::test] - async fn test_signer_id_path_parameter_extraction() { - let app_state = create_mock_app_state(None, None, None, None, None).await; - let app = test::init_service( - App::new() - .app_data(web::Data::new(app_state)) - .configure(init), - ) - .await; - - // Test various signer ID formats - let test_ids = vec![ - "simple-id", - "id-with-dashes", - "id_with_underscores", - "1234567890", - "mixed-id_123", - ]; - - for signer_id in test_ids { - // Test GET with various ID formats - let req = test::TestRequest::get() - .uri(&format!("/signers/{}", signer_id)) - .to_request(); - let resp = test::call_service(&app, req).await; - - // Should not be NOT_FOUND due to route configuration - // (may be other errors like signer not found, but route should exist) - assert_ne!( - resp.status(), - StatusCode::NOT_FOUND, - "Route not found for signer ID: {}", - signer_id - ); - - // Test DELETE with various ID formats - let req = test::TestRequest::delete() - .uri(&format!("/signers/{}", signer_id)) - .to_request(); - let resp = test::call_service(&app, req).await; - - assert_ne!( - resp.status(), - StatusCode::NOT_FOUND, - "DELETE route not found for signer ID: {}", - signer_id - ); - } - } - - #[actix_web::test] - async fn test_json_request_parsing() { - let app_state = create_mock_app_state(None, None, None, None, None).await; - let app = test::init_service( - App::new() - .app_data(web::Data::new(app_state)) - .configure(init), - ) - .await; - - // Test POST /signers with valid JSON - let create_request = serde_json::json!({ - "id": "test-signer", - "signer_type": "test", - "name": "Test Signer", - "description": "A test signer for development" - }); - - let req = test::TestRequest::post() - .uri("/signers") - .set_json(&create_request) - .to_request(); - let resp = test::call_service(&app, req).await; - - // Should not return 404 (route exists) or 400 for JSON parsing issues - assert_ne!(resp.status(), StatusCode::NOT_FOUND); - // JSON should parse correctly (business logic errors are separate) - - // Test PATCH /signers/{id} with valid JSON - let update_request = serde_json::json!({ - "name": "Updated Signer Name", - "description": "Updated description" - }); - - let req = test::TestRequest::patch() - .uri("/signers/test-id") - .set_json(&update_request) - .to_request(); - let resp = test::call_service(&app, req).await; - - assert_ne!(resp.status(), StatusCode::NOT_FOUND); - // JSON should parse correctly - - // Test POST with minimal valid JSON - let minimal_request = serde_json::json!({ - "signer_type": "test" - }); - - let req = test::TestRequest::post() - .uri("/signers") - .set_json(&minimal_request) - .to_request(); - let resp = test::call_service(&app, req).await; - - assert_ne!(resp.status(), StatusCode::NOT_FOUND); - // Should parse JSON successfully - } } diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index c0abb13cd..96e4144e7 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -1067,11 +1067,9 @@ mod tests { // Create test signers, relayers, and notifications let signers = vec![SignerFileConfig { id: "test-signer-1".to_string(), - config: SignerFileConfigEnum::Local(LocalSignerFileConfig { - path: "test-path".to_string(), - passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("test-passphrase"), - }, + config: SignerFileConfigEnum::AwsKms(AwsKmsSignerFileConfig { + region: "us-east-1".to_string(), + key_id: "test-key-id".to_string(), }), }]; diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index 2f46db2ed..b6245ac55 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -91,7 +91,7 @@ impl Config { self.validate_notifications()?; self.validate_plugins()?; - self.validate_relayer_signer_refs(&self.networks)?; + self.validate_relayer_signer_refs()?; self.validate_relayer_notification_refs()?; Ok(()) @@ -105,10 +105,7 @@ impl Config { /// # Errors /// Returns a `ConfigFileError::InvalidReference` if a relayer references a non-existent signer. /// Returns a `ConfigFileError::TestSigner` if a test signer is used on a production network. - fn validate_relayer_signer_refs( - &self, - networks: &NetworksFileConfig, - ) -> Result<(), ConfigFileError> { + fn validate_relayer_signer_refs(&self) -> Result<(), ConfigFileError> { let signer_ids: HashSet<_> = self.signers.iter().map(|s| &s.id).collect(); for relayer in &self.relayers { @@ -118,16 +115,6 @@ impl Config { relayer.id, relayer.signer_id ))); } - let signer_config = self - .signers - .iter() - .find(|s| s.id == relayer.signer_id) - .ok_or_else(|| { - ConfigFileError::InternalError(format!( - "Signer '{}' not found for relayer '{}'", - relayer.signer_id, relayer.id - )) - })?; } Ok(()) diff --git a/src/models/signer/config.rs b/src/models/signer/config.rs index b825d82e2..9453ca3d9 100644 --- a/src/models/signer/config.rs +++ b/src/models/signer/config.rs @@ -464,9 +464,8 @@ impl TryFrom for Signer { #[cfg(test)] mod tests { - use crate::models::SecretString; - use super::*; + use crate::models::SecretString; #[test] fn test_aws_kms_conversion() { @@ -587,4 +586,82 @@ mod tests { Err(ConfigFileError::DuplicateId(_)) )); } + + #[test] + fn test_local_conversion_invalid_path() { + let config = LocalSignerFileConfig { + path: "non-existent-path".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test-passphrase"), + }, + }; + + let result = LocalSignerConfig::try_from(config); + assert!(result.is_err()); + if let Err(ConfigFileError::FileNotFound(msg)) = result { + assert!(msg.contains("Signer file not found")); + } else { + panic!("Expected FileNotFound error"); + } + } + + #[test] + fn test_vault_conversion() { + let config = VaultSignerFileConfig { + address: "https://vault.example.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: PlainOrEnvValue::Plain { + value: SecretString::new("test-role"), + }, + secret_id: PlainOrEnvValue::Plain { + value: SecretString::new("test-secret"), + }, + key_name: "test-key".to_string(), + mount_point: Some("test-mount".to_string()), + }; + + let result = VaultSignerConfig::try_from(config); + assert!(result.is_ok()); + + let vault_config = result.unwrap(); + assert_eq!(vault_config.address, "https://vault.example.com"); + assert_eq!(vault_config.namespace, Some("test-namespace".to_string())); + } + + #[test] + fn test_google_cloud_kms_conversion() { + let config = GoogleCloudKmsSignerFileConfig { + service_account: GoogleCloudKmsServiceAccountFileConfig { + project_id: "test-project".to_string(), + private_key_id: PlainOrEnvValue::Plain { + value: SecretString::new("test-key-id"), + }, + private_key: PlainOrEnvValue::Plain { + value: SecretString::new("test-private-key"), + }, + client_email: PlainOrEnvValue::Plain { + value: SecretString::new("test@email.com"), + }, + client_id: "test-client-id".to_string(), + auth_uri: google_cloud_default_auth_uri(), + token_uri: google_cloud_default_token_uri(), + auth_provider_x509_cert_url: google_cloud_default_auth_provider_x509_cert_url(), + client_x509_cert_url: google_cloud_default_client_x509_cert_url(), + universe_domain: google_cloud_default_universe_domain(), + }, + key: GoogleCloudKmsKeyFileConfig { + location: google_cloud_default_location(), + key_ring_id: "test-ring".to_string(), + key_id: "test-key".to_string(), + key_version: google_cloud_default_key_version(), + }, + }; + + let result = GoogleCloudKmsSignerConfig::try_from(config); + assert!(result.is_ok()); + + let gcp_config = result.unwrap(); + assert_eq!(gcp_config.key.key_id, "test-key"); + assert_eq!(gcp_config.service_account.project_id, "test-project"); + } } diff --git a/src/models/signer/request.rs b/src/models/signer/request.rs index 1090673a3..8f007c1dd 100644 --- a/src/models/signer/request.rs +++ b/src/models/signer/request.rs @@ -523,4 +523,159 @@ mod tests { assert_eq!(signer.id, "local-signer"); assert_eq!(signer.signer_type(), SignerType::Local); } + + #[test] + fn test_valid_turnkey_create_request() { + let request = SignerCreateRequest { + id: Some("test-turnkey-signer".to_string()), + config: SignerConfigRequest::Turnkey { + config: TurnkeySignerRequestConfig { + api_public_key: "test-public-key".to_string(), + api_private_key: "test-private-key".to_string(), + organization_id: "test-org".to_string(), + private_key_id: "test-private-key-id".to_string(), + public_key: "test-public-key".to_string(), + }, + }, + }; + + let result = Signer::try_from(request); + assert!(result.is_ok()); + + let signer = result.unwrap(); + assert_eq!(signer.id, "test-turnkey-signer"); + assert_eq!(signer.signer_type(), SignerType::Turnkey); + + if let Some(turnkey_config) = signer.config.get_turnkey() { + assert_eq!(turnkey_config.api_public_key, "test-public-key"); + assert_eq!(turnkey_config.organization_id, "test-org"); + } else { + panic!("Expected Turnkey config"); + } + } + + #[test] + fn test_valid_vault_cloud_create_request() { + let request = SignerCreateRequest { + id: Some("test-vault-cloud-signer".to_string()), + config: SignerConfigRequest::VaultCloud { + config: VaultCloudSignerRequestConfig { + client_id: "test-client-id".to_string(), + client_secret: "test-client-secret".to_string(), + org_id: "test-org".to_string(), + project_id: "test-project".to_string(), + app_name: "test-app".to_string(), + key_name: "test-key".to_string(), + }, + }, + }; + + let result = Signer::try_from(request); + assert!(result.is_ok()); + + let signer = result.unwrap(); + assert_eq!(signer.id, "test-vault-cloud-signer"); + assert_eq!(signer.signer_type(), SignerType::VaultCloud); + } + + #[test] + fn test_valid_vault_transit_create_request() { + let request = SignerCreateRequest { + id: Some("test-vault-transit-signer".to_string()), + config: SignerConfigRequest::VaultTransit { + config: VaultTransitSignerRequestConfig { + key_name: "test-key".to_string(), + address: "https://vault.example.com".to_string(), + namespace: None, + role_id: "test-role".to_string(), + secret_id: "test-secret".to_string(), + pubkey: "test-pubkey".to_string(), + mount_point: None, + }, + }, + }; + + let result = Signer::try_from(request); + assert!(result.is_ok()); + + let signer = result.unwrap(); + assert_eq!(signer.id, "test-vault-transit-signer"); + assert_eq!(signer.signer_type(), SignerType::VaultTransit); + } + + #[test] + fn test_valid_google_cloud_kms_create_request() { + let request = SignerCreateRequest { + id: Some("test-gcp-kms-signer".to_string()), + config: SignerConfigRequest::GoogleCloudKms { + config: GoogleCloudKmsSignerRequestConfig { + service_account: GoogleCloudKmsSignerServiceAccountRequestConfig { + private_key: "test-private-key".to_string(), + private_key_id: "test-key-id".to_string(), + project_id: "test-project".to_string(), + client_email: "test@email.com".to_string(), + client_id: "test-client-id".to_string(), + auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs".to_string(), + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test%40test.iam.gserviceaccount.com".to_string(), + universe_domain: "googleapis.com".to_string(), + }, + key: GoogleCloudKmsSignerKeyRequestConfig { + location: "global".to_string(), + key_ring_id: "test-ring".to_string(), + key_id: "test-key".to_string(), + key_version: 1, + }, + }, + }, + }; + + let result = Signer::try_from(request); + assert!(result.is_ok()); + + let signer = result.unwrap(); + assert_eq!(signer.id, "test-gcp-kms-signer"); + assert_eq!(signer.signer_type(), SignerType::GoogleCloudKms); + } + + #[test] + fn test_invalid_local_hex_key() { + let request = SignerCreateRequest { + id: Some("test-signer".to_string()), + config: SignerConfigRequest::Local { + config: PlainSignerRequestConfig { + key: "invalid-hex".to_string(), // Invalid hex + }, + }, + }; + + let result = Signer::try_from(request); + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Invalid hex key format")); + } + } + + #[test] + fn test_invalid_turnkey_empty_key() { + let request = SignerCreateRequest { + id: Some("test-signer".to_string()), + config: SignerConfigRequest::Turnkey { + config: TurnkeySignerRequestConfig { + api_public_key: "".to_string(), // Empty + api_private_key: "test-private-key".to_string(), + organization_id: "test-org".to_string(), + private_key_id: "test-private-key-id".to_string(), + public_key: "test-public-key".to_string(), + }, + }, + }; + + let result = Signer::try_from(request); + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("API public key cannot be empty")); + } + } } diff --git a/src/models/signer/response.rs b/src/models/signer/response.rs index 944192ff6..dfc2fbd9d 100644 --- a/src/models/signer/response.rs +++ b/src/models/signer/response.rs @@ -20,14 +20,14 @@ use utoipa::ToSchema; #[serde(rename_all = "lowercase")] pub enum SignerConfigResponse { #[serde(rename = "plain")] - Plain { has_key: bool }, + Plain {}, Vault { address: String, namespace: Option, key_name: String, mount_point: Option, - has_role_id: bool, - has_secret_id: bool, + // role_id: Option, + // secret_id: Option, }, #[serde(rename = "vault_cloud")] VaultCloud { @@ -36,7 +36,7 @@ pub enum SignerConfigResponse { project_id: String, app_name: String, key_name: String, - has_client_secret: bool, + // client_secret: Option, }, #[serde(rename = "vault_transit")] VaultTransit { @@ -45,8 +45,8 @@ pub enum SignerConfigResponse { namespace: Option, pubkey: String, mount_point: Option, - has_role_id: bool, - has_secret_id: bool, + // role_id: Option, + // secret_id: Option, }, #[serde(rename = "aws_kms")] AwsKms { @@ -58,7 +58,7 @@ pub enum SignerConfigResponse { organization_id: String, private_key_id: String, public_key: String, - has_api_private_key: bool, + // api_private_key: Option, }, #[serde(rename = "google_cloud_kms")] GoogleCloudKms { @@ -76,9 +76,9 @@ pub struct GoogleCloudKmsSignerServiceAccountResponseConfig { pub auth_provider_x509_cert_url: String, pub client_x509_cert_url: String, pub universe_domain: String, - pub has_private_key: bool, - pub has_private_key_id: bool, - pub has_client_email: bool, + // pub private_key: Option, + // pub private_key_id: Option, + // pub client_email: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Eq)] @@ -92,16 +92,12 @@ pub struct GoogleCloudKmsSignerKeyResponseConfig { impl From for SignerConfigResponse { fn from(config: SignerConfig) -> Self { match config { - SignerConfig::Local(c) => SignerConfigResponse::Plain { - has_key: !c.raw_key.is_empty(), - }, + SignerConfig::Local(_) => SignerConfigResponse::Plain {}, SignerConfig::Vault(c) => SignerConfigResponse::Vault { address: c.address, namespace: c.namespace, key_name: c.key_name, mount_point: c.mount_point, - has_role_id: !c.role_id.is_empty(), - has_secret_id: !c.secret_id.is_empty(), }, SignerConfig::VaultCloud(c) => SignerConfigResponse::VaultCloud { client_id: c.client_id, @@ -109,7 +105,6 @@ impl From for SignerConfigResponse { project_id: c.project_id, app_name: c.app_name, key_name: c.key_name, - has_client_secret: !c.client_secret.is_empty(), }, SignerConfig::VaultTransit(c) => SignerConfigResponse::VaultTransit { key_name: c.key_name, @@ -117,8 +112,6 @@ impl From for SignerConfigResponse { namespace: c.namespace, pubkey: c.pubkey, mount_point: c.mount_point, - has_role_id: !c.role_id.is_empty(), - has_secret_id: !c.secret_id.is_empty(), }, SignerConfig::AwsKms(c) => SignerConfigResponse::AwsKms { region: c.region, @@ -129,7 +122,6 @@ impl From for SignerConfigResponse { organization_id: c.organization_id, private_key_id: c.private_key_id, public_key: c.public_key, - has_api_private_key: !c.api_private_key.is_empty(), }, SignerConfig::GoogleCloudKms(c) => SignerConfigResponse::GoogleCloudKms { service_account: GoogleCloudKmsSignerServiceAccountResponseConfig { @@ -140,9 +132,6 @@ impl From for SignerConfigResponse { auth_provider_x509_cert_url: c.service_account.auth_provider_x509_cert_url, client_x509_cert_url: c.service_account.client_x509_cert_url, universe_domain: c.service_account.universe_domain, - has_private_key: !c.service_account.private_key.is_empty(), - has_private_key_id: !c.service_account.private_key_id.is_empty(), - has_client_email: !c.service_account.client_email.is_empty(), }, key: GoogleCloudKmsSignerKeyResponseConfig { location: c.key.location, @@ -207,10 +196,7 @@ mod tests { assert_eq!(response.id, "test-signer"); assert_eq!(response.r#type, SignerType::Local); - assert_eq!( - response.config, - SignerConfigResponse::Plain { has_key: true } - ); + assert_eq!(response.config, SignerConfigResponse::Plain {}); } #[test] @@ -248,7 +234,7 @@ mod tests { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), SignerType::Local, - SignerConfigResponse::Plain { has_key: true }, + SignerConfigResponse::Plain {}, ), ( SignerConfig::AwsKms(crate::models::AwsKmsSignerConfig { @@ -284,13 +270,12 @@ mod tests { let response = SignerResponse { id: "test-signer".to_string(), r#type: SignerType::Local, - config: SignerConfigResponse::Plain { has_key: true }, + config: SignerConfigResponse::Plain {}, }; let json = serde_json::to_string(&response).unwrap(); assert!(json.contains("\"id\":\"test-signer\"")); assert!(json.contains("\"type\":\"local\"")); - assert!(json.contains("\"has_key\":true")); // Updated to match actual format } #[test] @@ -315,4 +300,12 @@ mod tests { } ); } + + #[test] + fn test_response_deserialization_all_types() { + let json = r#"{"id": "test", "type": "google_cloud_kms", "config": {"service_account": {"project_id": "proj", "client_id": "cid", "auth_uri": "auth", "token_uri": "token", "auth_provider_x509_cert_url": "cert", "client_x509_cert_url": "client_cert", "universe_domain": "domain"}, "key": {"location": "loc", "key_ring_id": "ring", "key_id": "key", "key_version": 1}}}"#; + + let response: SignerResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.r#type, SignerType::GoogleCloudKms); + } } diff --git a/src/models/signer/signer.rs b/src/models/signer/signer.rs index fea5cdb2b..e3073b7f1 100644 --- a/src/models/signer/signer.rs +++ b/src/models/signer/signer.rs @@ -290,7 +290,7 @@ impl SignerConfig { } } - /// Get local signer config if this is a local or test signer + /// Get local signer config if this is a local signer pub fn get_local(&self) -> Option<&LocalSignerConfig> { match self { Self::Local(config) => Some(config), @@ -650,4 +650,280 @@ mod tests { panic!("Expected BadRequest error"); } } + + #[test] + fn test_valid_vault_signer() { + let config = SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.example.com".to_string(), + namespace: Some("test".to_string()), + role_id: SecretString::new("role-id"), + secret_id: SecretString::new("secret-id"), + key_name: "test-key".to_string(), + mount_point: None, + }); + + let signer = Signer::new("vault-signer".to_string(), config); + assert!(signer.validate().is_ok()); + assert_eq!(signer.signer_type(), SignerType::Vault); + } + + #[test] + fn test_invalid_vault_signer_url() { + let config = SignerConfig::Vault(VaultSignerConfig { + address: "not-a-url".to_string(), + namespace: Some("test".to_string()), + role_id: SecretString::new("role-id"), + secret_id: SecretString::new("secret-id"), + key_name: "test-key".to_string(), + mount_point: None, + }); + + let signer = Signer::new("vault-signer".to_string(), config); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("Address must be a valid URL")); + } else { + panic!("Expected InvalidConfig error for invalid URL"); + } + } + + #[test] + fn test_valid_vault_cloud_signer() { + let config = SignerConfig::VaultCloud(VaultCloudSignerConfig { + client_id: "client-id".to_string(), + client_secret: SecretString::new("secret"), + org_id: "org-id".to_string(), + project_id: "project-id".to_string(), + app_name: "app".to_string(), + key_name: "key".to_string(), + }); + + let signer = Signer::new("vault-cloud-signer".to_string(), config); + assert!(signer.validate().is_ok()); + assert_eq!(signer.signer_type(), SignerType::VaultCloud); + } + + #[test] + fn test_invalid_vault_cloud_empty_fields() { + let config = SignerConfig::VaultCloud(VaultCloudSignerConfig { + client_id: "".to_string(), // Empty client ID + client_secret: SecretString::new("secret"), + org_id: "org-id".to_string(), + project_id: "project-id".to_string(), + app_name: "app".to_string(), + key_name: "key".to_string(), + }); + + let signer = Signer::new("vault-cloud-signer".to_string(), config); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("Client ID cannot be empty")); + } else { + panic!("Expected InvalidConfig error for empty client ID"); + } + } + + #[test] + fn test_valid_google_cloud_kms_signer() { + let config = SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { + service_account: GoogleCloudKmsSignerServiceAccountConfig { + private_key: SecretString::new("private-key"), + private_key_id: SecretString::new("key-id"), + project_id: "project".to_string(), + client_email: SecretString::new("client@example.com"), + client_id: "client-id".to_string(), + auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" + .to_string(), + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" + .to_string(), + universe_domain: "googleapis.com".to_string(), + }, + key: GoogleCloudKmsSignerKeyConfig { + location: "us-central1".to_string(), + key_ring_id: "test-ring".to_string(), + key_id: "test-key".to_string(), + key_version: 1, + }, + }); + + let signer = Signer::new("gcp-kms-signer".to_string(), config); + assert!(signer.validate().is_ok()); + assert_eq!(signer.signer_type(), SignerType::GoogleCloudKms); + } + + #[test] + fn test_invalid_google_cloud_kms_urls() { + let config = SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { + service_account: GoogleCloudKmsSignerServiceAccountConfig { + private_key: SecretString::new("private-key"), + private_key_id: SecretString::new("key-id"), + project_id: "project".to_string(), + client_email: SecretString::new("client@example.com"), + client_id: "client-id".to_string(), + auth_uri: "not-a-url".to_string(), // Invalid URL + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" + .to_string(), + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" + .to_string(), + universe_domain: "googleapis.com".to_string(), + }, + key: GoogleCloudKmsSignerKeyConfig { + location: "us-central1".to_string(), + key_ring_id: "test-ring".to_string(), + key_id: "test-key".to_string(), + key_version: 1, + }, + }); + + let signer = Signer::new("gcp-kms-signer".to_string(), config); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("Auth URI must be a valid URL")); + } else { + panic!("Expected InvalidConfig error for invalid URL"); + } + } + + #[test] + fn test_secret_string_validation() { + // Test empty secret + let result = validate_secret_string(&SecretString::new("")); + if let Err(e) = result { + assert_eq!(e.code, "empty_secret"); + } else { + panic!("Expected validation error for empty secret"); + } + + // Test valid secret + let result = validate_secret_string(&SecretString::new("secret")); + assert!(result.is_ok()); + } + + #[test] + fn test_validation_error_formatting() { + // Create an invalid config to trigger multiple nested validation errors + let invalid_config = GoogleCloudKmsSignerConfig { + service_account: GoogleCloudKmsSignerServiceAccountConfig { + private_key: SecretString::new(""), // Invalid: empty + private_key_id: SecretString::new("key-id"), + project_id: "project".to_string(), + client_email: SecretString::new("client@example.com"), + client_id: "".to_string(), // Invalid: empty + auth_uri: "not-a-url".to_string(), // Invalid: not a URL + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" + .to_string(), + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" + .to_string(), + universe_domain: "googleapis.com".to_string(), + }, + key: GoogleCloudKmsSignerKeyConfig { + location: "us-central1".to_string(), + key_ring_id: "".to_string(), // Invalid: empty + key_id: "test-key".to_string(), + key_version: 1, + }, + }; + + let errors = invalid_config.validate().unwrap_err(); + + // Format the errors using the helper function + let formatted = format_validation_errors(&errors); + + println!("formatted: {}", formatted); + + // Check that messages from nested fields are correctly formatted + assert!(formatted.contains("client_id: Client ID cannot be empty")); + assert!(formatted.contains("private_key: Private key cannot be empty")); + assert!(formatted.contains("auth_uri: Auth URI must be a valid URL")); + assert!(formatted.contains("key_ring_id: Key ring ID cannot be empty")); + } + + #[test] + fn test_config_type_getters() { + // Test Vault config getter + let vault_config = VaultSignerConfig { + address: "https://vault.example.com".to_string(), + namespace: None, + role_id: SecretString::new("role"), + secret_id: SecretString::new("secret"), + key_name: "key".to_string(), + mount_point: None, + }; + let config = SignerConfig::Vault(vault_config); + assert!(config.get_vault().is_some()); + assert!(config.get_vault_cloud().is_none()); + + // Test VaultCloud config getter + let vault_cloud_config = VaultCloudSignerConfig { + client_id: "client".to_string(), + client_secret: SecretString::new("secret"), + org_id: "org".to_string(), + project_id: "project".to_string(), + app_name: "app".to_string(), + key_name: "key".to_string(), + }; + let config = SignerConfig::VaultCloud(vault_cloud_config); + assert!(config.get_vault_cloud().is_some()); + assert!(config.get_vault_transit().is_none()); + + // Test VaultTransit config getter + let vault_transit_config = VaultTransitSignerConfig { + key_name: "key".to_string(), + address: "https://vault.example.com".to_string(), + namespace: None, + role_id: SecretString::new("role"), + secret_id: SecretString::new("secret"), + pubkey: "pubkey".to_string(), + mount_point: None, + }; + let config = SignerConfig::VaultTransit(vault_transit_config); + assert!(config.get_vault_transit().is_some()); + assert!(config.get_turnkey().is_none()); + + // Test Turnkey config getter + let turnkey_config = TurnkeySignerConfig { + api_public_key: "public".to_string(), + api_private_key: SecretString::new("private"), + organization_id: "org".to_string(), + private_key_id: "key-id".to_string(), + public_key: "pubkey".to_string(), + }; + let config = SignerConfig::Turnkey(turnkey_config); + assert!(config.get_turnkey().is_some()); + assert!(config.get_google_cloud_kms().is_none()); + + // Test Google Cloud KMS config getter + let gcp_config = GoogleCloudKmsSignerConfig { + service_account: GoogleCloudKmsSignerServiceAccountConfig { + private_key: SecretString::new("private-key"), + private_key_id: SecretString::new("key-id"), + project_id: "project".to_string(), + client_email: SecretString::new("client@example.com"), + client_id: "client-id".to_string(), + auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" + .to_string(), + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" + .to_string(), + universe_domain: "googleapis.com".to_string(), + }, + key: GoogleCloudKmsSignerKeyConfig { + location: "us-central1".to_string(), + key_ring_id: "test-ring".to_string(), + key_id: "test-key".to_string(), + key_version: 1, + }, + }; + let config = SignerConfig::GoogleCloudKms(gcp_config); + assert!(config.get_google_cloud_kms().is_some()); + assert!(config.get_local().is_none()); + } } diff --git a/src/services/signer/evm/mod.rs b/src/services/signer/evm/mod.rs index 4e0d31ea1..de28f4397 100644 --- a/src/services/signer/evm/mod.rs +++ b/src/services/signer/evm/mod.rs @@ -13,7 +13,7 @@ //! ├── LocalSigner (encrypted JSON keystore) //! ├── AwsKmsSigner (AWS KMS backend) //! ├── Vault (HashiCorp Vault backend) -//! ├── VaultCould (HashiCorp Vault backend) +//! ├── VaultCloud (HashiCorp Vault backend) //! └── Turnkey (Turnkey backend) //! ``` mod aws_kms_signer; diff --git a/src/services/signer/solana/mod.rs b/src/services/signer/solana/mod.rs index 72411377d..746913e89 100644 --- a/src/services/signer/solana/mod.rs +++ b/src/services/signer/solana/mod.rs @@ -384,24 +384,6 @@ mod solana_signer_factory_tests { assert_eq!(test_key_bytes_pubkey(), signer_address); assert_eq!(test_key_bytes_pubkey(), signer_pubkey); } - - #[tokio::test] - async fn test_address_solana_signer_test() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::Local(LocalSignerConfig { - raw_key: test_key_bytes(), - }), - }; - - let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); - let signer_address = signer.address().await.unwrap(); - let signer_pubkey = signer.pubkey().await.unwrap(); - - assert_eq!(test_key_bytes_pubkey(), signer_address); - assert_eq!(test_key_bytes_pubkey(), signer_pubkey); - } - #[tokio::test] async fn test_address_solana_signer_vault() { let signer_model = SignerRepoModel { From 21ce87029c1f27ed6bfe76c4eddc71b2929ba3d8 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Fri, 18 Jul 2025 12:03:21 +0200 Subject: [PATCH 18/59] refactor + remove vault cloud due to deprecation of service --- docs/modules/ROOT/pages/signers.adoc | 63 --- docs/modules/ROOT/pages/solana.adoc | 1 - src/bootstrap/config_processor.rs | 28 +- src/config/config_file/mod.rs | 218 ++-------- src/models/error/signer.rs | 12 + src/models/signer/config.rs | 37 +- src/models/signer/mod.rs | 3 +- src/models/signer/repository.rs | 45 +-- src/models/signer/request.rs | 56 +-- src/models/signer/response.rs | 34 +- src/models/signer/signer.rs | 89 ----- src/services/signer/evm/local_signer.rs | 1 + src/services/signer/evm/mod.rs | 238 +++-------- src/services/signer/evm/vault_signer.rs | 371 ++++++++++++++++++ src/services/signer/solana/mod.rs | 121 +----- src/services/signer/stellar/mod.rs | 7 +- .../test_keys/unit-test-local-signer.json | 2 +- 17 files changed, 494 insertions(+), 832 deletions(-) create mode 100644 src/services/signer/evm/vault_signer.rs diff --git a/docs/modules/ROOT/pages/signers.adoc b/docs/modules/ROOT/pages/signers.adoc index 923b5fded..d35036147 100644 --- a/docs/modules/ROOT/pages/signers.adoc +++ b/docs/modules/ROOT/pages/signers.adoc @@ -33,7 +33,6 @@ OpenZeppelin Relayer supports the following signer types: - `local`: Keystore file signer - `vault`: HashiCorp Vault secret signer -- `vault_cloud`: Hosted HashiCorp Vault secret signer - `vault_transit`: HashiCorp Vault Transit signer - `turnkey`: Turnkey signer - `google_cloud_kms`: Google Cloud KMS signer @@ -57,11 +56,6 @@ The following table shows which signer types are compatible with each network ty |✅ Supported |❌ Not supported -|`vault_cloud` -|✅ Supported -|✅ Supported -|❌ Not supported - |`vault_transit` |❌ Not supported |✅ Supported @@ -214,63 +208,6 @@ Configuration fields: | The mount point for the Secrets engine in Vault. Defaults to `secret` if not explicitly specified. Optional. |=== -=== Vault Cloud Signer - -Uses HashiCorp Vault Cloud (HCP Vault) for key management. - -[source,json] ----- -{ - "id": "vault-cloud-signer", - "type": "vault_cloud", - "config": { - "client_id": "your-client-id", - "client_secret": { - "type": "env", - "value": "VAULT_CLOUD_CLIENT_SECRET" - }, - "org_id": "your-org-id", - "project_id": "your-project-id", - "app_name": "relayer-app", - "key_name": "signing-key" - } -} ----- - -Configuration fields: -[cols="1,1,2"] -|=== -|Field |Type |Description - -| client_id -| String -| The client identifier used to authenticate with Vault Cloud - -| client_secret.type -| String -| Type of value source (`env` or `plain`) - -| client_secret.value -| String -| The Vault secret value, or the environment variable name where the secret value is stored - -| org_id -| String -| The organization ID for your Vault Cloud account - -| project_id -| String -| The project ID that uniquely identifies your Vault Cloud project - -| app_name -| String -| The name of the application integrating with Vault Cloud - -| key_name -| String -| The name of the cryptographic key used for signing or encryption operations in Vault Cloud -|=== - === Vault Transit Signer Uses HashiCorp Vault's Transit secrets engine for cryptographic operations. diff --git a/docs/modules/ROOT/pages/solana.adoc b/docs/modules/ROOT/pages/solana.adoc index bd35aae07..048235c1e 100644 --- a/docs/modules/ROOT/pages/solana.adoc +++ b/docs/modules/ROOT/pages/solana.adoc @@ -64,7 +64,6 @@ For detailed network configuration options, see the xref:network_configuration.a - `google_cloud_kms` (hosted) - `local` (local) - `vault` (local) -- `vault_cloud` (local) [NOTE] ==== diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index 96e4144e7..c9418caeb 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -417,9 +417,9 @@ mod tests { let signer = SignerFileConfig { id: "test-signer".to_string(), config: SignerFileConfigEnum::Local(LocalSignerFileConfig { - path: "test-path".to_string(), + path: "tests/utils/test_keys/unit-test-local-signer.json".to_string(), passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("test-passphrase"), + value: SecretString::new("test"), }, }), }; @@ -634,18 +634,18 @@ mod tests { SignerFileConfig { id: "test-signer-1".to_string(), config: SignerFileConfigEnum::Local(LocalSignerFileConfig { - path: "test-path".to_string(), + path: "tests/utils/test_keys/unit-test-local-signer.json".to_string(), passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("test-passphrase"), + value: SecretString::new("test"), }, }), }, SignerFileConfig { id: "test-signer-2".to_string(), config: SignerFileConfigEnum::Local(LocalSignerFileConfig { - path: "test-path".to_string(), + path: "tests/utils/test_keys/unit-test-local-signer.json".to_string(), passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("test-passphrase"), + value: SecretString::new("test"), }, }), }, @@ -951,9 +951,9 @@ mod tests { let signers = vec![SignerFileConfig { id: "test-signer-1".to_string(), config: SignerFileConfigEnum::Local(LocalSignerFileConfig { - path: "test-path".to_string(), + path: "tests/utils/test_keys/unit-test-local-signer.json".to_string(), passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("test-passphrase"), + value: SecretString::new("test"), }, }), }]; @@ -1067,9 +1067,11 @@ mod tests { // Create test signers, relayers, and notifications let signers = vec![SignerFileConfig { id: "test-signer-1".to_string(), - config: SignerFileConfigEnum::AwsKms(AwsKmsSignerFileConfig { - region: "us-east-1".to_string(), - key_id: "test-key-id".to_string(), + config: SignerFileConfigEnum::Local(LocalSignerFileConfig { + path: "tests/utils/test_keys/unit-test-local-signer.json".to_string(), + passphrase: PlainOrEnvValue::Plain { + value: SecretString::new("test"), + }, }), }]; @@ -1424,9 +1426,9 @@ mod tests { signers: vec![SignerFileConfig { id: "test-signer-1".to_string(), config: SignerFileConfigEnum::Local(LocalSignerFileConfig { - path: "test-path".to_string(), + path: "tests/utils/test_keys/unit-test-local-signer.json".to_string(), passphrase: PlainOrEnvValue::Plain { - value: SecretString::new("test-passphrase"), + value: SecretString::new("test"), }, }), }], diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index b6245ac55..0d6e30b5c 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -405,192 +405,6 @@ mod tests { )); } - #[test] - fn test_evm_mainnet_not_allowed_for_signer_type_test() { - let mut config = create_valid_config(); - config.relayers[0].network = "mainnet".to_string(); - config.relayers[0].signer_id = "test-type".to_string(); - - // Add mainnet network to the config - let mainnet_network = NetworkFileConfig::Evm(EvmNetworkConfig { - common: NetworkConfigCommon { - network: "mainnet".to_string(), - from: None, - rpc_urls: Some(vec!["https://rpc.mainnet.example.com".to_string()]), - explorer_urls: Some(vec!["https://explorer.mainnet.example.com".to_string()]), - average_blocktime_ms: Some(12000), - is_testnet: Some(false), // This is mainnet, not testnet - tags: Some(vec!["mainnet".to_string()]), - }, - chain_id: Some(1), - required_confirmations: Some(12), - features: None, - symbol: Some("ETH".to_string()), - }); - - let mut networks = config.networks.networks; - networks.push(mainnet_network); - config.networks = - NetworksFileConfig::new(networks).expect("Failed to create NetworksFileConfig"); - - let result = config.validate(); - assert!(matches!( - result, - Err(ConfigFileError::TestSigner(msg)) if msg.contains("production networks") - )); - } - - #[test] - fn test_evm_sepolia_allowed_for_signer_type_test() { - let mut config = create_valid_config(); - config.relayers[0].network = "sepolia".to_string(); - config.relayers[0].signer_id = "test-type".to_string(); - - let sepolia_network = NetworkFileConfig::Evm(EvmNetworkConfig { - common: NetworkConfigCommon { - network: "sepolia".to_string(), - from: None, - rpc_urls: Some(vec!["https://rpc.sepolia.example.com".to_string()]), - explorer_urls: Some(vec!["https://explorer.sepolia.example.com".to_string()]), - average_blocktime_ms: Some(12000), - is_testnet: Some(true), - tags: Some(vec!["test".to_string()]), - }, - chain_id: Some(11155111), - required_confirmations: Some(1), - features: None, - symbol: Some("ETH".to_string()), - }); - - let mut networks = config.networks.networks; - networks.push(sepolia_network); - config.networks = - NetworksFileConfig::new(networks).expect("Failed to create NetworksFileConfig"); - - let result = config.validate(); - assert!(result.is_ok()); - } - - #[test] - fn test_solana_mainnet_not_allowed_for_signer_type_test() { - let mut config = create_valid_config(); - config.relayers[0].network_type = ConfigFileNetworkType::Solana; - config.relayers[0].network = "mainnet-beta".to_string(); - config.relayers[0].signer_id = "test-type".to_string(); - - let mainnet_beta_network = NetworkFileConfig::Solana(SolanaNetworkConfig { - common: NetworkConfigCommon { - network: "mainnet-beta".to_string(), - from: None, - rpc_urls: Some(vec!["https://api.mainnet-beta.solana.com".to_string()]), - explorer_urls: Some(vec!["https://explorer.solana.com".to_string()]), - average_blocktime_ms: Some(400), - is_testnet: Some(false), - tags: Some(vec!["mainnet".to_string()]), - }, - }); - - let mut networks = config.networks.networks; - networks.push(mainnet_beta_network); - config.networks = - NetworksFileConfig::new(networks).expect("Failed to create NetworksFileConfig"); - - let result = config.validate(); - assert!(matches!( - result, - Err(ConfigFileError::TestSigner(msg)) if msg.contains("production networks") - )); - } - - #[test] - fn test_solana_devnet_allowed_for_signer_type_test() { - let mut config = create_valid_config(); - config.relayers[0].network_type = ConfigFileNetworkType::Solana; - config.relayers[0].network = "devnet".to_string(); - config.relayers[0].signer_id = "test-type".to_string(); - - let devnet_network = NetworkFileConfig::Solana(SolanaNetworkConfig { - common: NetworkConfigCommon { - network: "devnet".to_string(), - from: None, - rpc_urls: Some(vec!["https://api.devnet.solana.com".to_string()]), - explorer_urls: Some(vec!["https://explorer.solana.com".to_string()]), - average_blocktime_ms: Some(400), - is_testnet: Some(true), - tags: Some(vec!["test".to_string()]), - }, - }); - - let mut networks = config.networks.networks; - networks.push(devnet_network); - config.networks = - NetworksFileConfig::new(networks).expect("Failed to create NetworksFileConfig"); - - let result = config.validate(); - assert!(result.is_ok()); - } - - #[test] - fn test_stellar_mainnet_not_allowed_for_signer_type_test() { - let mut config = create_valid_config(); - config.relayers[0].network_type = ConfigFileNetworkType::Stellar; - config.relayers[0].network = "mainnet".to_string(); - config.relayers[0].signer_id = "test-type".to_string(); - - let mainnet_network = NetworkFileConfig::Stellar(StellarNetworkConfig { - common: NetworkConfigCommon { - network: "mainnet".to_string(), - from: None, - rpc_urls: Some(vec!["https://horizon.stellar.org".to_string()]), - explorer_urls: Some(vec!["https://stellar.expert".to_string()]), - average_blocktime_ms: Some(5000), - is_testnet: Some(false), - tags: Some(vec!["mainnet".to_string()]), - }, - passphrase: Some("Public Global Stellar Network ; September 2015".to_string()), - }); - - let mut networks = config.networks.networks; - networks.push(mainnet_network); - config.networks = - NetworksFileConfig::new(networks).expect("Failed to create NetworksFileConfig"); - - let result = config.validate(); - assert!(matches!( - result, - Err(ConfigFileError::TestSigner(msg)) if msg.contains("production networks") - )); - } - - #[test] - fn test_stellar_testnet_allowed_for_signer_type_test() { - let mut config = create_valid_config(); - config.relayers[0].network_type = ConfigFileNetworkType::Stellar; - config.relayers[0].network = "testnet".to_string(); - config.relayers[0].signer_id = "test-type".to_string(); - - let testnet_network = NetworkFileConfig::Stellar(StellarNetworkConfig { - common: NetworkConfigCommon { - network: "testnet".to_string(), - from: None, - rpc_urls: Some(vec!["https://soroban-testnet.stellar.org".to_string()]), - explorer_urls: Some(vec!["https://stellar.expert/explorer/testnet".to_string()]), - average_blocktime_ms: Some(5000), - is_testnet: Some(true), - tags: Some(vec!["test".to_string()]), - }, - passphrase: Some("Test SDF Network ; September 2015".to_string()), - }); - - let mut networks = config.networks.networks; - networks.push(testnet_network); - config.networks = - NetworksFileConfig::new(networks).expect("Failed to create NetworksFileConfig"); - - let result = config.validate(); - assert!(result.is_ok()); - } - #[test] fn test_config_with_networks() { let mut config = create_valid_config(); @@ -1097,8 +911,14 @@ mod tests { }], "signers": [{ "id": "test-signer", - "type": "test", - "config": {} + "type": "local", + "config": { + "path": "tests/utils/test_keys/unit-test-local-signer.json", + "passphrase": { + "value": "test", + "type": "plain" + } + } }], "notifications": [{ "id": "test-notification", @@ -1233,8 +1053,14 @@ mod tests { }], "signers": [{ "id": "test-signer-unicode", - "type": "test", - "config": {} + "type": "local", + "config": { + "path": "tests/utils/test_keys/unit-test-local-signer.json", + "passphrase": { + "value": "test", + "type": "plain" + } + } }], "notifications": [{ "id": "test-notification-unicode", @@ -1502,7 +1328,7 @@ mod tests { paused: false, network_type: ConfigFileNetworkType::Solana, policies: None, - signer_id: "test-type".to_string(), + signer_id: "test-1".to_string(), notification_id: None, custom_rpc_urls: None, }); @@ -1702,8 +1528,14 @@ mod tests { }], "signers": [{ "id": "test-signer", - "type": "test", - "config": {} + "type": "local", + "config": { + "path": "tests/utils/test_keys/unit-test-local-signer.json", + "passphrase": { + "value": "test", + "type": "plain" + } + } }], "notifications": [{ "id": "test-notification", diff --git a/src/models/error/signer.rs b/src/models/error/signer.rs index f6316c277..ee00976d6 100644 --- a/src/models/error/signer.rs +++ b/src/models/error/signer.rs @@ -38,6 +38,18 @@ pub enum SignerError { #[error("Not implemented: {0}")] NotImplemented(String), + #[error("Invalid configuration: {0}")] + ConfigError(String), + + #[error("Network error: {0}")] + NetworkError(String), + + #[error("Authentication error: {0}")] + AuthenticationError(String), + + #[error("Parse error: {0}")] + ParseError(String), + #[error("Invalid configuration: {0}")] Configuration(String), diff --git a/src/models/signer/config.rs b/src/models/signer/config.rs index 9453ca3d9..3a655b04c 100644 --- a/src/models/signer/config.rs +++ b/src/models/signer/config.rs @@ -14,7 +14,7 @@ use crate::{ models::signer::signer::{ AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, Signer, SignerConfig, - TurnkeySignerConfig, VaultCloudSignerConfig, VaultSignerConfig, VaultTransitSignerConfig, + TurnkeySignerConfig, VaultSignerConfig, VaultTransitSignerConfig, }, models::PlainOrEnvValue, }; @@ -57,17 +57,6 @@ pub struct VaultSignerFileConfig { pub mount_point: Option, } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct VaultCloudSignerFileConfig { - pub client_id: String, - pub client_secret: PlainOrEnvValue, - pub org_id: String, - pub project_id: String, - pub app_name: String, - pub key_name: String, -} - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct VaultTransitSignerFileConfig { @@ -155,8 +144,6 @@ pub enum SignerFileConfigEnum { AwsKms(AwsKmsSignerFileConfig), Turnkey(TurnkeySignerFileConfig), Vault(VaultSignerFileConfig), - #[serde(rename = "vault_cloud")] - VaultCloud(VaultCloudSignerFileConfig), #[serde(rename = "vault_transit")] VaultTransit(VaultTransitSignerFileConfig), #[serde(rename = "google_cloud_kms")] @@ -305,25 +292,6 @@ impl TryFrom for VaultSignerConfig { } } -impl TryFrom for VaultCloudSignerConfig { - type Error = ConfigFileError; - - fn try_from(config: VaultCloudSignerFileConfig) -> Result { - let client_secret = config.client_secret.get_value().map_err(|e| { - ConfigFileError::InvalidFormat(format!("Failed to get client secret: {}", e)) - })?; - - Ok(VaultCloudSignerConfig { - client_id: config.client_id, - client_secret, - org_id: config.org_id, - project_id: config.project_id, - app_name: config.app_name, - key_name: config.key_name, - }) - } -} - impl TryFrom for VaultTransitSignerConfig { type Error = ConfigFileError; @@ -421,9 +389,6 @@ impl TryFrom for SignerConfig { SignerFileConfigEnum::Vault(vault) => { Ok(SignerConfig::Vault(VaultSignerConfig::try_from(vault)?)) } - SignerFileConfigEnum::VaultCloud(vault_cloud) => Ok(SignerConfig::VaultCloud( - VaultCloudSignerConfig::try_from(vault_cloud)?, - )), SignerFileConfigEnum::VaultTransit(vault_transit) => Ok(SignerConfig::VaultTransit( VaultTransitSignerConfig::try_from(vault_transit)?, )), diff --git a/src/models/signer/mod.rs b/src/models/signer/mod.rs index 669f9298d..cd4aeddf3 100644 --- a/src/models/signer/mod.rs +++ b/src/models/signer/mod.rs @@ -4,8 +4,7 @@ pub use repository::{ AwsKmsSignerConfigStorage, GoogleCloudKmsSignerConfigStorage, GoogleCloudKmsSignerKeyConfigStorage, GoogleCloudKmsSignerServiceAccountConfigStorage, LocalSignerConfigStorage, SignerConfigStorage, SignerRepoModel, SignerRepoModelStorage, - TurnkeySignerConfigStorage, VaultCloudSignerConfigStorage, VaultSignerConfigStorage, - VaultTransitSignerConfigStorage, + TurnkeySignerConfigStorage, VaultSignerConfigStorage, VaultTransitSignerConfigStorage, }; mod config; diff --git a/src/models/signer/repository.rs b/src/models/signer/repository.rs index d14e939a4..f8a491006 100644 --- a/src/models/signer/repository.rs +++ b/src/models/signer/repository.rs @@ -15,7 +15,7 @@ use crate::{ signer::signer::{ AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, Signer, SignerConfig, - SignerValidationError, TurnkeySignerConfig, VaultCloudSignerConfig, VaultSignerConfig, + SignerValidationError, TurnkeySignerConfig, VaultSignerConfig, VaultTransitSignerConfig, }, SecretString, @@ -53,7 +53,6 @@ pub struct SignerRepoModelStorage { pub enum SignerConfigStorage { Local(LocalSignerConfigStorage), Vault(VaultSignerConfigStorage), - VaultCloud(VaultCloudSignerConfigStorage), VaultTransit(VaultTransitSignerConfigStorage), AwsKms(AwsKmsSignerConfigStorage), Turnkey(TurnkeySignerConfigStorage), @@ -103,16 +102,6 @@ pub struct VaultSignerConfigStorage { pub mount_point: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VaultCloudSignerConfigStorage { - pub client_id: String, - pub client_secret: String, // Stored as string for simplicity - pub org_id: String, - pub project_id: String, - pub app_name: String, - pub key_name: String, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VaultTransitSignerConfigStorage { pub key_name: String, @@ -198,9 +187,6 @@ impl From for SignerConfigStorage { SignerConfig::Local(local) => SignerConfigStorage::Local(local.into()), SignerConfig::AwsKms(aws) => SignerConfigStorage::AwsKms(aws.into()), SignerConfig::Vault(vault) => SignerConfigStorage::Vault(vault.into()), - SignerConfig::VaultCloud(vault_cloud) => { - SignerConfigStorage::VaultCloud(vault_cloud.into()) - } SignerConfig::VaultTransit(vault_transit) => { SignerConfigStorage::VaultTransit(vault_transit.into()) } @@ -217,9 +203,6 @@ impl From for SignerConfig { SignerConfigStorage::Local(local) => SignerConfig::Local(local.into()), SignerConfigStorage::AwsKms(aws) => SignerConfig::AwsKms(aws.into()), SignerConfigStorage::Vault(vault) => SignerConfig::Vault(vault.into()), - SignerConfigStorage::VaultCloud(vault_cloud) => { - SignerConfig::VaultCloud(vault_cloud.into()) - } SignerConfigStorage::VaultTransit(vault_transit) => { SignerConfig::VaultTransit(vault_transit.into()) } @@ -289,32 +272,6 @@ impl From for VaultSignerConfig { } } -impl From for VaultCloudSignerConfigStorage { - fn from(config: VaultCloudSignerConfig) -> Self { - Self { - client_id: config.client_id, - client_secret: config.client_secret.to_str().to_string(), - org_id: config.org_id, - project_id: config.project_id, - app_name: config.app_name, - key_name: config.key_name, - } - } -} - -impl From for VaultCloudSignerConfig { - fn from(storage: VaultCloudSignerConfigStorage) -> Self { - Self { - client_id: storage.client_id, - client_secret: SecretString::new(&storage.client_secret), - org_id: storage.org_id, - project_id: storage.project_id, - app_name: storage.app_name, - key_name: storage.key_name, - } - } -} - impl From for VaultTransitSignerConfigStorage { fn from(config: VaultTransitSignerConfig) -> Self { Self { diff --git a/src/models/signer/request.rs b/src/models/signer/request.rs index 8f007c1dd..fe70456f0 100644 --- a/src/models/signer/request.rs +++ b/src/models/signer/request.rs @@ -12,8 +12,7 @@ use crate::models::{ ApiError, AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, SecretString, Signer, - SignerConfig, TurnkeySignerConfig, VaultCloudSignerConfig, VaultSignerConfig, - VaultTransitSignerConfig, + SignerConfig, TurnkeySignerConfig, VaultSignerConfig, VaultTransitSignerConfig, }; use secrets::SecretVec; use serde::{Deserialize, Serialize}; @@ -44,17 +43,6 @@ pub struct VaultSignerRequestConfig { pub mount_point: Option, } -/// Vault Cloud signer configuration for API requests -#[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] -pub struct VaultCloudSignerRequestConfig { - pub client_id: String, - pub client_secret: String, - pub org_id: String, - pub project_id: String, - pub app_name: String, - pub key_name: String, -} - /// Vault Transit signer configuration for API requests #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] pub struct VaultTransitSignerRequestConfig { @@ -123,10 +111,6 @@ pub enum SignerConfigRequest { Vault { config: VaultSignerRequestConfig, }, - #[serde(rename = "vault_cloud")] - VaultCloud { - config: VaultCloudSignerRequestConfig, - }, #[serde(rename = "vault_transit")] VaultTransit { config: VaultTransitSignerRequestConfig, @@ -177,19 +161,6 @@ impl From for VaultSignerConfig { } } -impl From for VaultCloudSignerConfig { - fn from(config: VaultCloudSignerRequestConfig) -> Self { - Self { - client_id: config.client_id, - client_secret: SecretString::new(&config.client_secret), - org_id: config.org_id, - project_id: config.project_id, - app_name: config.app_name, - key_name: config.key_name, - } - } -} - impl From for VaultTransitSignerConfig { fn from(config: VaultTransitSignerRequestConfig) -> Self { Self { @@ -275,7 +246,6 @@ impl TryFrom for SignerConfig { } SignerConfigRequest::AwsKms { config } => SignerConfig::AwsKms(config.into()), SignerConfigRequest::Vault { config } => SignerConfig::Vault(config.into()), - SignerConfigRequest::VaultCloud { config } => SignerConfig::VaultCloud(config.into()), SignerConfigRequest::VaultTransit { config } => { SignerConfig::VaultTransit(config.into()) } @@ -554,30 +524,6 @@ mod tests { } } - #[test] - fn test_valid_vault_cloud_create_request() { - let request = SignerCreateRequest { - id: Some("test-vault-cloud-signer".to_string()), - config: SignerConfigRequest::VaultCloud { - config: VaultCloudSignerRequestConfig { - client_id: "test-client-id".to_string(), - client_secret: "test-client-secret".to_string(), - org_id: "test-org".to_string(), - project_id: "test-project".to_string(), - app_name: "test-app".to_string(), - key_name: "test-key".to_string(), - }, - }, - }; - - let result = Signer::try_from(request); - assert!(result.is_ok()); - - let signer = result.unwrap(); - assert_eq!(signer.id, "test-vault-cloud-signer"); - assert_eq!(signer.signer_type(), SignerType::VaultCloud); - } - #[test] fn test_valid_vault_transit_create_request() { let request = SignerCreateRequest { diff --git a/src/models/signer/response.rs b/src/models/signer/response.rs index dfc2fbd9d..103ae727e 100644 --- a/src/models/signer/response.rs +++ b/src/models/signer/response.rs @@ -20,23 +20,13 @@ use utoipa::ToSchema; #[serde(rename_all = "lowercase")] pub enum SignerConfigResponse { #[serde(rename = "plain")] - Plain {}, Vault { address: String, namespace: Option, key_name: String, mount_point: Option, - // role_id: Option, - // secret_id: Option, - }, - #[serde(rename = "vault_cloud")] - VaultCloud { - client_id: String, - org_id: String, - project_id: String, - app_name: String, - key_name: String, - // client_secret: Option, + // role_id: Option, hidden from response due to security concerns + // secret_id: Option, hidden from response due to security concerns }, #[serde(rename = "vault_transit")] VaultTransit { @@ -45,8 +35,8 @@ pub enum SignerConfigResponse { namespace: Option, pubkey: String, mount_point: Option, - // role_id: Option, - // secret_id: Option, + // role_id: Option, hidden from response due to security concerns + // secret_id: Option, hidden from response due to security concerns }, #[serde(rename = "aws_kms")] AwsKms { @@ -58,13 +48,14 @@ pub enum SignerConfigResponse { organization_id: String, private_key_id: String, public_key: String, - // api_private_key: Option, + // api_private_key: Option, hidden from response due to security concerns }, #[serde(rename = "google_cloud_kms")] GoogleCloudKms { service_account: GoogleCloudKmsSignerServiceAccountResponseConfig, key: GoogleCloudKmsSignerKeyResponseConfig, }, + Plain {}, } #[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Eq)] @@ -76,9 +67,9 @@ pub struct GoogleCloudKmsSignerServiceAccountResponseConfig { pub auth_provider_x509_cert_url: String, pub client_x509_cert_url: String, pub universe_domain: String, - // pub private_key: Option, - // pub private_key_id: Option, - // pub client_email: Option, + // pub private_key: Option, hidden from response due to security concerns + // pub private_key_id: Option, hidden from response due to security concerns + // pub client_email: Option, hidden from response due to security concerns } #[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Eq)] @@ -99,13 +90,6 @@ impl From for SignerConfigResponse { key_name: c.key_name, mount_point: c.mount_point, }, - SignerConfig::VaultCloud(c) => SignerConfigResponse::VaultCloud { - client_id: c.client_id, - org_id: c.org_id, - project_id: c.project_id, - app_name: c.app_name, - key_name: c.key_name, - }, SignerConfig::VaultTransit(c) => SignerConfigResponse::VaultTransit { key_name: c.key_name, address: c.address, diff --git a/src/models/signer/signer.rs b/src/models/signer/signer.rs index e3073b7f1..56885e874 100644 --- a/src/models/signer/signer.rs +++ b/src/models/signer/signer.rs @@ -112,26 +112,6 @@ pub struct VaultSignerConfig { pub mount_point: Option, } -/// Vault Cloud signer configuration -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct VaultCloudSignerConfig { - #[validate(length(min = 1, message = "Client ID cannot be empty"))] - pub client_id: String, - #[validate(custom( - function = "validate_secret_string", - message = "Client secret cannot be empty" - ))] - pub client_secret: SecretString, - #[validate(length(min = 1, message = "Organization ID cannot be empty"))] - pub org_id: String, - #[validate(length(min = 1, message = "Project ID cannot be empty"))] - pub project_id: String, - #[validate(length(min = 1, message = "Application name cannot be empty"))] - pub app_name: String, - #[validate(length(min = 1, message = "Key name cannot be empty"))] - pub key_name: String, -} - /// Vault Transit signer configuration #[derive(Debug, Clone, Serialize, Deserialize, Validate)] pub struct VaultTransitSignerConfig { @@ -239,7 +219,6 @@ fn validate_secret_string(secret: &SecretString) -> Result<(), validator::Valida pub enum SignerConfig { Local(LocalSignerConfig), Vault(VaultSignerConfig), - VaultCloud(VaultCloudSignerConfig), VaultTransit(VaultTransitSignerConfig), AwsKms(AwsKmsSignerConfig), Turnkey(TurnkeySignerConfig), @@ -263,12 +242,6 @@ impl SignerConfig { format_validation_errors(&e) )) }), - Self::VaultCloud(config) => Validate::validate(config).map_err(|e| { - SignerValidationError::InvalidConfig(format!( - "Vault Cloud validation failed: {}", - format_validation_errors(&e) - )) - }), Self::VaultTransit(config) => Validate::validate(config).map_err(|e| { SignerValidationError::InvalidConfig(format!( "Vault Transit validation failed: {}", @@ -314,14 +287,6 @@ impl SignerConfig { } } - /// Get Vault Cloud signer config if this is a Vault Cloud signer - pub fn get_vault_cloud(&self) -> Option<&VaultCloudSignerConfig> { - match self { - Self::VaultCloud(config) => Some(config), - _ => None, - } - } - /// Get Vault Transit signer config if this is a Vault Transit signer pub fn get_vault_transit(&self) -> Option<&VaultTransitSignerConfig> { match self { @@ -352,7 +317,6 @@ impl SignerConfig { Self::Local(_) => SignerType::Local, Self::AwsKms(_) => SignerType::AwsKms, Self::Vault(_) => SignerType::Vault, - Self::VaultCloud(_) => SignerType::VaultCloud, Self::VaultTransit(_) => SignerType::VaultTransit, Self::Turnkey(_) => SignerType::Turnkey, Self::GoogleCloudKms(_) => SignerType::GoogleCloudKms, @@ -406,8 +370,6 @@ pub enum SignerType { #[serde(rename = "google_cloud_kms")] GoogleCloudKms, Vault, - #[serde(rename = "vault_cloud")] - VaultCloud, #[serde(rename = "vault_transit")] VaultTransit, Turnkey, @@ -688,43 +650,6 @@ mod tests { } } - #[test] - fn test_valid_vault_cloud_signer() { - let config = SignerConfig::VaultCloud(VaultCloudSignerConfig { - client_id: "client-id".to_string(), - client_secret: SecretString::new("secret"), - org_id: "org-id".to_string(), - project_id: "project-id".to_string(), - app_name: "app".to_string(), - key_name: "key".to_string(), - }); - - let signer = Signer::new("vault-cloud-signer".to_string(), config); - assert!(signer.validate().is_ok()); - assert_eq!(signer.signer_type(), SignerType::VaultCloud); - } - - #[test] - fn test_invalid_vault_cloud_empty_fields() { - let config = SignerConfig::VaultCloud(VaultCloudSignerConfig { - client_id: "".to_string(), // Empty client ID - client_secret: SecretString::new("secret"), - org_id: "org-id".to_string(), - project_id: "project-id".to_string(), - app_name: "app".to_string(), - key_name: "key".to_string(), - }); - - let signer = Signer::new("vault-cloud-signer".to_string(), config); - let result = signer.validate(); - assert!(result.is_err()); - if let Err(SignerValidationError::InvalidConfig(msg)) = result { - assert!(msg.contains("Client ID cannot be empty")); - } else { - panic!("Expected InvalidConfig error for empty client ID"); - } - } - #[test] fn test_valid_google_cloud_kms_signer() { let config = SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { @@ -858,20 +783,6 @@ mod tests { }; let config = SignerConfig::Vault(vault_config); assert!(config.get_vault().is_some()); - assert!(config.get_vault_cloud().is_none()); - - // Test VaultCloud config getter - let vault_cloud_config = VaultCloudSignerConfig { - client_id: "client".to_string(), - client_secret: SecretString::new("secret"), - org_id: "org".to_string(), - project_id: "project".to_string(), - app_name: "app".to_string(), - key_name: "key".to_string(), - }; - let config = SignerConfig::VaultCloud(vault_cloud_config); - assert!(config.get_vault_cloud().is_some()); - assert!(config.get_vault_transit().is_none()); // Test VaultTransit config getter let vault_transit_config = VaultTransitSignerConfig { diff --git a/src/services/signer/evm/local_signer.rs b/src/services/signer/evm/local_signer.rs index 7f937cb44..de2ff0d38 100644 --- a/src/services/signer/evm/local_signer.rs +++ b/src/services/signer/evm/local_signer.rs @@ -43,6 +43,7 @@ use super::DataSignerTrait; use alloy::rpc::types::TransactionRequest; +#[derive(Clone)] pub struct LocalSigner { local_signer_client: AlloyLocalSignerClient, } diff --git a/src/services/signer/evm/mod.rs b/src/services/signer/evm/mod.rs index de28f4397..e9046a52d 100644 --- a/src/services/signer/evm/mod.rs +++ b/src/services/signer/evm/mod.rs @@ -1,32 +1,32 @@ //! EVM signer implementation for managing Ethereum-compatible private keys and signing operations. -//! -//! Provides: -//! - Local keystore support (encrypted JSON files) -//! - Turnkey (Turnkey backend) -//! - AWS KMS support +//! This module provides various EVM signer implementations, including local keystore, HashiCorp Vault, Google Cloud KMS, AWS KMS, and Turnkey. //! //! # Architecture //! //! ```text //! EvmSigner -//! ├── TestSigner (Temporary testing private key) //! ├── LocalSigner (encrypted JSON keystore) //! ├── AwsKmsSigner (AWS KMS backend) //! ├── Vault (HashiCorp Vault backend) -//! ├── VaultCloud (HashiCorp Vault backend) +//! ├── Google Cloud KMS signer +//! ├── AWS KMS Signer //! └── Turnkey (Turnkey backend) //! ``` mod aws_kms_signer; mod google_cloud_kms_signer; mod local_signer; mod turnkey_signer; +mod vault_signer; use aws_kms_signer::*; use google_cloud_kms_signer::*; use local_signer::*; +use oz_keystore::HashicorpCloudClient; use turnkey_signer::*; +use vault_signer::*; use async_trait::async_trait; use color_eyre::config; +use std::sync::Arc; use crate::{ domain::{ @@ -35,16 +35,19 @@ use crate::{ }, models::{ Address, NetworkTransactionData, SignerConfig, SignerRepoModel, SignerType, - TransactionRepoModel, VaultCloudSignerConfig, VaultSignerConfig, + TransactionRepoModel, VaultSignerConfig, }, services::{ - turnkey::TurnkeyService, AwsKmsService, GoogleCloudKmsService, TurnkeyServiceTrait, + signer::Signer, + signer::SignerError, + signer::SignerFactoryError, + turnkey::TurnkeyService, + vault::{VaultConfig, VaultService, VaultServiceTrait}, + AwsKmsService, GoogleCloudKmsService, TurnkeyServiceTrait, }, }; use eyre::Result; -use super::{Signer, SignerError, SignerFactoryError}; - #[async_trait] pub trait DataSignerTrait: Send + Sync { /// Signs arbitrary message data @@ -59,8 +62,7 @@ pub trait DataSignerTrait: Send + Sync { pub enum EvmSigner { Local(LocalSigner), - Vault(LocalSigner), - VaultCloud(LocalSigner), + Vault(VaultSigner), Turnkey(TurnkeySigner), AwsKms(AwsKmsSigner), GoogleCloudKms(GoogleCloudKmsSigner), @@ -72,7 +74,6 @@ impl Signer for EvmSigner { match self { Self::Local(signer) => signer.address().await, Self::Vault(signer) => signer.address().await, - Self::VaultCloud(signer) => signer.address().await, Self::Turnkey(signer) => signer.address().await, Self::AwsKms(signer) => signer.address().await, Self::GoogleCloudKms(signer) => signer.address().await, @@ -86,7 +87,6 @@ impl Signer for EvmSigner { match self { Self::Local(signer) => signer.sign_transaction(transaction).await, Self::Vault(signer) => signer.sign_transaction(transaction).await, - Self::VaultCloud(signer) => signer.sign_transaction(transaction).await, Self::Turnkey(signer) => signer.sign_transaction(transaction).await, Self::AwsKms(signer) => signer.sign_transaction(transaction).await, Self::GoogleCloudKms(signer) => signer.sign_transaction(transaction).await, @@ -100,7 +100,6 @@ impl DataSignerTrait for EvmSigner { match self { Self::Local(signer) => signer.sign_data(request).await, Self::Vault(signer) => signer.sign_data(request).await, - Self::VaultCloud(signer) => signer.sign_data(request).await, Self::Turnkey(signer) => signer.sign_data(request).await, Self::AwsKms(signer) => signer.sign_data(request).await, Self::GoogleCloudKms(signer) => signer.sign_data(request).await, @@ -114,7 +113,6 @@ impl DataSignerTrait for EvmSigner { match self { Self::Local(signer) => signer.sign_typed_data(request).await, Self::Vault(signer) => signer.sign_typed_data(request).await, - Self::VaultCloud(signer) => signer.sign_typed_data(request).await, Self::Turnkey(signer) => signer.sign_typed_data(request).await, Self::AwsKms(signer) => signer.sign_typed_data(request).await, Self::GoogleCloudKms(signer) => signer.sign_typed_data(request).await, @@ -128,11 +126,25 @@ impl EvmSignerFactory { pub async fn create_evm_signer( signer_model: SignerRepoModel, ) -> Result { - let signer = match signer_model.config { - SignerConfig::Local(_) | SignerConfig::Vault(_) | SignerConfig::VaultCloud(_) => { - EvmSigner::Local(LocalSigner::new(&signer_model)?) + let signer = match &signer_model.config { + SignerConfig::Local(_) => EvmSigner::Local(LocalSigner::new(&signer_model)?), + SignerConfig::Vault(config) => { + let vault_config = VaultConfig::new( + config.address.clone(), + config.role_id.clone(), + config.secret_id.clone(), + config.namespace.clone(), + config + .mount_point + .clone() + .unwrap_or_else(|| "secret".to_string()), + None, + ); + let vault_service = VaultService::new(vault_config); + + EvmSigner::Vault(VaultSigner::new(config.clone(), vault_service)) } - SignerConfig::AwsKms(ref config) => { + SignerConfig::AwsKms(config) => { let aws_service = AwsKmsService::new(config.clone()).await.map_err(|e| { SignerFactoryError::CreationFailed(format!("AWS KMS service error: {}", e)) })?; @@ -141,13 +153,13 @@ impl EvmSignerFactory { SignerConfig::VaultTransit(_) => { return Err(SignerFactoryError::UnsupportedType("Vault Transit".into())); } - SignerConfig::Turnkey(ref config) => { + SignerConfig::Turnkey(config) => { let turnkey_service = TurnkeyService::new(config.clone()).map_err(|e| { SignerFactoryError::CreationFailed(format!("Turnkey service error: {}", e)) })?; EvmSigner::Turnkey(TurnkeySigner::new(turnkey_service)) } - SignerConfig::GoogleCloudKms(ref config) => { + SignerConfig::GoogleCloudKms(config) => { let gcp_service = GoogleCloudKmsService::new(config).map_err(|e| { SignerFactoryError::CreationFailed(format!( "Google Cloud KMS service error: {}", @@ -240,28 +252,7 @@ mod tests { .await .unwrap(); - assert!(matches!(signer, EvmSigner::Local(_))); - } - - #[tokio::test] - async fn test_create_evm_signer_vault_cloud() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::VaultCloud(VaultCloudSignerConfig { - client_id: "test-client-id".to_string(), - client_secret: crate::models::SecretString::new("test-client-secret"), - org_id: "test-org-id".to_string(), - project_id: "test-project-id".to_string(), - app_name: "test-app".to_string(), - key_name: "test-key".to_string(), - }), - }; - - let signer = EvmSignerFactory::create_evm_signer(signer_model) - .await - .unwrap(); - - assert!(matches!(signer, EvmSigner::Local(_))); + assert!(matches!(signer, EvmSigner::Vault(_))); } #[tokio::test] @@ -384,28 +375,6 @@ mod tests { assert_eq!(test_key_address(), signer_address); } - #[tokio::test] - async fn test_address_evm_signer_vault_cloud() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::VaultCloud(VaultCloudSignerConfig { - client_id: "test-client-id".to_string(), - client_secret: crate::models::SecretString::new("test-client-secret"), - org_id: "test-org-id".to_string(), - project_id: "test-project-id".to_string(), - app_name: "test-app".to_string(), - key_name: "test-key".to_string(), - }), - }; - - let signer = EvmSignerFactory::create_evm_signer(signer_model) - .await - .unwrap(); - let signer_address = signer.address().await.unwrap(); - - assert_eq!(test_key_address(), signer_address); - } - #[tokio::test] async fn test_address_evm_signer_turnkey() { let signer_model = SignerRepoModel { @@ -615,106 +584,6 @@ mod tests { } } - #[tokio::test] - async fn test_sign_transaction_with_vault_cloud_signer() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::VaultCloud(VaultCloudSignerConfig { - client_id: "test-client-id".to_string(), - client_secret: crate::models::SecretString::new("test-client-secret"), - org_id: "test-org-id".to_string(), - project_id: "test-project-id".to_string(), - app_name: "test-app".to_string(), - key_name: "test-key".to_string(), - }), - }; - - let signer = EvmSignerFactory::create_evm_signer(signer_model) - .await - .unwrap(); - - let transaction_data = NetworkTransactionData::Evm(EvmTransactionData { - from: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(), - to: Some("0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string()), - gas_price: Some(20000000000), - gas_limit: Some(21000), - nonce: Some(0), - value: U256::from(1000000000000000000u64), - data: Some("0x".to_string()), - chain_id: 1, - hash: None, - signature: None, - raw: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - speed: None, - }); - - let result = signer.sign_transaction(transaction_data).await; - assert!(result.is_ok()); - - if let Ok(SignTransactionResponse::Evm(evm_tx)) = result { - assert!(!evm_tx.hash.is_empty()); - assert!(!evm_tx.raw.is_empty()); - assert!(!evm_tx.signature.sig.is_empty()); - } else { - panic!("Expected successful EVM transaction signing"); - } - } - - #[tokio::test] - async fn test_address_consistency_across_vault_types() { - // Test that different vault types using the same key produce the same address - let key_bytes = test_key_bytes(); - - let signers = vec![ - EvmSignerFactory::create_evm_signer(SignerRepoModel { - id: "local".to_string(), - config: SignerConfig::Local(LocalSignerConfig { - raw_key: key_bytes.clone(), - }), - }) - .await - .unwrap(), - EvmSignerFactory::create_evm_signer(SignerRepoModel { - id: "vault".to_string(), - config: SignerConfig::Vault(VaultSignerConfig { - address: "https://vault.test.com".to_string(), - namespace: Some("test-namespace".to_string()), - role_id: crate::models::SecretString::new("test-role-id"), - secret_id: crate::models::SecretString::new("test-secret-id"), - key_name: "test-key".to_string(), - mount_point: Some("secret".to_string()), - }), - }) - .await - .unwrap(), - EvmSignerFactory::create_evm_signer(SignerRepoModel { - id: "vault_cloud".to_string(), - config: SignerConfig::VaultCloud(VaultCloudSignerConfig { - client_id: "test-client-id".to_string(), - client_secret: crate::models::SecretString::new("test-client-secret"), - org_id: "test-org-id".to_string(), - project_id: "test-project-id".to_string(), - app_name: "test-app".to_string(), - key_name: "test-key".to_string(), - }), - }) - .await - .unwrap(), - ]; - - let addresses: Vec
= - futures::future::try_join_all(signers.iter().map(|s| s.address())) - .await - .unwrap(); - - // All addresses should be identical since they use the same key - assert_eq!(addresses[0], addresses[1]); - assert_eq!(addresses[1], addresses[2]); - assert_eq!(addresses[0], test_key_address()); - } - #[tokio::test] async fn test_transaction_signing_with_different_vault_types() { // Test that different vault configurations can sign transactions correctly @@ -735,30 +604,17 @@ mod tests { speed: None, }); - let vault_configs = vec![ - ( - "vault", - SignerConfig::Vault(VaultSignerConfig { - address: "https://vault.test.com".to_string(), - namespace: Some("test-namespace".to_string()), - role_id: crate::models::SecretString::new("test-role-id"), - secret_id: crate::models::SecretString::new("test-secret-id"), - key_name: "test-key".to_string(), - mount_point: Some("secret".to_string()), - }), - ), - ( - "vault_cloud", - SignerConfig::VaultCloud(VaultCloudSignerConfig { - client_id: "test-client-id".to_string(), - client_secret: crate::models::SecretString::new("test-client-secret"), - org_id: "test-org-id".to_string(), - project_id: "test-project-id".to_string(), - app_name: "test-app".to_string(), - key_name: "test-key".to_string(), - }), - ), - ]; + let vault_configs = vec![( + "vault", + SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: crate::models::SecretString::new("test-role-id"), + secret_id: crate::models::SecretString::new("test-secret-id"), + key_name: "test-key".to_string(), + mount_point: Some("secret".to_string()), + }), + )]; for (name, config) in vault_configs { let signer_model = SignerRepoModel { diff --git a/src/services/signer/evm/vault_signer.rs b/src/services/signer/evm/vault_signer.rs new file mode 100644 index 000000000..98124d75b --- /dev/null +++ b/src/services/signer/evm/vault_signer.rs @@ -0,0 +1,371 @@ +//! # Vault Signer for EVM +//! +//! This module provides an EVM signer implementation that uses HashiCorp Vault's KV2 engine +//! for secure key management. The private key is fetched once during signer creation and cached +//! in memory for optimal performance. + +use async_trait::async_trait; +use once_cell::sync::Lazy; +use secrets::SecretVec; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use zeroize::Zeroizing; + +use crate::{ + domain::{ + SignDataRequest, SignDataResponse, SignDataResponseEvm, SignTransactionResponse, + SignTypedDataRequest, + }, + models::{Address, NetworkTransactionData, SignerError, SignerRepoModel, VaultSignerConfig}, + services::{ + signer::evm::{local_signer::LocalSigner, DataSignerTrait}, + vault::{VaultService, VaultServiceTrait}, + Signer, + }, +}; + +#[derive(Clone, Eq)] +struct VaultCacheKey { + address: String, + namespace: Option, + key_name: String, + mount_point: String, +} + +impl PartialEq for VaultCacheKey { + fn eq(&self, other: &Self) -> bool { + self.key_name == other.key_name + && self.mount_point == other.mount_point + && self.address == other.address + && self.namespace == other.namespace + } +} + +impl Hash for VaultCacheKey { + fn hash(&self, state: &mut H) { + self.key_name.hash(state); + self.mount_point.hash(state); + self.address.hash(state); + self.namespace.hash(state); + } +} + +// Global signer cache - HashMap keyed by VaultCacheKey +static VAULT_SIGNER_CACHE: Lazy>>> = + Lazy::new(|| RwLock::new(HashMap::new())); + +/// EVM signer that fetches private keys from HashiCorp Vault KV2 engine +#[derive(Clone)] +pub struct VaultSigner +where + T: VaultServiceTrait + Clone, +{ + key_name: String, + address: String, + namespace: Option, + mount_point: Option, + vault_service: T, + /// Cached local signer, wrapped in Arc> for thread-safe lazy initialization + local_signer: Arc>>>, +} + +impl VaultSigner { + pub fn new(vault_config: VaultSignerConfig, vault_service: T) -> Self { + Self { + key_name: vault_config.key_name, + address: vault_config.address, + namespace: vault_config.namespace, + mount_point: vault_config.mount_point, + vault_service, + local_signer: Arc::new(Mutex::new(None)), + } + } + + /// Ensures the local signer is loaded, using caching for performance + async fn get_local_signer(&self) -> Result, SignerError> { + // Fast path: check if already loaded + { + let guard = self.local_signer.lock().await; + if let Some(ref signer) = *guard { + return Ok(Arc::clone(signer)); + } + } + + // Check global cache + let cache_key = self.create_cache_key()?; + { + let cache = VAULT_SIGNER_CACHE.read().await; + if let Some(signer) = cache.get(&cache_key) { + // Update local cache + let mut guard = self.local_signer.lock().await; + *guard = Some(Arc::clone(signer)); + return Ok(Arc::clone(signer)); + } + } + + // Need to load from vault + let signer = self.load_signer_from_vault().await?; + let arc_signer = Arc::new(signer); + + // Update both caches + { + let mut cache = VAULT_SIGNER_CACHE.write().await; + cache.insert(cache_key, Arc::clone(&arc_signer)); + } + { + let mut guard = self.local_signer.lock().await; + *guard = Some(Arc::clone(&arc_signer)); + } + + Ok(arc_signer) + } + + /// Loads a new signer from vault + async fn load_signer_from_vault(&self) -> Result { + let raw_key = self.fetch_private_key().await?; + let local_config = crate::models::LocalSignerConfig { raw_key }; + let local_model = SignerRepoModel { + id: self.key_name.clone(), + config: crate::models::SignerConfig::Local(local_config), + }; + + LocalSigner::new(&local_model) + } + + /// Fetches private key from vault with proper error handling + async fn fetch_private_key(&self) -> Result, SignerError> { + let hex_secret = Zeroizing::new( + self.vault_service + .retrieve_secret(&self.key_name) + .await + .map_err(SignerError::VaultError)?, + ); + + // Validate hex format before decoding + let trimmed = hex_secret.trim(); + if trimmed.is_empty() { + return Err(SignerError::KeyError( + "Empty key received from vault".to_string(), + )); + } + + // Remove '0x' prefix if present + let hex_str = if trimmed.starts_with("0x") || trimmed.starts_with("0X") { + &trimmed[2..] + } else { + trimmed + }; + + // Validate hex characters + if !hex_str.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SignerError::KeyError( + "Invalid hex characters in vault secret".to_string(), + )); + } + + // Validate key length (32 bytes = 64 hex chars for secp256k1) + if hex_str.len() != 64 { + return Err(SignerError::KeyError(format!( + "Invalid key length: expected 64 hex characters, got {}", + hex_str.len() + ))); + } + + let decoded_bytes = hex::decode(hex_str) + .map_err(|e| SignerError::KeyError(format!("Failed to decode hex: {}", e)))?; + + Ok(SecretVec::new(decoded_bytes.len(), |buffer| { + buffer.copy_from_slice(&decoded_bytes); + })) + } + + fn create_cache_key(&self) -> Result { + Ok(VaultCacheKey { + address: self.address.clone(), + namespace: self.namespace.clone(), + key_name: self.key_name.clone(), + mount_point: self + .mount_point + .clone() + .unwrap_or_else(|| "secret".to_string()), + }) + } +} + +#[async_trait] +impl Signer for VaultSigner { + async fn address(&self) -> Result { + let signer = self.get_local_signer().await?; + signer.address().await + } + + async fn sign_transaction( + &self, + transaction: NetworkTransactionData, + ) -> Result { + let signer = self.get_local_signer().await?; + signer.sign_transaction(transaction).await + } +} + +#[async_trait] +impl DataSignerTrait for VaultSigner { + async fn sign_data(&self, request: SignDataRequest) -> Result { + let signer = self.get_local_signer().await?; + signer.sign_data(request).await + } + + async fn sign_typed_data( + &self, + request: SignTypedDataRequest, + ) -> Result { + let signer = self.get_local_signer().await?; + signer.sign_typed_data(request).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{SecretString, SignerConfig, VaultSignerConfig}; + use crate::services::vault::VaultError; + use async_trait::async_trait; + + // Mock VaultService for testing + #[derive(Clone)] + struct MockVaultService { + mock_secret: String, + } + + impl MockVaultService { + fn new(mock_secret: String) -> Self { + Self { mock_secret } + } + } + + #[async_trait] + impl VaultServiceTrait for MockVaultService { + async fn retrieve_secret(&self, _key_name: &str) -> Result { + Ok(self.mock_secret.clone()) + } + + async fn sign(&self, _key_name: &str, _message: &[u8]) -> Result { + Ok("mock_signature".to_string()) + } + } + + fn create_test_config() -> VaultSignerConfig { + VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: SecretString::new("test-role-id"), + secret_id: SecretString::new("test-secret-id"), + key_name: "test-key".to_string(), + mount_point: Some("secret".to_string()), + } + } + + #[tokio::test] + async fn test_valid_private_key() { + let config = create_test_config(); + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_service = MockVaultService::new(mock_private_key.to_string()); + + let signer = VaultSigner::new(config, mock_service); + let address_result = signer.address().await; + + assert!( + address_result.is_ok(), + "Signer should provide a valid address" + ); + } + + #[tokio::test] + async fn test_valid_private_key_with_0x_prefix() { + let config = create_test_config(); + let mock_private_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_service = MockVaultService::new(mock_private_key.to_string()); + + let signer = VaultSigner::new(config, mock_service); + let address_result = signer.address().await; + + assert!(address_result.is_ok(), "Signer should handle 0x prefix"); + } + + #[tokio::test] + async fn test_invalid_hex_characters() { + let config = create_test_config(); + let invalid_hex = "invalid_hex_string_with_non_hex_chars"; + let mock_service = MockVaultService::new(invalid_hex.to_string()); + + let signer = VaultSigner::new(config, mock_service); + let result = signer.address().await; + + assert!(result.is_err(), "Should fail with invalid hex characters"); + if let Err(SignerError::KeyError(msg)) = result { + assert!( + msg.contains("Invalid hex characters"), + "Error should mention invalid hex characters" + ); + } else { + panic!("Expected KeyError for invalid hex characters"); + } + } + + #[tokio::test] + async fn test_invalid_key_length() { + let config = create_test_config(); + let short_key = "4c0883a69102937d"; // Too short + let mock_service = MockVaultService::new(short_key.to_string()); + + let signer = VaultSigner::new(config, mock_service); + let result = signer.address().await; + + assert!(result.is_err(), "Should fail with invalid key length"); + if let Err(SignerError::KeyError(msg)) = result { + assert!( + msg.contains("Invalid key length"), + "Error should mention invalid key length" + ); + } else { + panic!("Expected KeyError for invalid key length"); + } + } + + #[tokio::test] + async fn test_empty_key() { + let config = create_test_config(); + let empty_key = ""; + let mock_service = MockVaultService::new(empty_key.to_string()); + + let signer = VaultSigner::new(config, mock_service); + let result = signer.address().await; + + assert!(result.is_err(), "Should fail with empty key"); + if let Err(SignerError::KeyError(msg)) = result { + assert!(msg.contains("Empty key"), "Error should mention empty key"); + } else { + panic!("Expected KeyError for empty key"); + } + } + + #[tokio::test] + async fn test_caching_behavior() { + let config = create_test_config(); + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_service = MockVaultService::new(mock_private_key.to_string()); + + let signer = VaultSigner::new(config, mock_service); + + // First call should load from vault + let address1 = signer.address().await; + assert!(address1.is_ok()); + + // Second call should use cached version + let address2 = signer.address().await; + assert!(address2.is_ok()); + assert_eq!(address1.unwrap(), address2.unwrap()); + } +} diff --git a/src/services/signer/solana/mod.rs b/src/services/signer/solana/mod.rs index 746913e89..3d1cca396 100644 --- a/src/services/signer/solana/mod.rs +++ b/src/services/signer/solana/mod.rs @@ -9,7 +9,6 @@ //! SolanaSigner //! ├── Local (Raw Key Signer) //! ├── Vault (HashiCorp Vault backend) -//! ├── VaultCloud (HashiCorp Cloud Vault backend) //! ├── VaultTransit (HashiCorp Vault Transit signer) //! |── GoogleCloudKms (Google Cloud KMS backend) //! └── Turnkey (Turnkey backend) @@ -37,7 +36,7 @@ use crate::{ }, models::{ Address, NetworkTransactionData, SignerConfig, SignerRepoModel, SignerType, - TransactionRepoModel, VaultCloudSignerConfig, VaultSignerConfig, + TransactionRepoModel, VaultSignerConfig, }, services::{GoogleCloudKmsService, TurnkeyService, VaultConfig, VaultService}, }; @@ -50,7 +49,6 @@ use mockall::automock; pub enum SolanaSigner { Local(LocalSigner), Vault(LocalSigner), - VaultCloud(LocalSigner), VaultTransit(VaultTransitSigner), Turnkey(TurnkeySigner), GoogleCloudKms(GoogleCloudKmsSigner), @@ -60,9 +58,7 @@ pub enum SolanaSigner { impl Signer for SolanaSigner { async fn address(&self) -> Result { match self { - Self::Local(signer) | Self::Vault(signer) | Self::VaultCloud(signer) => { - signer.address().await - } + Self::Local(signer) | Self::Vault(signer) => signer.address().await, Self::VaultTransit(signer) => signer.address().await, Self::Turnkey(signer) => signer.address().await, Self::GoogleCloudKms(signer) => signer.address().await, @@ -74,9 +70,7 @@ impl Signer for SolanaSigner { transaction: NetworkTransactionData, ) -> Result { match self { - Self::Local(signer) | Self::Vault(signer) | Self::VaultCloud(signer) => { - signer.sign_transaction(transaction).await - } + Self::Local(signer) | Self::Vault(signer) => signer.sign_transaction(transaction).await, Self::VaultTransit(signer) => signer.sign_transaction(transaction).await, Self::Turnkey(signer) => signer.sign_transaction(transaction).await, Self::GoogleCloudKms(signer) => signer.sign_transaction(transaction).await, @@ -110,9 +104,7 @@ pub trait SolanaSignTrait: Sync + Send { impl SolanaSignTrait for SolanaSigner { async fn pubkey(&self) -> Result { match self { - Self::Local(signer) | Self::Vault(signer) | Self::VaultCloud(signer) => { - signer.pubkey().await - } + Self::Local(signer) | Self::Vault(signer) => signer.pubkey().await, Self::VaultTransit(signer) => signer.pubkey().await, Self::Turnkey(signer) => signer.pubkey().await, Self::GoogleCloudKms(signer) => signer.pubkey().await, @@ -121,9 +113,7 @@ impl SolanaSignTrait for SolanaSigner { async fn sign(&self, message: &[u8]) -> Result { match self { - Self::Local(signer) | Self::Vault(signer) | Self::VaultCloud(signer) => { - Ok(signer.sign(message).await?) - } + Self::Local(signer) | Self::Vault(signer) => Ok(signer.sign(message).await?), Self::VaultTransit(signer) => Ok(signer.sign(message).await?), Self::Turnkey(signer) => Ok(signer.sign(message).await?), Self::GoogleCloudKms(signer) => Ok(signer.sign(message).await?), @@ -138,7 +128,7 @@ impl SolanaSignerFactory { signer_model: &SignerRepoModel, ) -> Result { let signer = match &signer_model.config { - SignerConfig::Local(_) | SignerConfig::Vault(_) | SignerConfig::VaultCloud(_) => { + SignerConfig::Local(_) | SignerConfig::Vault(_) => { SolanaSigner::Local(LocalSigner::new(signer_model)?) } SignerConfig::VaultTransit(vault_transit_signer_config) => { @@ -268,28 +258,6 @@ mod solana_signer_factory_tests { } } - #[test] - fn test_create_solana_signer_vault_cloud() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::VaultCloud(VaultCloudSignerConfig { - client_id: "test-client-id".to_string(), - client_secret: crate::models::SecretString::new("test-client-secret"), - org_id: "test-org-id".to_string(), - project_id: "test-project-id".to_string(), - app_name: "test-app".to_string(), - key_name: "test-key".to_string(), - }), - }; - - let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); - - match signer { - SolanaSigner::Local(_) => {} - _ => panic!("Expected Local signer"), - } - } - #[test] fn test_create_solana_signer_vault_transit() { let signer_model = SignerRepoModel { @@ -406,28 +374,6 @@ mod solana_signer_factory_tests { assert_eq!(test_key_bytes_pubkey(), signer_pubkey); } - #[tokio::test] - async fn test_address_solana_signer_vault_cloud() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::VaultCloud(VaultCloudSignerConfig { - client_id: "test-client-id".to_string(), - client_secret: crate::models::SecretString::new("test-client-secret"), - org_id: "test-org-id".to_string(), - project_id: "test-project-id".to_string(), - app_name: "test-app".to_string(), - key_name: "test-key".to_string(), - }), - }; - - let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); - let signer_address = signer.address().await.unwrap(); - let signer_pubkey = signer.pubkey().await.unwrap(); - - assert_eq!(test_key_bytes_pubkey(), signer_address); - assert_eq!(test_key_bytes_pubkey(), signer_pubkey); - } - #[tokio::test] async fn test_address_solana_signer_vault_transit() { let signer_model = SignerRepoModel { @@ -564,59 +510,4 @@ mod solana_signer_factory_tests { assert!(signature.is_ok()); } - - #[tokio::test] - async fn test_sign_solana_signer_vault_cloud() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::VaultCloud(VaultCloudSignerConfig { - client_id: "test-client-id".to_string(), - client_secret: crate::models::SecretString::new("test-client-secret"), - org_id: "test-org-id".to_string(), - project_id: "test-project-id".to_string(), - app_name: "test-app".to_string(), - key_name: "test-key".to_string(), - }), - }; - - let signer: SolanaSigner = - SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); - let message = b"test message"; - let signature = signer.sign(message).await; - - assert!(signature.is_ok()); - } - - #[tokio::test] - async fn test_sign_transaction_not_implemented() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::VaultCloud(VaultCloudSignerConfig { - client_id: "test-client-id".to_string(), - client_secret: crate::models::SecretString::new("test-client-secret"), - org_id: "test-org-id".to_string(), - project_id: "test-project-id".to_string(), - app_name: "test-app".to_string(), - key_name: "test-key".to_string(), - }), - }; - - let signer: SolanaSigner = - SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); - let transaction_data = NetworkTransactionData::Solana(SolanaTransactionData { - fee_payer: "test".to_string(), - hash: None, - recent_blockhash: None, - instructions: vec![], - }); - - let result = signer.sign_transaction(transaction_data).await; - - match result { - Err(SignerError::NotImplemented(msg)) => { - assert_eq!(msg, "sign_transaction is not implemented".to_string()); - } - _ => panic!("Expected SignerError::NotImplemented"), - } - } } diff --git a/src/services/signer/stellar/mod.rs b/src/services/signer/stellar/mod.rs index 0f0fa739b..d00ae00f7 100644 --- a/src/services/signer/stellar/mod.rs +++ b/src/services/signer/stellar/mod.rs @@ -17,14 +17,13 @@ use super::DataSignerTrait; pub enum StellarSigner { Local(LocalSigner), Vault(LocalSigner), - VaultCloud(LocalSigner), } #[async_trait] impl Signer for StellarSigner { async fn address(&self) -> Result { match self { - Self::Local(s) | Self::Vault(s) | Self::VaultCloud(s) => s.address().await, + Self::Local(s) | Self::Vault(s) => s.address().await, } } @@ -33,7 +32,7 @@ impl Signer for StellarSigner { tx: NetworkTransactionData, ) -> Result { match self { - Self::Local(s) | Self::Vault(s) | Self::VaultCloud(s) => s.sign_transaction(tx).await, + Self::Local(s) | Self::Vault(s) => s.sign_transaction(tx).await, } } } @@ -43,7 +42,7 @@ pub struct StellarSignerFactory; impl StellarSignerFactory { pub fn create_stellar_signer(m: &SignerRepoModel) -> Result { let signer = match m.config { - SignerConfig::Local(_) | SignerConfig::Vault(_) | SignerConfig::VaultCloud(_) => { + SignerConfig::Local(_) | SignerConfig::Vault(_) => { StellarSigner::Local(LocalSigner::new(m)?) } SignerConfig::AwsKms(_) => { diff --git a/tests/utils/test_keys/unit-test-local-signer.json b/tests/utils/test_keys/unit-test-local-signer.json index 5dfbb062b..f578d757d 100644 --- a/tests/utils/test_keys/unit-test-local-signer.json +++ b/tests/utils/test_keys/unit-test-local-signer.json @@ -1 +1 @@ -{"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"981dbc6e12b03661798018468552019d"},"ciphertext":"4956bb91cb0e02689f3d9dbd94dbd9c7e6438e44387869fb81a22c750d410b37","kdf":"scrypt","kdfparams":{"dklen":32,"n":8192,"p":1,"r":8,"salt":"8580c4df3ed0073a7467a83e6925ef523b33005487112877ba33e070c78ee2d3"},"mac":"82202935d7e4bda61633d5bf2152cdd122089dbe5df180c43f8996699f5310b5"},"id":"ebbaf818-1207-4b94-9f68-0be98d664062","version":3} +{"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"1f22be94c4f5af8e1b7ed4839844718e"},"ciphertext":"3a5de39a934f05de412f8b6a503c9b37480e5e75bbd943b2ea4595c73a7c47b0","kdf":"scrypt","kdfparams":{"dklen":32,"n":8192,"p":1,"r":8,"salt":"016200642e10b4093d8f16fcafbd4fe1fa3dc39e6c173e61252274006f0c8b67"},"mac":"997687605cfb66ca440a321e56a2c989320c08a132271376b1b7412b2e431cf4"},"id":"2af4511c-b352-41e0-bf6d-a63bfd530ce9","version":3} From bb2d2bcddb1f2d9976224dddd7e7afcafb2acd8c Mon Sep 17 00:00:00 2001 From: Zeljko Date: Fri, 18 Jul 2025 12:47:27 +0200 Subject: [PATCH 19/59] chore: impr --- src/models/signer/config.rs | 6 ++ src/services/signer/evm/mod.rs | 116 --------------------------------- 2 files changed, 6 insertions(+), 116 deletions(-) diff --git a/src/models/signer/config.rs b/src/models/signer/config.rs index 3a655b04c..9bf811e3f 100644 --- a/src/models/signer/config.rs +++ b/src/models/signer/config.rs @@ -227,6 +227,12 @@ impl TryFrom for LocalSignerConfig { ConfigFileError::InvalidFormat(format!("Failed to get passphrase value: {}", e)) })?; + if passphrase.is_empty() { + return Err(ConfigFileError::InvalidFormat( + "Local signer passphrase cannot be empty".into(), + )); + } + let raw_key = SecretVec::new(32, |buffer| { let loaded = oz_keystore::LocalClient::load( Path::new(&config.path).to_path_buf(), diff --git a/src/services/signer/evm/mod.rs b/src/services/signer/evm/mod.rs index e9046a52d..1486733f0 100644 --- a/src/services/signer/evm/mod.rs +++ b/src/services/signer/evm/mod.rs @@ -353,28 +353,6 @@ mod tests { assert_eq!(test_key_address(), signer_address); } - #[tokio::test] - async fn test_address_evm_signer_vault() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::Vault(VaultSignerConfig { - address: "https://vault.test.com".to_string(), - namespace: Some("test-namespace".to_string()), - role_id: crate::models::SecretString::new("test-role-id"), - secret_id: crate::models::SecretString::new("test-secret-id"), - key_name: "test-key".to_string(), - mount_point: Some("secret".to_string()), - }), - }; - - let signer = EvmSignerFactory::create_evm_signer(signer_model) - .await - .unwrap(); - let signer_address = signer.address().await.unwrap(); - - assert_eq!(test_key_address(), signer_address); - } - #[tokio::test] async fn test_address_evm_signer_turnkey() { let signer_model = SignerRepoModel { @@ -548,98 +526,4 @@ mod tests { } } } - - #[tokio::test] - async fn test_sign_data_with_vault_signer() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::Vault(VaultSignerConfig { - address: "https://vault.test.com".to_string(), - namespace: Some("test-namespace".to_string()), - role_id: crate::models::SecretString::new("test-role-id"), - secret_id: crate::models::SecretString::new("test-secret-id"), - key_name: "test-key".to_string(), - mount_point: Some("secret".to_string()), - }), - }; - - let signer = EvmSignerFactory::create_evm_signer(signer_model) - .await - .unwrap(); - - let request = SignDataRequest { - message: "Test vault message".to_string(), - }; - - let result = signer.sign_data(request).await; - assert!(result.is_ok()); - - if let Ok(SignDataResponse::Evm(sig)) = result { - assert_eq!(sig.r.len(), 64); - assert_eq!(sig.s.len(), 64); - assert!(sig.v == 27 || sig.v == 28); - assert_eq!(sig.sig.len(), 130); - } else { - panic!("Expected successful EVM signature"); - } - } - - #[tokio::test] - async fn test_transaction_signing_with_different_vault_types() { - // Test that different vault configurations can sign transactions correctly - let transaction_data = NetworkTransactionData::Evm(EvmTransactionData { - from: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(), - to: Some("0x742d35Cc6634C0532925a3b844Bc454e4438f44f".to_string()), - gas_price: Some(20000000000), - gas_limit: Some(21000), - nonce: Some(0), - value: U256::from(1000000000000000000u64), - data: Some("0x".to_string()), - chain_id: 1, - hash: None, - signature: None, - raw: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - speed: None, - }); - - let vault_configs = vec![( - "vault", - SignerConfig::Vault(VaultSignerConfig { - address: "https://vault.test.com".to_string(), - namespace: Some("test-namespace".to_string()), - role_id: crate::models::SecretString::new("test-role-id"), - secret_id: crate::models::SecretString::new("test-secret-id"), - key_name: "test-key".to_string(), - mount_point: Some("secret".to_string()), - }), - )]; - - for (name, config) in vault_configs { - let signer_model = SignerRepoModel { - id: name.to_string(), - config, - }; - - let signer = EvmSignerFactory::create_evm_signer(signer_model) - .await - .unwrap(); - - let result = signer.sign_transaction(transaction_data.clone()).await; - assert!(result.is_ok(), "Failed to sign transaction with {}", name); - - if let Ok(SignTransactionResponse::Evm(evm_tx)) = result { - assert!(!evm_tx.hash.is_empty(), "Empty hash for {}", name); - assert!(!evm_tx.raw.is_empty(), "Empty raw for {}", name); - assert!( - !evm_tx.signature.sig.is_empty(), - "Empty signature for {}", - name - ); - } else { - panic!("Expected EVM transaction response for {}", name); - } - } - } } From 87500fa8c664d7862a09554ca586314c7dd54d49 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Fri, 18 Jul 2025 13:49:22 +0200 Subject: [PATCH 20/59] chore: fix clippy --- src/models/notification/mod.rs | 355 +++++++- src/models/notification/notification.rs | 352 -------- src/models/notification/response.rs | 4 +- src/models/signer/config.rs | 8 +- src/models/signer/mod.rs | 845 +++++++++++++++++- src/models/signer/repository.rs | 14 +- src/models/signer/request.rs | 4 +- src/models/signer/response.rs | 2 +- src/models/signer/signer.rs | 840 ----------------- src/repositories/relayer/relayer_in_memory.rs | 2 +- src/repositories/relayer/relayer_redis.rs | 7 +- src/services/signer/evm/mod.rs | 6 +- src/services/signer/evm/vault_signer.rs | 50 +- src/services/signer/solana/mod.rs | 125 +-- src/services/signer/solana/vault_signer.rs | 373 ++++++++ 15 files changed, 1693 insertions(+), 1294 deletions(-) delete mode 100644 src/models/notification/notification.rs delete mode 100644 src/models/signer/signer.rs create mode 100644 src/services/signer/solana/vault_signer.rs diff --git a/src/models/notification/mod.rs b/src/models/notification/mod.rs index b1285b356..0344b1c90 100644 --- a/src/models/notification/mod.rs +++ b/src/models/notification/mod.rs @@ -1,5 +1,14 @@ -mod notification; -pub use notification::*; +//! Notification domain model and business logic. +//! +//! This module provides the central `Notification` type that represents notifications +//! throughout the relayer system, including: +//! +//! - **Domain Model**: Core `Notification` struct with validation +//! - **Business Logic**: Update operations and validation rules +//! - **Error Handling**: Comprehensive validation error types +//! - **Interoperability**: Conversions between API, config, and repository representations +//! +//! The notification model supports webhook-based notifications with optional message signing. mod config; pub use config::*; @@ -15,3 +24,345 @@ pub use repository::NotificationRepoModel; mod webhook_notification; pub use webhook_notification::*; + +use crate::{ + constants::{ID_REGEX, MINIMUM_SECRET_VALUE_LENGTH}, + models::SecretString, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use validator::{Validate, ValidationError}; + +/// Notification type enum used by both config file and API +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum NotificationType { + Webhook, +} + +/// Notification model used by both config file and API +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct Notification { + #[validate( + length(min = 1, max = 36, message = "ID must be between 1 and 36 characters"), + regex( + path = "*ID_REGEX", + message = "ID must contain only letters, numbers, dashes and underscores" + ) + )] + pub id: String, + pub notification_type: NotificationType, + #[validate(url(message = "Invalid URL format"))] + pub url: String, + #[validate(custom(function = "validate_signing_key"))] + pub signing_key: Option, +} + +/// Custom validator for signing key - validator handles Option automatically +fn validate_signing_key(signing_key: &SecretString) -> Result<(), ValidationError> { + let is_valid = signing_key.as_str(|key_str| key_str.len() >= MINIMUM_SECRET_VALUE_LENGTH); + if !is_valid { + return Err(ValidationError::new("signing_key_too_short")); + } + Ok(()) +} + +impl Notification { + /// Creates a new notification + pub fn new( + id: String, + notification_type: NotificationType, + url: String, + signing_key: Option, + ) -> Self { + Self { + id, + notification_type, + url, + signing_key, + } + } + + /// Validates the notification using the validator crate + pub fn validate(&self) -> Result<(), NotificationValidationError> { + Validate::validate(self).map_err(|validation_errors| { + // Convert validator errors to our custom error type + // Return the first error for simplicity + for (field, errors) in validation_errors.field_errors() { + if let Some(error) = errors.first() { + let field_str = field.as_ref(); + return match (field_str, error.code.as_ref()) { + ("id", "length") => NotificationValidationError::InvalidIdFormat, + ("id", "regex") => NotificationValidationError::InvalidIdFormat, + ("url", _) => NotificationValidationError::InvalidUrl, + ("signing_key", "signing_key_too_short") => { + NotificationValidationError::signing_key_too_short() + } + _ => NotificationValidationError::InvalidIdFormat, // fallback + }; + } + } + // Fallback error + NotificationValidationError::InvalidIdFormat + }) + } + + /// Applies an update request to create a new validated notification + /// + /// This method provides a domain-first approach where the core model handles + /// its own business rules and validation rather than having update logic + /// scattered across request models. + /// + /// # Arguments + /// * `request` - The update request containing partial data to apply + /// + /// # Returns + /// * `Ok(Notification)` - A new validated notification with updates applied + /// * `Err(NotificationValidationError)` - If the resulting notification would be invalid + pub fn apply_update( + &self, + request: &NotificationUpdateRequest, + ) -> Result { + let mut updated = self.clone(); + + // Apply updates from request + if let Some(notification_type) = &request.r#type { + updated.notification_type = notification_type.clone(); + } + + if let Some(url) = &request.url { + updated.url = url.clone(); + } + + if let Some(signing_key) = &request.signing_key { + updated.signing_key = if signing_key.is_empty() { + // Empty string means remove the signing key + None + } else { + // Non-empty string means update the signing key + Some(SecretString::new(signing_key)) + }; + } + + // Validate the complete updated model + updated.validate()?; + + Ok(updated) + } +} + +/// Common validation errors for notifications +#[derive(Debug, thiserror::Error)] +pub enum NotificationValidationError { + #[error("Notification ID cannot be empty")] + EmptyId, + #[error("Notification ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long")] + InvalidIdFormat, + #[error("Notification URL cannot be empty")] + EmptyUrl, + #[error("Invalid notification URL format")] + InvalidUrl, + #[error("Signing key must be at least {0} characters long")] + SigningKeyTooShort(usize), +} + +impl NotificationValidationError { + pub fn signing_key_too_short() -> Self { + Self::SigningKeyTooShort(MINIMUM_SECRET_VALUE_LENGTH) + } +} + +/// Centralized conversion from NotificationValidationError to ApiError +impl From for crate::models::ApiError { + fn from(error: NotificationValidationError) -> Self { + use crate::models::ApiError; + + ApiError::BadRequest(match error { + NotificationValidationError::EmptyId => "ID cannot be empty".to_string(), + NotificationValidationError::InvalidIdFormat => { + "ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long".to_string() + } + NotificationValidationError::EmptyUrl => "URL cannot be empty".to_string(), + NotificationValidationError::InvalidUrl => "Invalid URL format".to_string(), + NotificationValidationError::SigningKeyTooShort(min_len) => { + format!("Signing key must be at least {} characters long", min_len) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_notification() { + let notification = Notification::new( + "valid-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + Some(SecretString::new(&"a".repeat(32))), + ); + + assert!(notification.validate().is_ok()); + } + + #[test] + fn test_empty_id() { + let notification = Notification::new( + "".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + None, + ); + + assert!(matches!( + notification.validate(), + Err(NotificationValidationError::InvalidIdFormat) + )); + } + + #[test] + fn test_id_too_long() { + let notification = Notification::new( + "a".repeat(37), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + None, + ); + + assert!(matches!( + notification.validate(), + Err(NotificationValidationError::InvalidIdFormat) + )); + } + + #[test] + fn test_invalid_id_format() { + let notification = Notification::new( + "invalid@id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + None, + ); + + assert!(matches!( + notification.validate(), + Err(NotificationValidationError::InvalidIdFormat) + )); + } + + #[test] + fn test_invalid_url() { + let notification = Notification::new( + "valid-id".to_string(), + NotificationType::Webhook, + "not-a-url".to_string(), + None, + ); + + assert!(matches!( + notification.validate(), + Err(NotificationValidationError::InvalidUrl) + )); + } + + #[test] + fn test_signing_key_too_short() { + let notification = Notification::new( + "valid-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + Some(SecretString::new("short")), + ); + + assert!(matches!( + notification.validate(), + Err(NotificationValidationError::SigningKeyTooShort(_)) + )); + } + + #[test] + fn test_apply_update_success() { + let original = Notification::new( + "test-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + Some(SecretString::new(&"a".repeat(32))), + ); + + let update_request = NotificationUpdateRequest { + r#type: None, // Keep existing type + url: Some("https://updated.example.com/webhook".to_string()), + signing_key: Some("b".repeat(32)), // Update signing key + }; + + let result = original.apply_update(&update_request); + assert!(result.is_ok()); + + let updated = result.unwrap(); + assert_eq!(updated.id, "test-id"); // ID should remain unchanged + assert_eq!(updated.notification_type, NotificationType::Webhook); // Type unchanged + assert_eq!(updated.url, "https://updated.example.com/webhook"); // URL updated + assert!(updated.signing_key.is_some()); // Signing key updated + } + + #[test] + fn test_apply_update_remove_signing_key() { + let original = Notification::new( + "test-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + Some(SecretString::new(&"a".repeat(32))), + ); + + let update_request = NotificationUpdateRequest { + r#type: None, + url: None, + signing_key: Some("".to_string()), // Empty string removes signing key + }; + + let result = original.apply_update(&update_request); + assert!(result.is_ok()); + + let updated = result.unwrap(); + assert_eq!(updated.id, "test-id"); + assert_eq!(updated.url, "https://example.com/webhook"); // URL unchanged + assert!(updated.signing_key.is_none()); // Signing key removed + } + + #[test] + fn test_apply_update_validation_failure() { + let original = Notification::new( + "test-id".to_string(), + NotificationType::Webhook, + "https://example.com/webhook".to_string(), + None, + ); + + let update_request = NotificationUpdateRequest { + r#type: None, + url: Some("not-a-valid-url".to_string()), // Invalid URL + signing_key: None, + }; + + let result = original.apply_update(&update_request); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + NotificationValidationError::InvalidUrl + )); + } + + #[test] + fn test_error_conversion_to_api_error() { + let error = NotificationValidationError::InvalidUrl; + let api_error: crate::models::ApiError = error.into(); + + if let crate::models::ApiError::BadRequest(msg) = api_error { + assert_eq!(msg, "Invalid URL format"); + } else { + panic!("Expected BadRequest error"); + } + } +} diff --git a/src/models/notification/notification.rs b/src/models/notification/notification.rs deleted file mode 100644 index bb5dc80ec..000000000 --- a/src/models/notification/notification.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! Notification domain model and business logic. -//! -//! This module provides the central `Notification` type that represents notifications -//! throughout the relayer system, including: -//! -//! - **Domain Model**: Core `Notification` struct with validation -//! - **Business Logic**: Update operations and validation rules -//! - **Error Handling**: Comprehensive validation error types -//! - **Interoperability**: Conversions between API, config, and repository representations -//! -//! The notification model supports webhook-based notifications with optional message signing. -use crate::{ - constants::{ID_REGEX, MINIMUM_SECRET_VALUE_LENGTH}, - models::{NotificationUpdateRequest, SecretString}, -}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use validator::{Validate, ValidationError}; - -/// Notification type enum used by both config file and API -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum NotificationType { - Webhook, -} - -/// Notification model used by both config file and API -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct Notification { - #[validate( - length(min = 1, max = 36, message = "ID must be between 1 and 36 characters"), - regex( - path = "*ID_REGEX", - message = "ID must contain only letters, numbers, dashes and underscores" - ) - )] - pub id: String, - pub notification_type: NotificationType, - #[validate(url(message = "Invalid URL format"))] - pub url: String, - #[validate(custom(function = "validate_signing_key"))] - pub signing_key: Option, -} - -/// Custom validator for signing key - validator handles Option automatically -fn validate_signing_key(signing_key: &SecretString) -> Result<(), ValidationError> { - let is_valid = signing_key.as_str(|key_str| key_str.len() >= MINIMUM_SECRET_VALUE_LENGTH); - if !is_valid { - return Err(ValidationError::new("signing_key_too_short")); - } - Ok(()) -} - -impl Notification { - /// Creates a new notification - pub fn new( - id: String, - notification_type: NotificationType, - url: String, - signing_key: Option, - ) -> Self { - Self { - id, - notification_type, - url, - signing_key, - } - } - - /// Validates the notification using the validator crate - pub fn validate(&self) -> Result<(), NotificationValidationError> { - Validate::validate(self).map_err(|validation_errors| { - // Convert validator errors to our custom error type - // Return the first error for simplicity - for (field, errors) in validation_errors.field_errors() { - if let Some(error) = errors.first() { - let field_str = field.as_ref(); - return match (field_str, error.code.as_ref()) { - ("id", "length") => NotificationValidationError::InvalidIdFormat, - ("id", "regex") => NotificationValidationError::InvalidIdFormat, - ("url", _) => NotificationValidationError::InvalidUrl, - ("signing_key", "signing_key_too_short") => { - NotificationValidationError::signing_key_too_short() - } - _ => NotificationValidationError::InvalidIdFormat, // fallback - }; - } - } - // Fallback error - NotificationValidationError::InvalidIdFormat - }) - } - - /// Applies an update request to create a new validated notification - /// - /// This method provides a domain-first approach where the core model handles - /// its own business rules and validation rather than having update logic - /// scattered across request models. - /// - /// # Arguments - /// * `request` - The update request containing partial data to apply - /// - /// # Returns - /// * `Ok(Notification)` - A new validated notification with updates applied - /// * `Err(NotificationValidationError)` - If the resulting notification would be invalid - pub fn apply_update( - &self, - request: &NotificationUpdateRequest, - ) -> Result { - let mut updated = self.clone(); - - // Apply updates from request - if let Some(notification_type) = &request.r#type { - updated.notification_type = notification_type.clone(); - } - - if let Some(url) = &request.url { - updated.url = url.clone(); - } - - if let Some(signing_key) = &request.signing_key { - updated.signing_key = if signing_key.is_empty() { - // Empty string means remove the signing key - None - } else { - // Non-empty string means update the signing key - Some(SecretString::new(signing_key)) - }; - } - - // Validate the complete updated model - updated.validate()?; - - Ok(updated) - } -} - -/// Common validation errors for notifications -#[derive(Debug, thiserror::Error)] -pub enum NotificationValidationError { - #[error("Notification ID cannot be empty")] - EmptyId, - #[error("Notification ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long")] - InvalidIdFormat, - #[error("Notification URL cannot be empty")] - EmptyUrl, - #[error("Invalid notification URL format")] - InvalidUrl, - #[error("Signing key must be at least {0} characters long")] - SigningKeyTooShort(usize), -} - -impl NotificationValidationError { - pub fn signing_key_too_short() -> Self { - Self::SigningKeyTooShort(MINIMUM_SECRET_VALUE_LENGTH) - } -} - -/// Centralized conversion from NotificationValidationError to ApiError -impl From for crate::models::ApiError { - fn from(error: NotificationValidationError) -> Self { - use crate::models::ApiError; - - ApiError::BadRequest(match error { - NotificationValidationError::EmptyId => "ID cannot be empty".to_string(), - NotificationValidationError::InvalidIdFormat => { - "ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long".to_string() - } - NotificationValidationError::EmptyUrl => "URL cannot be empty".to_string(), - NotificationValidationError::InvalidUrl => "Invalid URL format".to_string(), - NotificationValidationError::SigningKeyTooShort(min_len) => { - format!("Signing key must be at least {} characters long", min_len) - } - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_valid_notification() { - let notification = Notification::new( - "valid-id".to_string(), - NotificationType::Webhook, - "https://example.com/webhook".to_string(), - Some(SecretString::new(&"a".repeat(32))), - ); - - assert!(notification.validate().is_ok()); - } - - #[test] - fn test_empty_id() { - let notification = Notification::new( - "".to_string(), - NotificationType::Webhook, - "https://example.com/webhook".to_string(), - None, - ); - - assert!(matches!( - notification.validate(), - Err(NotificationValidationError::InvalidIdFormat) - )); - } - - #[test] - fn test_id_too_long() { - let notification = Notification::new( - "a".repeat(37), - NotificationType::Webhook, - "https://example.com/webhook".to_string(), - None, - ); - - assert!(matches!( - notification.validate(), - Err(NotificationValidationError::InvalidIdFormat) - )); - } - - #[test] - fn test_invalid_id_format() { - let notification = Notification::new( - "invalid@id".to_string(), - NotificationType::Webhook, - "https://example.com/webhook".to_string(), - None, - ); - - assert!(matches!( - notification.validate(), - Err(NotificationValidationError::InvalidIdFormat) - )); - } - - #[test] - fn test_invalid_url() { - let notification = Notification::new( - "valid-id".to_string(), - NotificationType::Webhook, - "not-a-url".to_string(), - None, - ); - - assert!(matches!( - notification.validate(), - Err(NotificationValidationError::InvalidUrl) - )); - } - - #[test] - fn test_signing_key_too_short() { - let notification = Notification::new( - "valid-id".to_string(), - NotificationType::Webhook, - "https://example.com/webhook".to_string(), - Some(SecretString::new("short")), - ); - - assert!(matches!( - notification.validate(), - Err(NotificationValidationError::SigningKeyTooShort(_)) - )); - } - - #[test] - fn test_apply_update_success() { - let original = Notification::new( - "test-id".to_string(), - NotificationType::Webhook, - "https://example.com/webhook".to_string(), - Some(SecretString::new(&"a".repeat(32))), - ); - - let update_request = NotificationUpdateRequest { - r#type: None, // Keep existing type - url: Some("https://updated.example.com/webhook".to_string()), - signing_key: Some("b".repeat(32)), // Update signing key - }; - - let result = original.apply_update(&update_request); - assert!(result.is_ok()); - - let updated = result.unwrap(); - assert_eq!(updated.id, "test-id"); // ID should remain unchanged - assert_eq!(updated.notification_type, NotificationType::Webhook); // Type unchanged - assert_eq!(updated.url, "https://updated.example.com/webhook"); // URL updated - assert!(updated.signing_key.is_some()); // Signing key updated - } - - #[test] - fn test_apply_update_remove_signing_key() { - let original = Notification::new( - "test-id".to_string(), - NotificationType::Webhook, - "https://example.com/webhook".to_string(), - Some(SecretString::new(&"a".repeat(32))), - ); - - let update_request = NotificationUpdateRequest { - r#type: None, - url: None, - signing_key: Some("".to_string()), // Empty string removes signing key - }; - - let result = original.apply_update(&update_request); - assert!(result.is_ok()); - - let updated = result.unwrap(); - assert_eq!(updated.id, "test-id"); - assert_eq!(updated.url, "https://example.com/webhook"); // URL unchanged - assert!(updated.signing_key.is_none()); // Signing key removed - } - - #[test] - fn test_apply_update_validation_failure() { - let original = Notification::new( - "test-id".to_string(), - NotificationType::Webhook, - "https://example.com/webhook".to_string(), - None, - ); - - let update_request = NotificationUpdateRequest { - r#type: None, - url: Some("not-a-valid-url".to_string()), // Invalid URL - signing_key: None, - }; - - let result = original.apply_update(&update_request); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - NotificationValidationError::InvalidUrl - )); - } - - #[test] - fn test_error_conversion_to_api_error() { - let error = NotificationValidationError::InvalidUrl; - let api_error: crate::models::ApiError = error.into(); - - if let crate::models::ApiError::BadRequest(msg) = api_error { - assert_eq!(msg, "Invalid URL format"); - } else { - panic!("Expected BadRequest error"); - } - } -} diff --git a/src/models/notification/response.rs b/src/models/notification/response.rs index ab3a14b22..6064179d0 100644 --- a/src/models/notification/response.rs +++ b/src/models/notification/response.rs @@ -50,7 +50,7 @@ mod tests { assert_eq!(response.id, "test-id"); assert_eq!(response.r#type, NotificationType::Webhook); assert_eq!(response.url, "https://example.com/webhook"); - assert_eq!(response.has_signing_key, true); + assert!(response.has_signing_key); } #[test] @@ -67,6 +67,6 @@ mod tests { assert_eq!(response.id, "test-id"); assert_eq!(response.r#type, NotificationType::Webhook); assert_eq!(response.url, "https://example.com/webhook"); - assert_eq!(response.has_signing_key, false); + assert!(!response.has_signing_key); } } diff --git a/src/models/signer/config.rs b/src/models/signer/config.rs index 9bf811e3f..fc1e3c5b1 100644 --- a/src/models/signer/config.rs +++ b/src/models/signer/config.rs @@ -11,7 +11,7 @@ use crate::{ config::ConfigFileError, - models::signer::signer::{ + models::signer::{ AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, Signer, SignerConfig, TurnkeySignerConfig, VaultSignerConfig, VaultTransitSignerConfig, @@ -418,13 +418,13 @@ impl TryFrom for Signer { // Validate using domain model validation logic signer.validate().map_err(|e| match e { - crate::models::signer::signer::SignerValidationError::EmptyId => { + crate::models::signer::SignerValidationError::EmptyId => { ConfigFileError::MissingField("signer id".into()) } - crate::models::signer::signer::SignerValidationError::InvalidIdFormat => { + crate::models::signer::SignerValidationError::InvalidIdFormat => { ConfigFileError::InvalidFormat("Invalid signer ID format".into()) } - crate::models::signer::signer::SignerValidationError::InvalidConfig(msg) => { + crate::models::signer::SignerValidationError::InvalidConfig(msg) => { ConfigFileError::InvalidFormat(format!("Invalid signer configuration: {}", msg)) } })?; diff --git a/src/models/signer/mod.rs b/src/models/signer/mod.rs index cd4aeddf3..e2446d92c 100644 --- a/src/models/signer/mod.rs +++ b/src/models/signer/mod.rs @@ -1,4 +1,16 @@ -//! Signer models +//! Core signer domain model and business logic. +//! +//! This module provides the central `Signer` type that represents signers +//! throughout the relayer system, including: +//! +//! - **Domain Model**: Core `Signer` struct with validation and configuration +//! - **Business Logic**: Update operations and validation rules +//! - **Error Handling**: Comprehensive validation error types +//! - **Interoperability**: Conversions between API, config, and repository representations +//! +//! The signer model supports multiple signer types including local keys, AWS KMS, +//! Google Cloud KMS, Vault, and Turnkey service integrations. + mod repository; pub use repository::{ AwsKmsSignerConfigStorage, GoogleCloudKmsSignerConfigStorage, @@ -10,11 +22,836 @@ pub use repository::{ mod config; pub use config::*; -pub mod signer; -pub use signer::*; - mod request; pub use request::*; mod response; pub use response::*; + +use crate::{constants::ID_REGEX, models::SecretString}; +use secrets::SecretVec; +use serde::{Deserialize, Serialize, Serializer}; +use utoipa::ToSchema; +use validator::Validate; + +/// Helper function to serialize secrets as redacted +fn serialize_secret_redacted(_secret: &SecretVec, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str("[REDACTED]") +} + +/// Local signer configuration for storing private keys +#[derive(Debug, Clone, Serialize)] +pub struct LocalSignerConfig { + #[serde(serialize_with = "serialize_secret_redacted")] + pub raw_key: SecretVec, +} + +impl LocalSignerConfig { + /// Validates the raw key for cryptographic requirements + pub fn validate(&self) -> Result<(), SignerValidationError> { + let key_bytes = self.raw_key.borrow(); + + // Check key length - must be exactly 32 bytes for crypto operations + if key_bytes.len() != 32 { + return Err(SignerValidationError::InvalidConfig(format!( + "Raw key must be exactly 32 bytes, got {} bytes", + key_bytes.len() + ))); + } + + // Check if key is all zeros (cryptographically invalid) + if key_bytes.iter().all(|&b| b == 0) { + return Err(SignerValidationError::InvalidConfig( + "Raw key cannot be all zeros".to_string(), + )); + } + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for LocalSignerConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct LocalSignerConfigHelper { + raw_key: String, + } + + let helper = LocalSignerConfigHelper::deserialize(deserializer)?; + let raw_key = if helper.raw_key == "[REDACTED]" { + // Return a zero-filled SecretVec when deserializing redacted data + SecretVec::zero(32) + } else { + // For actual data, assume it's the raw bytes represented as a string + // In practice, this would come from proper key loading + SecretVec::new(helper.raw_key.len(), |v| { + v.copy_from_slice(helper.raw_key.as_bytes()) + }) + }; + + Ok(LocalSignerConfig { raw_key }) + } +} + +/// AWS KMS signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct AwsKmsSignerConfig { + #[validate(length(min = 1, message = "Region cannot be empty"))] + pub region: Option, + #[validate(length(min = 1, message = "Key ID cannot be empty"))] + pub key_id: String, +} + +/// Vault signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct VaultSignerConfig { + #[validate(url(message = "Address must be a valid URL"))] + pub address: String, + pub namespace: Option, + #[validate(custom( + function = "validate_secret_string", + message = "Role ID cannot be empty" + ))] + pub role_id: SecretString, + #[validate(custom( + function = "validate_secret_string", + message = "Secret ID cannot be empty" + ))] + pub secret_id: SecretString, + #[validate(length(min = 1, message = "Vault key name cannot be empty"))] + pub key_name: String, + pub mount_point: Option, +} + +/// Vault Transit signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct VaultTransitSignerConfig { + #[validate(length(min = 1, message = "Key name cannot be empty"))] + pub key_name: String, + #[validate(url(message = "Address must be a valid URL"))] + pub address: String, + pub namespace: Option, + #[validate(custom( + function = "validate_secret_string", + message = "Role ID cannot be empty" + ))] + pub role_id: SecretString, + #[validate(custom( + function = "validate_secret_string", + message = "Secret ID cannot be empty" + ))] + pub secret_id: SecretString, + #[validate(length(min = 1, message = "pubkey cannot be empty"))] + pub pubkey: String, + pub mount_point: Option, +} + +/// Turnkey signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct TurnkeySignerConfig { + #[validate(length(min = 1, message = "API public key cannot be empty"))] + pub api_public_key: String, + #[validate(custom( + function = "validate_secret_string", + message = "API private key cannot be empty" + ))] + pub api_private_key: SecretString, + #[validate(length(min = 1, message = "Organization ID cannot be empty"))] + pub organization_id: String, + #[validate(length(min = 1, message = "Private key ID cannot be empty"))] + pub private_key_id: String, + #[validate(length(min = 1, message = "Public key cannot be empty"))] + pub public_key: String, +} + +/// Google Cloud KMS service account configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct GoogleCloudKmsSignerServiceAccountConfig { + #[validate(custom( + function = "validate_secret_string", + message = "Private key cannot be empty" + ))] + pub private_key: SecretString, + #[validate(custom( + function = "validate_secret_string", + message = "Private key ID cannot be empty" + ))] + pub private_key_id: SecretString, + #[validate(length(min = 1, message = "Project ID cannot be empty"))] + pub project_id: String, + #[validate(custom( + function = "validate_secret_string", + message = "Client email cannot be empty" + ))] + pub client_email: SecretString, + #[validate(length(min = 1, message = "Client ID cannot be empty"))] + pub client_id: String, + #[validate(url(message = "Auth URI must be a valid URL"))] + pub auth_uri: String, + #[validate(url(message = "Token URI must be a valid URL"))] + pub token_uri: String, + #[validate(url(message = "Auth provider x509 cert URL must be a valid URL"))] + pub auth_provider_x509_cert_url: String, + #[validate(url(message = "Client x509 cert URL must be a valid URL"))] + pub client_x509_cert_url: String, + pub universe_domain: String, +} + +/// Google Cloud KMS key configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct GoogleCloudKmsSignerKeyConfig { + pub location: String, + #[validate(length(min = 1, message = "Key ring ID cannot be empty"))] + pub key_ring_id: String, + #[validate(length(min = 1, message = "Key ID cannot be empty"))] + pub key_id: String, + pub key_version: u32, +} + +/// Google Cloud KMS signer configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct GoogleCloudKmsSignerConfig { + #[validate(nested)] + pub service_account: GoogleCloudKmsSignerServiceAccountConfig, + #[validate(nested)] + pub key: GoogleCloudKmsSignerKeyConfig, +} + +/// Custom validator for SecretString +fn validate_secret_string(secret: &SecretString) -> Result<(), validator::ValidationError> { + if secret.to_str().is_empty() { + return Err(validator::ValidationError::new("empty_secret")); + } + Ok(()) +} + +/// Domain signer configuration enum containing all supported signer types +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SignerConfig { + Local(LocalSignerConfig), + Vault(VaultSignerConfig), + VaultTransit(VaultTransitSignerConfig), + AwsKms(AwsKmsSignerConfig), + Turnkey(TurnkeySignerConfig), + GoogleCloudKms(GoogleCloudKmsSignerConfig), +} + +impl SignerConfig { + /// Validates the configuration using the appropriate validator + pub fn validate(&self) -> Result<(), SignerValidationError> { + match self { + Self::Local(config) => config.validate(), + Self::AwsKms(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "AWS KMS validation failed: {}", + format_validation_errors(&e) + )) + }), + Self::Vault(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "Vault validation failed: {}", + format_validation_errors(&e) + )) + }), + Self::VaultTransit(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "Vault Transit validation failed: {}", + format_validation_errors(&e) + )) + }), + Self::Turnkey(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "Turnkey validation failed: {}", + format_validation_errors(&e) + )) + }), + Self::GoogleCloudKms(config) => Validate::validate(config).map_err(|e| { + SignerValidationError::InvalidConfig(format!( + "Google Cloud KMS validation failed: {}", + format_validation_errors(&e) + )) + }), + } + } + + /// Get local signer config if this is a local signer + pub fn get_local(&self) -> Option<&LocalSignerConfig> { + match self { + Self::Local(config) => Some(config), + _ => None, + } + } + + /// Get AWS KMS signer config if this is an AWS KMS signer + pub fn get_aws_kms(&self) -> Option<&AwsKmsSignerConfig> { + match self { + Self::AwsKms(config) => Some(config), + _ => None, + } + } + + /// Get Vault signer config if this is a Vault signer + pub fn get_vault(&self) -> Option<&VaultSignerConfig> { + match self { + Self::Vault(config) => Some(config), + _ => None, + } + } + + /// Get Vault Transit signer config if this is a Vault Transit signer + pub fn get_vault_transit(&self) -> Option<&VaultTransitSignerConfig> { + match self { + Self::VaultTransit(config) => Some(config), + _ => None, + } + } + + /// Get Turnkey signer config if this is a Turnkey signer + pub fn get_turnkey(&self) -> Option<&TurnkeySignerConfig> { + match self { + Self::Turnkey(config) => Some(config), + _ => None, + } + } + + /// Get Google Cloud KMS signer config if this is a Google Cloud KMS signer + pub fn get_google_cloud_kms(&self) -> Option<&GoogleCloudKmsSignerConfig> { + match self { + Self::GoogleCloudKms(config) => Some(config), + _ => None, + } + } + + /// Get the signer type from the configuration + pub fn get_signer_type(&self) -> SignerType { + match self { + Self::Local(_) => SignerType::Local, + Self::AwsKms(_) => SignerType::AwsKms, + Self::Vault(_) => SignerType::Vault, + Self::VaultTransit(_) => SignerType::VaultTransit, + Self::Turnkey(_) => SignerType::Turnkey, + Self::GoogleCloudKms(_) => SignerType::GoogleCloudKms, + } + } +} + +/// Helper function to format validation errors +fn format_validation_errors(errors: &validator::ValidationErrors) -> String { + let mut messages = Vec::new(); + + for (field, field_errors) in errors.field_errors().iter() { + let field_msgs: Vec = field_errors + .iter() + .map(|error| error.message.clone().unwrap_or_default().to_string()) + .collect(); + messages.push(format!("{}: {}", field, field_msgs.join(", "))); + } + + for (struct_field, kind) in errors.errors().iter() { + if let validator::ValidationErrorsKind::Struct(nested) = kind { + let nested_msgs = format_validation_errors(nested); + messages.push(format!("{}.{}", struct_field, nested_msgs)); + } + } + + messages.join("; ") +} + +/// Core signer domain model containing both metadata and configuration +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct Signer { + #[validate( + length(min = 1, max = 36, message = "ID must be between 1 and 36 characters"), + regex( + path = "*ID_REGEX", + message = "ID must contain only letters, numbers, dashes and underscores" + ) + )] + pub id: String, + pub config: SignerConfig, +} + +/// Signer type enum used for validation and API responses +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum SignerType { + Local, + #[serde(rename = "aws_kms")] + AwsKms, + #[serde(rename = "google_cloud_kms")] + GoogleCloudKms, + Vault, + #[serde(rename = "vault_transit")] + VaultTransit, + Turnkey, +} + +impl Signer { + /// Creates a new signer with configuration + pub fn new(id: String, config: SignerConfig) -> Self { + Self { id, config } + } + + /// Gets the signer type from the configuration + pub fn signer_type(&self) -> SignerType { + self.config.get_signer_type() + } + + /// Validates the signer using both struct validation and config validation + pub fn validate(&self) -> Result<(), SignerValidationError> { + // First validate struct-level constraints (ID format, etc.) + Validate::validate(self).map_err(|validation_errors| { + // Convert validator errors to our custom error type + // Return the first error for simplicity + for (field, errors) in validation_errors.field_errors() { + if let Some(error) = errors.first() { + let field_str = field.as_ref(); + return match (field_str, error.code.as_ref()) { + ("id", "length") => SignerValidationError::InvalidIdFormat, + ("id", "regex") => SignerValidationError::InvalidIdFormat, + _ => SignerValidationError::InvalidIdFormat, // fallback + }; + } + } + // Fallback error + SignerValidationError::InvalidIdFormat + })?; + + // Then validate the configuration + self.config.validate()?; + + Ok(()) + } +} + +/// Validation errors for signers +#[derive(Debug, thiserror::Error)] +pub enum SignerValidationError { + #[error("Signer ID cannot be empty")] + EmptyId, + #[error("Signer ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long")] + InvalidIdFormat, + #[error("Invalid signer configuration: {0}")] + InvalidConfig(String), +} + +/// Centralized conversion from SignerValidationError to ApiError +impl From for crate::models::ApiError { + fn from(error: SignerValidationError) -> Self { + use crate::models::ApiError; + + ApiError::BadRequest(match error { + SignerValidationError::EmptyId => "ID cannot be empty".to_string(), + SignerValidationError::InvalidIdFormat => { + "ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long".to_string() + } + SignerValidationError::InvalidConfig(msg) => format!("Invalid signer configuration: {}", msg), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_local_signer() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), + }); + + let signer = Signer::new("valid-id".to_string(), config); + + assert!(signer.validate().is_ok()); + assert_eq!(signer.signer_type(), SignerType::Local); + } + + #[test] + fn test_valid_aws_kms_signer() { + let config = SignerConfig::AwsKms(AwsKmsSignerConfig { + region: Some("us-east-1".to_string()), + key_id: "test-key-id".to_string(), + }); + + let signer = Signer::new("aws-signer".to_string(), config); + + assert!(signer.validate().is_ok()); + assert_eq!(signer.signer_type(), SignerType::AwsKms); + } + + #[test] + fn test_empty_id() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key + }); + + let signer = Signer::new("".to_string(), config); + + assert!(matches!( + signer.validate(), + Err(SignerValidationError::InvalidIdFormat) + )); + } + + #[test] + fn test_id_too_long() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key + }); + + let signer = Signer::new("a".repeat(37), config); + + assert!(matches!( + signer.validate(), + Err(SignerValidationError::InvalidIdFormat) + )); + } + + #[test] + fn test_invalid_id_format() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key + }); + + let signer = Signer::new("invalid@id".to_string(), config); + + assert!(matches!( + signer.validate(), + Err(SignerValidationError::InvalidIdFormat) + )); + } + + #[test] + fn test_local_signer_invalid_key_length() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(16, |v| v.fill(1)), // Invalid length: 16 bytes instead of 32 + }); + + let signer = Signer::new("valid-id".to_string(), config); + + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("Raw key must be exactly 32 bytes")); + assert!(msg.contains("got 16 bytes")); + } else { + panic!("Expected InvalidConfig error for invalid key length"); + } + } + + #[test] + fn test_local_signer_all_zero_key() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(0)), // Invalid: all zeros + }); + + let signer = Signer::new("valid-id".to_string(), config); + + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert_eq!(msg, "Raw key cannot be all zeros"); + } else { + panic!("Expected InvalidConfig error for all-zero key"); + } + } + + #[test] + fn test_local_signer_valid_key() { + let config = SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), // Valid: 32 bytes, non-zero + }); + + let signer = Signer::new("valid-id".to_string(), config); + + assert!(signer.validate().is_ok()); + } + + #[test] + fn test_signer_type_serialization() { + use serde_json::{from_str, to_string}; + + assert_eq!(to_string(&SignerType::Local).unwrap(), "\"local\""); + assert_eq!(to_string(&SignerType::AwsKms).unwrap(), "\"aws_kms\""); + assert_eq!( + to_string(&SignerType::GoogleCloudKms).unwrap(), + "\"google_cloud_kms\"" + ); + assert_eq!( + to_string(&SignerType::VaultTransit).unwrap(), + "\"vault_transit\"" + ); + + assert_eq!( + from_str::("\"local\"").unwrap(), + SignerType::Local + ); + assert_eq!( + from_str::("\"aws_kms\"").unwrap(), + SignerType::AwsKms + ); + } + + #[test] + fn test_config_accessor_methods() { + // Test Local config accessor + let local_config = LocalSignerConfig { + raw_key: SecretVec::new(32, |v| v.fill(1)), + }; + let config = SignerConfig::Local(local_config); + assert!(config.get_local().is_some()); + assert!(config.get_aws_kms().is_none()); + + // Test AWS KMS config accessor + let aws_config = AwsKmsSignerConfig { + region: Some("us-east-1".to_string()), + key_id: "test-key".to_string(), + }; + let config = SignerConfig::AwsKms(aws_config); + assert!(config.get_aws_kms().is_some()); + assert!(config.get_local().is_none()); + } + + #[test] + fn test_error_conversion_to_api_error() { + let error = SignerValidationError::InvalidIdFormat; + let api_error: crate::models::ApiError = error.into(); + + if let crate::models::ApiError::BadRequest(msg) = api_error { + assert!(msg.contains("ID must contain only letters, numbers, dashes and underscores")); + } else { + panic!("Expected BadRequest error"); + } + } + + #[test] + fn test_valid_vault_signer() { + let config = SignerConfig::Vault(VaultSignerConfig { + address: "https://vault.example.com".to_string(), + namespace: Some("test".to_string()), + role_id: SecretString::new("role-id"), + secret_id: SecretString::new("secret-id"), + key_name: "test-key".to_string(), + mount_point: None, + }); + + let signer = Signer::new("vault-signer".to_string(), config); + assert!(signer.validate().is_ok()); + assert_eq!(signer.signer_type(), SignerType::Vault); + } + + #[test] + fn test_invalid_vault_signer_url() { + let config = SignerConfig::Vault(VaultSignerConfig { + address: "not-a-url".to_string(), + namespace: Some("test".to_string()), + role_id: SecretString::new("role-id"), + secret_id: SecretString::new("secret-id"), + key_name: "test-key".to_string(), + mount_point: None, + }); + + let signer = Signer::new("vault-signer".to_string(), config); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("Address must be a valid URL")); + } else { + panic!("Expected InvalidConfig error for invalid URL"); + } + } + + #[test] + fn test_valid_google_cloud_kms_signer() { + let config = SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { + service_account: GoogleCloudKmsSignerServiceAccountConfig { + private_key: SecretString::new("private-key"), + private_key_id: SecretString::new("key-id"), + project_id: "project".to_string(), + client_email: SecretString::new("client@example.com"), + client_id: "client-id".to_string(), + auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" + .to_string(), + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" + .to_string(), + universe_domain: "googleapis.com".to_string(), + }, + key: GoogleCloudKmsSignerKeyConfig { + location: "us-central1".to_string(), + key_ring_id: "test-ring".to_string(), + key_id: "test-key".to_string(), + key_version: 1, + }, + }); + + let signer = Signer::new("gcp-kms-signer".to_string(), config); + assert!(signer.validate().is_ok()); + assert_eq!(signer.signer_type(), SignerType::GoogleCloudKms); + } + + #[test] + fn test_invalid_google_cloud_kms_urls() { + let config = SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { + service_account: GoogleCloudKmsSignerServiceAccountConfig { + private_key: SecretString::new("private-key"), + private_key_id: SecretString::new("key-id"), + project_id: "project".to_string(), + client_email: SecretString::new("client@example.com"), + client_id: "client-id".to_string(), + auth_uri: "not-a-url".to_string(), // Invalid URL + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" + .to_string(), + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" + .to_string(), + universe_domain: "googleapis.com".to_string(), + }, + key: GoogleCloudKmsSignerKeyConfig { + location: "us-central1".to_string(), + key_ring_id: "test-ring".to_string(), + key_id: "test-key".to_string(), + key_version: 1, + }, + }); + + let signer = Signer::new("gcp-kms-signer".to_string(), config); + let result = signer.validate(); + assert!(result.is_err()); + if let Err(SignerValidationError::InvalidConfig(msg)) = result { + assert!(msg.contains("Auth URI must be a valid URL")); + } else { + panic!("Expected InvalidConfig error for invalid URL"); + } + } + + #[test] + fn test_secret_string_validation() { + // Test empty secret + let result = validate_secret_string(&SecretString::new("")); + if let Err(e) = result { + assert_eq!(e.code, "empty_secret"); + } else { + panic!("Expected validation error for empty secret"); + } + + // Test valid secret + let result = validate_secret_string(&SecretString::new("secret")); + assert!(result.is_ok()); + } + + #[test] + fn test_validation_error_formatting() { + // Create an invalid config to trigger multiple nested validation errors + let invalid_config = GoogleCloudKmsSignerConfig { + service_account: GoogleCloudKmsSignerServiceAccountConfig { + private_key: SecretString::new(""), // Invalid: empty + private_key_id: SecretString::new("key-id"), + project_id: "project".to_string(), + client_email: SecretString::new("client@example.com"), + client_id: "".to_string(), // Invalid: empty + auth_uri: "not-a-url".to_string(), // Invalid: not a URL + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" + .to_string(), + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" + .to_string(), + universe_domain: "googleapis.com".to_string(), + }, + key: GoogleCloudKmsSignerKeyConfig { + location: "us-central1".to_string(), + key_ring_id: "".to_string(), // Invalid: empty + key_id: "test-key".to_string(), + key_version: 1, + }, + }; + + let errors = invalid_config.validate().unwrap_err(); + + // Format the errors using the helper function + let formatted = format_validation_errors(&errors); + + println!("formatted: {}", formatted); + + // Check that messages from nested fields are correctly formatted + assert!(formatted.contains("client_id: Client ID cannot be empty")); + assert!(formatted.contains("private_key: Private key cannot be empty")); + assert!(formatted.contains("auth_uri: Auth URI must be a valid URL")); + assert!(formatted.contains("key_ring_id: Key ring ID cannot be empty")); + } + + #[test] + fn test_config_type_getters() { + // Test Vault config getter + let vault_config = VaultSignerConfig { + address: "https://vault.example.com".to_string(), + namespace: None, + role_id: SecretString::new("role"), + secret_id: SecretString::new("secret"), + key_name: "key".to_string(), + mount_point: None, + }; + let config = SignerConfig::Vault(vault_config); + assert!(config.get_vault().is_some()); + + // Test VaultTransit config getter + let vault_transit_config = VaultTransitSignerConfig { + key_name: "key".to_string(), + address: "https://vault.example.com".to_string(), + namespace: None, + role_id: SecretString::new("role"), + secret_id: SecretString::new("secret"), + pubkey: "pubkey".to_string(), + mount_point: None, + }; + let config = SignerConfig::VaultTransit(vault_transit_config); + assert!(config.get_vault_transit().is_some()); + assert!(config.get_turnkey().is_none()); + + // Test Turnkey config getter + let turnkey_config = TurnkeySignerConfig { + api_public_key: "public".to_string(), + api_private_key: SecretString::new("private"), + organization_id: "org".to_string(), + private_key_id: "key-id".to_string(), + public_key: "pubkey".to_string(), + }; + let config = SignerConfig::Turnkey(turnkey_config); + assert!(config.get_turnkey().is_some()); + assert!(config.get_google_cloud_kms().is_none()); + + // Test Google Cloud KMS config getter + let gcp_config = GoogleCloudKmsSignerConfig { + service_account: GoogleCloudKmsSignerServiceAccountConfig { + private_key: SecretString::new("private-key"), + private_key_id: SecretString::new("key-id"), + project_id: "project".to_string(), + client_email: SecretString::new("client@example.com"), + client_id: "client-id".to_string(), + auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" + .to_string(), + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" + .to_string(), + universe_domain: "googleapis.com".to_string(), + }, + key: GoogleCloudKmsSignerKeyConfig { + location: "us-central1".to_string(), + key_ring_id: "test-ring".to_string(), + key_id: "test-key".to_string(), + key_version: 1, + }, + }; + let config = SignerConfig::GoogleCloudKms(gcp_config); + assert!(config.get_google_cloud_kms().is_some()); + assert!(config.get_local().is_none()); + } +} diff --git a/src/models/signer/repository.rs b/src/models/signer/repository.rs index f8a491006..a7b840371 100644 --- a/src/models/signer/repository.rs +++ b/src/models/signer/repository.rs @@ -12,7 +12,7 @@ use crate::{ models::{ - signer::signer::{ + signer::{ AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, Signer, SignerConfig, SignerValidationError, TurnkeySignerConfig, VaultSignerConfig, @@ -413,7 +413,7 @@ impl SignerRepoModel { #[cfg(test)] mod tests { use super::*; - use crate::models::signer::signer::{LocalSignerConfig, SignerConfig}; + use crate::models::signer::{LocalSignerConfig, SignerConfig}; use secrets::SecretVec; #[test] @@ -422,10 +422,8 @@ mod tests { raw_key: SecretVec::new(32, |v| v.fill(1)), }; - let core = crate::models::signer::signer::Signer::new( - "test-id".to_string(), - SignerConfig::Local(config), - ); + let core = + crate::models::signer::Signer::new("test-id".to_string(), SignerConfig::Local(config)); let repo_model = SignerRepoModel::from(core); assert_eq!(repo_model.id, "test-id"); @@ -434,7 +432,7 @@ mod tests { #[test] fn test_to_core_signer() { - use crate::models::signer::signer::AwsKmsSignerConfig; + use crate::models::signer::AwsKmsSignerConfig; let domain_config = AwsKmsSignerConfig { region: Some("us-east-1".to_string()), @@ -450,7 +448,7 @@ mod tests { assert_eq!(core.id, "test-id"); assert_eq!( core.signer_type(), - crate::models::signer::signer::SignerType::AwsKms + crate::models::signer::SignerType::AwsKms ); } diff --git a/src/models/signer/request.rs b/src/models/signer/request.rs index fe70456f0..aa9ea60d6 100644 --- a/src/models/signer/request.rs +++ b/src/models/signer/request.rs @@ -256,7 +256,7 @@ impl TryFrom for SignerConfig { }; // Validate the configuration using domain model validation - domain_config.validate().map_err(|e| ApiError::from(e))?; + domain_config.validate().map_err(ApiError::from)?; Ok(domain_config) } @@ -287,7 +287,7 @@ impl TryFrom for Signer { #[cfg(test)] mod tests { use super::*; - use crate::models::signer::signer::SignerType; + use crate::models::signer::SignerType; #[test] fn test_valid_aws_kms_create_request() { diff --git a/src/models/signer/response.rs b/src/models/signer/response.rs index 103ae727e..c9360a74b 100644 --- a/src/models/signer/response.rs +++ b/src/models/signer/response.rs @@ -185,7 +185,7 @@ mod tests { #[test] fn test_signer_response_from_domain_model() { - use crate::models::signer::signer::{AwsKmsSignerConfig, SignerConfig}; + use crate::models::signer::{AwsKmsSignerConfig, SignerConfig}; let aws_config = AwsKmsSignerConfig { key_id: "test-key-id".to_string(), diff --git a/src/models/signer/signer.rs b/src/models/signer/signer.rs deleted file mode 100644 index 56885e874..000000000 --- a/src/models/signer/signer.rs +++ /dev/null @@ -1,840 +0,0 @@ -//! Core signer domain model and business logic. -//! -//! This module provides the central `Signer` type that represents signers -//! throughout the relayer system, including: -//! -//! - **Domain Model**: Core `Signer` struct with validation and configuration -//! - **Business Logic**: Update operations and validation rules -//! - **Error Handling**: Comprehensive validation error types -//! - **Interoperability**: Conversions between API, config, and repository representations -//! -//! The signer model supports multiple signer types including local keys, AWS KMS, -//! Google Cloud KMS, Vault, and Turnkey service integrations. - -use crate::{constants::ID_REGEX, models::SecretString}; -use secrets::SecretVec; -use serde::{Deserialize, Serialize, Serializer}; -use utoipa::ToSchema; -use validator::Validate; - -/// Helper function to serialize secrets as redacted -fn serialize_secret_redacted(_secret: &SecretVec, serializer: S) -> Result -where - S: Serializer, -{ - serializer.serialize_str("[REDACTED]") -} - -/// Local signer configuration for storing private keys -#[derive(Debug, Clone, Serialize)] -pub struct LocalSignerConfig { - #[serde(serialize_with = "serialize_secret_redacted")] - pub raw_key: SecretVec, -} - -impl LocalSignerConfig { - /// Validates the raw key for cryptographic requirements - pub fn validate(&self) -> Result<(), SignerValidationError> { - let key_bytes = self.raw_key.borrow(); - - // Check key length - must be exactly 32 bytes for crypto operations - if key_bytes.len() != 32 { - return Err(SignerValidationError::InvalidConfig(format!( - "Raw key must be exactly 32 bytes, got {} bytes", - key_bytes.len() - ))); - } - - // Check if key is all zeros (cryptographically invalid) - if key_bytes.iter().all(|&b| b == 0) { - return Err(SignerValidationError::InvalidConfig( - "Raw key cannot be all zeros".to_string(), - )); - } - - Ok(()) - } -} - -impl<'de> Deserialize<'de> for LocalSignerConfig { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct LocalSignerConfigHelper { - raw_key: String, - } - - let helper = LocalSignerConfigHelper::deserialize(deserializer)?; - let raw_key = if helper.raw_key == "[REDACTED]" { - // Return a zero-filled SecretVec when deserializing redacted data - SecretVec::zero(32) - } else { - // For actual data, assume it's the raw bytes represented as a string - // In practice, this would come from proper key loading - SecretVec::new(helper.raw_key.len(), |v| { - v.copy_from_slice(helper.raw_key.as_bytes()) - }) - }; - - Ok(LocalSignerConfig { raw_key }) - } -} - -/// AWS KMS signer configuration -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct AwsKmsSignerConfig { - #[validate(length(min = 1, message = "Region cannot be empty"))] - pub region: Option, - #[validate(length(min = 1, message = "Key ID cannot be empty"))] - pub key_id: String, -} - -/// Vault signer configuration -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct VaultSignerConfig { - #[validate(url(message = "Address must be a valid URL"))] - pub address: String, - pub namespace: Option, - #[validate(custom( - function = "validate_secret_string", - message = "Role ID cannot be empty" - ))] - pub role_id: SecretString, - #[validate(custom( - function = "validate_secret_string", - message = "Secret ID cannot be empty" - ))] - pub secret_id: SecretString, - #[validate(length(min = 1, message = "Vault key name cannot be empty"))] - pub key_name: String, - pub mount_point: Option, -} - -/// Vault Transit signer configuration -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct VaultTransitSignerConfig { - #[validate(length(min = 1, message = "Key name cannot be empty"))] - pub key_name: String, - #[validate(url(message = "Address must be a valid URL"))] - pub address: String, - pub namespace: Option, - #[validate(custom( - function = "validate_secret_string", - message = "Role ID cannot be empty" - ))] - pub role_id: SecretString, - #[validate(custom( - function = "validate_secret_string", - message = "Secret ID cannot be empty" - ))] - pub secret_id: SecretString, - #[validate(length(min = 1, message = "pubkey cannot be empty"))] - pub pubkey: String, - pub mount_point: Option, -} - -/// Turnkey signer configuration -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct TurnkeySignerConfig { - #[validate(length(min = 1, message = "API public key cannot be empty"))] - pub api_public_key: String, - #[validate(custom( - function = "validate_secret_string", - message = "API private key cannot be empty" - ))] - pub api_private_key: SecretString, - #[validate(length(min = 1, message = "Organization ID cannot be empty"))] - pub organization_id: String, - #[validate(length(min = 1, message = "Private key ID cannot be empty"))] - pub private_key_id: String, - #[validate(length(min = 1, message = "Public key cannot be empty"))] - pub public_key: String, -} - -/// Google Cloud KMS service account configuration -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct GoogleCloudKmsSignerServiceAccountConfig { - #[validate(custom( - function = "validate_secret_string", - message = "Private key cannot be empty" - ))] - pub private_key: SecretString, - #[validate(custom( - function = "validate_secret_string", - message = "Private key ID cannot be empty" - ))] - pub private_key_id: SecretString, - #[validate(length(min = 1, message = "Project ID cannot be empty"))] - pub project_id: String, - #[validate(custom( - function = "validate_secret_string", - message = "Client email cannot be empty" - ))] - pub client_email: SecretString, - #[validate(length(min = 1, message = "Client ID cannot be empty"))] - pub client_id: String, - #[validate(url(message = "Auth URI must be a valid URL"))] - pub auth_uri: String, - #[validate(url(message = "Token URI must be a valid URL"))] - pub token_uri: String, - #[validate(url(message = "Auth provider x509 cert URL must be a valid URL"))] - pub auth_provider_x509_cert_url: String, - #[validate(url(message = "Client x509 cert URL must be a valid URL"))] - pub client_x509_cert_url: String, - pub universe_domain: String, -} - -/// Google Cloud KMS key configuration -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct GoogleCloudKmsSignerKeyConfig { - pub location: String, - #[validate(length(min = 1, message = "Key ring ID cannot be empty"))] - pub key_ring_id: String, - #[validate(length(min = 1, message = "Key ID cannot be empty"))] - pub key_id: String, - pub key_version: u32, -} - -/// Google Cloud KMS signer configuration -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct GoogleCloudKmsSignerConfig { - #[validate(nested)] - pub service_account: GoogleCloudKmsSignerServiceAccountConfig, - #[validate(nested)] - pub key: GoogleCloudKmsSignerKeyConfig, -} - -/// Custom validator for SecretString -fn validate_secret_string(secret: &SecretString) -> Result<(), validator::ValidationError> { - if secret.to_str().is_empty() { - return Err(validator::ValidationError::new("empty_secret")); - } - Ok(()) -} - -/// Domain signer configuration enum containing all supported signer types -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum SignerConfig { - Local(LocalSignerConfig), - Vault(VaultSignerConfig), - VaultTransit(VaultTransitSignerConfig), - AwsKms(AwsKmsSignerConfig), - Turnkey(TurnkeySignerConfig), - GoogleCloudKms(GoogleCloudKmsSignerConfig), -} - -impl SignerConfig { - /// Validates the configuration using the appropriate validator - pub fn validate(&self) -> Result<(), SignerValidationError> { - match self { - Self::Local(config) => config.validate(), - Self::AwsKms(config) => Validate::validate(config).map_err(|e| { - SignerValidationError::InvalidConfig(format!( - "AWS KMS validation failed: {}", - format_validation_errors(&e) - )) - }), - Self::Vault(config) => Validate::validate(config).map_err(|e| { - SignerValidationError::InvalidConfig(format!( - "Vault validation failed: {}", - format_validation_errors(&e) - )) - }), - Self::VaultTransit(config) => Validate::validate(config).map_err(|e| { - SignerValidationError::InvalidConfig(format!( - "Vault Transit validation failed: {}", - format_validation_errors(&e) - )) - }), - Self::Turnkey(config) => Validate::validate(config).map_err(|e| { - SignerValidationError::InvalidConfig(format!( - "Turnkey validation failed: {}", - format_validation_errors(&e) - )) - }), - Self::GoogleCloudKms(config) => Validate::validate(config).map_err(|e| { - SignerValidationError::InvalidConfig(format!( - "Google Cloud KMS validation failed: {}", - format_validation_errors(&e) - )) - }), - } - } - - /// Get local signer config if this is a local signer - pub fn get_local(&self) -> Option<&LocalSignerConfig> { - match self { - Self::Local(config) => Some(config), - _ => None, - } - } - - /// Get AWS KMS signer config if this is an AWS KMS signer - pub fn get_aws_kms(&self) -> Option<&AwsKmsSignerConfig> { - match self { - Self::AwsKms(config) => Some(config), - _ => None, - } - } - - /// Get Vault signer config if this is a Vault signer - pub fn get_vault(&self) -> Option<&VaultSignerConfig> { - match self { - Self::Vault(config) => Some(config), - _ => None, - } - } - - /// Get Vault Transit signer config if this is a Vault Transit signer - pub fn get_vault_transit(&self) -> Option<&VaultTransitSignerConfig> { - match self { - Self::VaultTransit(config) => Some(config), - _ => None, - } - } - - /// Get Turnkey signer config if this is a Turnkey signer - pub fn get_turnkey(&self) -> Option<&TurnkeySignerConfig> { - match self { - Self::Turnkey(config) => Some(config), - _ => None, - } - } - - /// Get Google Cloud KMS signer config if this is a Google Cloud KMS signer - pub fn get_google_cloud_kms(&self) -> Option<&GoogleCloudKmsSignerConfig> { - match self { - Self::GoogleCloudKms(config) => Some(config), - _ => None, - } - } - - /// Get the signer type from the configuration - pub fn get_signer_type(&self) -> SignerType { - match self { - Self::Local(_) => SignerType::Local, - Self::AwsKms(_) => SignerType::AwsKms, - Self::Vault(_) => SignerType::Vault, - Self::VaultTransit(_) => SignerType::VaultTransit, - Self::Turnkey(_) => SignerType::Turnkey, - Self::GoogleCloudKms(_) => SignerType::GoogleCloudKms, - } - } -} - -/// Helper function to format validation errors -fn format_validation_errors(errors: &validator::ValidationErrors) -> String { - let mut messages = Vec::new(); - - for (field, field_errors) in errors.field_errors().iter() { - let field_msgs: Vec = field_errors - .iter() - .map(|error| error.message.clone().unwrap_or_default().to_string()) - .collect(); - messages.push(format!("{}: {}", field, field_msgs.join(", "))); - } - - for (struct_field, kind) in errors.errors().iter() { - if let validator::ValidationErrorsKind::Struct(nested) = kind { - let nested_msgs = format_validation_errors(nested); - messages.push(format!("{}.{}", struct_field, nested_msgs)); - } - } - - messages.join("; ") -} - -/// Core signer domain model containing both metadata and configuration -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct Signer { - #[validate( - length(min = 1, max = 36, message = "ID must be between 1 and 36 characters"), - regex( - path = "*ID_REGEX", - message = "ID must contain only letters, numbers, dashes and underscores" - ) - )] - pub id: String, - pub config: SignerConfig, -} - -/// Signer type enum used for validation and API responses -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum SignerType { - Local, - #[serde(rename = "aws_kms")] - AwsKms, - #[serde(rename = "google_cloud_kms")] - GoogleCloudKms, - Vault, - #[serde(rename = "vault_transit")] - VaultTransit, - Turnkey, -} - -impl Signer { - /// Creates a new signer with configuration - pub fn new(id: String, config: SignerConfig) -> Self { - Self { id, config } - } - - /// Gets the signer type from the configuration - pub fn signer_type(&self) -> SignerType { - self.config.get_signer_type() - } - - /// Validates the signer using both struct validation and config validation - pub fn validate(&self) -> Result<(), SignerValidationError> { - // First validate struct-level constraints (ID format, etc.) - Validate::validate(self).map_err(|validation_errors| { - // Convert validator errors to our custom error type - // Return the first error for simplicity - for (field, errors) in validation_errors.field_errors() { - if let Some(error) = errors.first() { - let field_str = field.as_ref(); - return match (field_str, error.code.as_ref()) { - ("id", "length") => SignerValidationError::InvalidIdFormat, - ("id", "regex") => SignerValidationError::InvalidIdFormat, - _ => SignerValidationError::InvalidIdFormat, // fallback - }; - } - } - // Fallback error - SignerValidationError::InvalidIdFormat - })?; - - // Then validate the configuration - self.config.validate()?; - - Ok(()) - } -} - -/// Validation errors for signers -#[derive(Debug, thiserror::Error)] -pub enum SignerValidationError { - #[error("Signer ID cannot be empty")] - EmptyId, - #[error("Signer ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long")] - InvalidIdFormat, - #[error("Invalid signer configuration: {0}")] - InvalidConfig(String), -} - -/// Centralized conversion from SignerValidationError to ApiError -impl From for crate::models::ApiError { - fn from(error: SignerValidationError) -> Self { - use crate::models::ApiError; - - ApiError::BadRequest(match error { - SignerValidationError::EmptyId => "ID cannot be empty".to_string(), - SignerValidationError::InvalidIdFormat => { - "ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long".to_string() - } - SignerValidationError::InvalidConfig(msg) => format!("Invalid signer configuration: {}", msg), - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_valid_local_signer() { - let config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(32, |v| v.fill(1)), - }); - - let signer = Signer::new("valid-id".to_string(), config); - - assert!(signer.validate().is_ok()); - assert_eq!(signer.signer_type(), SignerType::Local); - } - - #[test] - fn test_valid_aws_kms_signer() { - let config = SignerConfig::AwsKms(AwsKmsSignerConfig { - region: Some("us-east-1".to_string()), - key_id: "test-key-id".to_string(), - }); - - let signer = Signer::new("aws-signer".to_string(), config); - - assert!(signer.validate().is_ok()); - assert_eq!(signer.signer_type(), SignerType::AwsKms); - } - - #[test] - fn test_empty_id() { - let config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key - }); - - let signer = Signer::new("".to_string(), config); - - assert!(matches!( - signer.validate(), - Err(SignerValidationError::InvalidIdFormat) - )); - } - - #[test] - fn test_id_too_long() { - let config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key - }); - - let signer = Signer::new("a".repeat(37), config); - - assert!(matches!( - signer.validate(), - Err(SignerValidationError::InvalidIdFormat) - )); - } - - #[test] - fn test_invalid_id_format() { - let config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(32, |v| v.fill(1)), // Use valid non-zero key - }); - - let signer = Signer::new("invalid@id".to_string(), config); - - assert!(matches!( - signer.validate(), - Err(SignerValidationError::InvalidIdFormat) - )); - } - - #[test] - fn test_local_signer_invalid_key_length() { - let config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(16, |v| v.fill(1)), // Invalid length: 16 bytes instead of 32 - }); - - let signer = Signer::new("valid-id".to_string(), config); - - let result = signer.validate(); - assert!(result.is_err()); - if let Err(SignerValidationError::InvalidConfig(msg)) = result { - assert!(msg.contains("Raw key must be exactly 32 bytes")); - assert!(msg.contains("got 16 bytes")); - } else { - panic!("Expected InvalidConfig error for invalid key length"); - } - } - - #[test] - fn test_local_signer_all_zero_key() { - let config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(32, |v| v.fill(0)), // Invalid: all zeros - }); - - let signer = Signer::new("valid-id".to_string(), config); - - let result = signer.validate(); - assert!(result.is_err()); - if let Err(SignerValidationError::InvalidConfig(msg)) = result { - assert_eq!(msg, "Raw key cannot be all zeros"); - } else { - panic!("Expected InvalidConfig error for all-zero key"); - } - } - - #[test] - fn test_local_signer_valid_key() { - let config = SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(32, |v| v.fill(1)), // Valid: 32 bytes, non-zero - }); - - let signer = Signer::new("valid-id".to_string(), config); - - assert!(signer.validate().is_ok()); - } - - #[test] - fn test_signer_type_serialization() { - use serde_json::{from_str, to_string}; - - assert_eq!(to_string(&SignerType::Local).unwrap(), "\"local\""); - assert_eq!(to_string(&SignerType::AwsKms).unwrap(), "\"aws_kms\""); - assert_eq!( - to_string(&SignerType::GoogleCloudKms).unwrap(), - "\"google_cloud_kms\"" - ); - assert_eq!( - to_string(&SignerType::VaultTransit).unwrap(), - "\"vault_transit\"" - ); - - assert_eq!( - from_str::("\"local\"").unwrap(), - SignerType::Local - ); - assert_eq!( - from_str::("\"aws_kms\"").unwrap(), - SignerType::AwsKms - ); - } - - #[test] - fn test_config_accessor_methods() { - // Test Local config accessor - let local_config = LocalSignerConfig { - raw_key: SecretVec::new(32, |v| v.fill(1)), - }; - let config = SignerConfig::Local(local_config); - assert!(config.get_local().is_some()); - assert!(config.get_aws_kms().is_none()); - - // Test AWS KMS config accessor - let aws_config = AwsKmsSignerConfig { - region: Some("us-east-1".to_string()), - key_id: "test-key".to_string(), - }; - let config = SignerConfig::AwsKms(aws_config); - assert!(config.get_aws_kms().is_some()); - assert!(config.get_local().is_none()); - } - - #[test] - fn test_error_conversion_to_api_error() { - let error = SignerValidationError::InvalidIdFormat; - let api_error: crate::models::ApiError = error.into(); - - if let crate::models::ApiError::BadRequest(msg) = api_error { - assert!(msg.contains("ID must contain only letters, numbers, dashes and underscores")); - } else { - panic!("Expected BadRequest error"); - } - } - - #[test] - fn test_valid_vault_signer() { - let config = SignerConfig::Vault(VaultSignerConfig { - address: "https://vault.example.com".to_string(), - namespace: Some("test".to_string()), - role_id: SecretString::new("role-id"), - secret_id: SecretString::new("secret-id"), - key_name: "test-key".to_string(), - mount_point: None, - }); - - let signer = Signer::new("vault-signer".to_string(), config); - assert!(signer.validate().is_ok()); - assert_eq!(signer.signer_type(), SignerType::Vault); - } - - #[test] - fn test_invalid_vault_signer_url() { - let config = SignerConfig::Vault(VaultSignerConfig { - address: "not-a-url".to_string(), - namespace: Some("test".to_string()), - role_id: SecretString::new("role-id"), - secret_id: SecretString::new("secret-id"), - key_name: "test-key".to_string(), - mount_point: None, - }); - - let signer = Signer::new("vault-signer".to_string(), config); - let result = signer.validate(); - assert!(result.is_err()); - if let Err(SignerValidationError::InvalidConfig(msg)) = result { - assert!(msg.contains("Address must be a valid URL")); - } else { - panic!("Expected InvalidConfig error for invalid URL"); - } - } - - #[test] - fn test_valid_google_cloud_kms_signer() { - let config = SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { - service_account: GoogleCloudKmsSignerServiceAccountConfig { - private_key: SecretString::new("private-key"), - private_key_id: SecretString::new("key-id"), - project_id: "project".to_string(), - client_email: SecretString::new("client@example.com"), - client_id: "client-id".to_string(), - auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), - token_uri: "https://oauth2.googleapis.com/token".to_string(), - auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" - .to_string(), - client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" - .to_string(), - universe_domain: "googleapis.com".to_string(), - }, - key: GoogleCloudKmsSignerKeyConfig { - location: "us-central1".to_string(), - key_ring_id: "test-ring".to_string(), - key_id: "test-key".to_string(), - key_version: 1, - }, - }); - - let signer = Signer::new("gcp-kms-signer".to_string(), config); - assert!(signer.validate().is_ok()); - assert_eq!(signer.signer_type(), SignerType::GoogleCloudKms); - } - - #[test] - fn test_invalid_google_cloud_kms_urls() { - let config = SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { - service_account: GoogleCloudKmsSignerServiceAccountConfig { - private_key: SecretString::new("private-key"), - private_key_id: SecretString::new("key-id"), - project_id: "project".to_string(), - client_email: SecretString::new("client@example.com"), - client_id: "client-id".to_string(), - auth_uri: "not-a-url".to_string(), // Invalid URL - token_uri: "https://oauth2.googleapis.com/token".to_string(), - auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" - .to_string(), - client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" - .to_string(), - universe_domain: "googleapis.com".to_string(), - }, - key: GoogleCloudKmsSignerKeyConfig { - location: "us-central1".to_string(), - key_ring_id: "test-ring".to_string(), - key_id: "test-key".to_string(), - key_version: 1, - }, - }); - - let signer = Signer::new("gcp-kms-signer".to_string(), config); - let result = signer.validate(); - assert!(result.is_err()); - if let Err(SignerValidationError::InvalidConfig(msg)) = result { - assert!(msg.contains("Auth URI must be a valid URL")); - } else { - panic!("Expected InvalidConfig error for invalid URL"); - } - } - - #[test] - fn test_secret_string_validation() { - // Test empty secret - let result = validate_secret_string(&SecretString::new("")); - if let Err(e) = result { - assert_eq!(e.code, "empty_secret"); - } else { - panic!("Expected validation error for empty secret"); - } - - // Test valid secret - let result = validate_secret_string(&SecretString::new("secret")); - assert!(result.is_ok()); - } - - #[test] - fn test_validation_error_formatting() { - // Create an invalid config to trigger multiple nested validation errors - let invalid_config = GoogleCloudKmsSignerConfig { - service_account: GoogleCloudKmsSignerServiceAccountConfig { - private_key: SecretString::new(""), // Invalid: empty - private_key_id: SecretString::new("key-id"), - project_id: "project".to_string(), - client_email: SecretString::new("client@example.com"), - client_id: "".to_string(), // Invalid: empty - auth_uri: "not-a-url".to_string(), // Invalid: not a URL - token_uri: "https://oauth2.googleapis.com/token".to_string(), - auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" - .to_string(), - client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" - .to_string(), - universe_domain: "googleapis.com".to_string(), - }, - key: GoogleCloudKmsSignerKeyConfig { - location: "us-central1".to_string(), - key_ring_id: "".to_string(), // Invalid: empty - key_id: "test-key".to_string(), - key_version: 1, - }, - }; - - let errors = invalid_config.validate().unwrap_err(); - - // Format the errors using the helper function - let formatted = format_validation_errors(&errors); - - println!("formatted: {}", formatted); - - // Check that messages from nested fields are correctly formatted - assert!(formatted.contains("client_id: Client ID cannot be empty")); - assert!(formatted.contains("private_key: Private key cannot be empty")); - assert!(formatted.contains("auth_uri: Auth URI must be a valid URL")); - assert!(formatted.contains("key_ring_id: Key ring ID cannot be empty")); - } - - #[test] - fn test_config_type_getters() { - // Test Vault config getter - let vault_config = VaultSignerConfig { - address: "https://vault.example.com".to_string(), - namespace: None, - role_id: SecretString::new("role"), - secret_id: SecretString::new("secret"), - key_name: "key".to_string(), - mount_point: None, - }; - let config = SignerConfig::Vault(vault_config); - assert!(config.get_vault().is_some()); - - // Test VaultTransit config getter - let vault_transit_config = VaultTransitSignerConfig { - key_name: "key".to_string(), - address: "https://vault.example.com".to_string(), - namespace: None, - role_id: SecretString::new("role"), - secret_id: SecretString::new("secret"), - pubkey: "pubkey".to_string(), - mount_point: None, - }; - let config = SignerConfig::VaultTransit(vault_transit_config); - assert!(config.get_vault_transit().is_some()); - assert!(config.get_turnkey().is_none()); - - // Test Turnkey config getter - let turnkey_config = TurnkeySignerConfig { - api_public_key: "public".to_string(), - api_private_key: SecretString::new("private"), - organization_id: "org".to_string(), - private_key_id: "key-id".to_string(), - public_key: "pubkey".to_string(), - }; - let config = SignerConfig::Turnkey(turnkey_config); - assert!(config.get_turnkey().is_some()); - assert!(config.get_google_cloud_kms().is_none()); - - // Test Google Cloud KMS config getter - let gcp_config = GoogleCloudKmsSignerConfig { - service_account: GoogleCloudKmsSignerServiceAccountConfig { - private_key: SecretString::new("private-key"), - private_key_id: SecretString::new("key-id"), - project_id: "project".to_string(), - client_email: SecretString::new("client@example.com"), - client_id: "client-id".to_string(), - auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), - token_uri: "https://oauth2.googleapis.com/token".to_string(), - auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs" - .to_string(), - client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test" - .to_string(), - universe_domain: "googleapis.com".to_string(), - }, - key: GoogleCloudKmsSignerKeyConfig { - location: "us-central1".to_string(), - key_ring_id: "test-ring".to_string(), - key_id: "test-key".to_string(), - key_version: 1, - }, - }; - let config = SignerConfig::GoogleCloudKms(gcp_config); - assert!(config.get_google_cloud_kms().is_some()); - assert!(config.get_local().is_none()); - } -} diff --git a/src/repositories/relayer/relayer_in_memory.rs b/src/repositories/relayer/relayer_in_memory.rs index 94dcbbb62..b652cea62 100644 --- a/src/repositories/relayer/relayer_in_memory.rs +++ b/src/repositories/relayer/relayer_in_memory.rs @@ -92,7 +92,7 @@ impl RelayerRepository for InMemoryRelayerRepository { relayer .notification_id .as_ref() - .map_or(false, |id| id == notification_id) + .is_some_and(|id| id == notification_id) }) .cloned() .collect(); diff --git a/src/repositories/relayer/relayer_redis.rs b/src/repositories/relayer/relayer_redis.rs index fb997bb49..c69652ebf 100644 --- a/src/repositories/relayer/relayer_redis.rs +++ b/src/repositories/relayer/relayer_redis.rs @@ -467,7 +467,7 @@ impl RelayerRepository for RedisRelayerRepository { relayer .notification_id .as_ref() - .map_or(false, |id| id == notification_id) + .is_some_and(|id| id == notification_id) }) .collect(); @@ -1031,10 +1031,7 @@ mod tests { let result = repo.list_by_signer_id(&signer2_id).await.unwrap(); assert_eq!(result.len(), 1); - let result = repo - .list_by_signer_id(&"nonexistent".to_string()) - .await - .unwrap(); + let result = repo.list_by_signer_id("nonexistent").await.unwrap(); assert_eq!(result.len(), 0); } diff --git a/src/services/signer/evm/mod.rs b/src/services/signer/evm/mod.rs index 1486733f0..1350e3c87 100644 --- a/src/services/signer/evm/mod.rs +++ b/src/services/signer/evm/mod.rs @@ -142,7 +142,11 @@ impl EvmSignerFactory { ); let vault_service = VaultService::new(vault_config); - EvmSigner::Vault(VaultSigner::new(config.clone(), vault_service)) + EvmSigner::Vault(VaultSigner::new( + signer_model.id.clone(), + config.clone(), + vault_service, + )) } SignerConfig::AwsKms(config) => { let aws_service = AwsKmsService::new(config.clone()).await.map_err(|e| { diff --git a/src/services/signer/evm/vault_signer.rs b/src/services/signer/evm/vault_signer.rs index 98124d75b..6c373e980 100644 --- a/src/services/signer/evm/vault_signer.rs +++ b/src/services/signer/evm/vault_signer.rs @@ -28,6 +28,7 @@ use crate::{ #[derive(Clone, Eq)] struct VaultCacheKey { + signer_id: String, address: String, namespace: Option, key_name: String, @@ -36,7 +37,8 @@ struct VaultCacheKey { impl PartialEq for VaultCacheKey { fn eq(&self, other: &Self) -> bool { - self.key_name == other.key_name + self.signer_id == other.signer_id + && self.key_name == other.key_name && self.mount_point == other.mount_point && self.address == other.address && self.namespace == other.namespace @@ -45,6 +47,7 @@ impl PartialEq for VaultCacheKey { impl Hash for VaultCacheKey { fn hash(&self, state: &mut H) { + self.signer_id.hash(state); self.key_name.hash(state); self.mount_point.hash(state); self.address.hash(state); @@ -62,6 +65,7 @@ pub struct VaultSigner where T: VaultServiceTrait + Clone, { + signer_id: String, key_name: String, address: String, namespace: Option, @@ -72,8 +76,9 @@ where } impl VaultSigner { - pub fn new(vault_config: VaultSignerConfig, vault_service: T) -> Self { + pub fn new(signer_id: String, vault_config: VaultSignerConfig, vault_service: T) -> Self { Self { + signer_id, key_name: vault_config.key_name, address: vault_config.address, namespace: vault_config.namespace, @@ -183,6 +188,7 @@ impl VaultSigner { fn create_cache_key(&self) -> Result { Ok(VaultCacheKey { + signer_id: self.signer_id.clone(), address: self.address.clone(), namespace: self.namespace.clone(), key_name: self.key_name.clone(), @@ -256,24 +262,24 @@ mod tests { } } - fn create_test_config() -> VaultSignerConfig { + fn create_test_config(key_name: Option<&str>) -> VaultSignerConfig { VaultSignerConfig { address: "https://vault.test.com".to_string(), namespace: Some("test-namespace".to_string()), role_id: SecretString::new("test-role-id"), secret_id: SecretString::new("test-secret-id"), - key_name: "test-key".to_string(), + key_name: key_name.unwrap_or("test-key").to_string(), mount_point: Some("secret".to_string()), } } #[tokio::test] async fn test_valid_private_key() { - let config = create_test_config(); + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; let mock_service = MockVaultService::new(mock_private_key.to_string()); - - let signer = VaultSigner::new(config, mock_service); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); let address_result = signer.address().await; assert!( @@ -284,11 +290,11 @@ mod tests { #[tokio::test] async fn test_valid_private_key_with_0x_prefix() { - let config = create_test_config(); + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); let mock_private_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; let mock_service = MockVaultService::new(mock_private_key.to_string()); - - let signer = VaultSigner::new(config, mock_service); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); let address_result = signer.address().await; assert!(address_result.is_ok(), "Signer should handle 0x prefix"); @@ -296,11 +302,11 @@ mod tests { #[tokio::test] async fn test_invalid_hex_characters() { - let config = create_test_config(); + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); let invalid_hex = "invalid_hex_string_with_non_hex_chars"; let mock_service = MockVaultService::new(invalid_hex.to_string()); - - let signer = VaultSigner::new(config, mock_service); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); let result = signer.address().await; assert!(result.is_err(), "Should fail with invalid hex characters"); @@ -316,11 +322,11 @@ mod tests { #[tokio::test] async fn test_invalid_key_length() { - let config = create_test_config(); + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); let short_key = "4c0883a69102937d"; // Too short let mock_service = MockVaultService::new(short_key.to_string()); - - let signer = VaultSigner::new(config, mock_service); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); let result = signer.address().await; assert!(result.is_err(), "Should fail with invalid key length"); @@ -336,11 +342,11 @@ mod tests { #[tokio::test] async fn test_empty_key() { - let config = create_test_config(); + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); let empty_key = ""; let mock_service = MockVaultService::new(empty_key.to_string()); - - let signer = VaultSigner::new(config, mock_service); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); let result = signer.address().await; assert!(result.is_err(), "Should fail with empty key"); @@ -353,11 +359,11 @@ mod tests { #[tokio::test] async fn test_caching_behavior() { - let config = create_test_config(); + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; let mock_service = MockVaultService::new(mock_private_key.to_string()); - - let signer = VaultSigner::new(config, mock_service); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); // First call should load from vault let address1 = signer.address().await; diff --git a/src/services/signer/solana/mod.rs b/src/services/signer/solana/mod.rs index 3d1cca396..14a0dead9 100644 --- a/src/services/signer/solana/mod.rs +++ b/src/services/signer/solana/mod.rs @@ -18,6 +18,9 @@ use async_trait::async_trait; mod local_signer; use local_signer::*; +mod vault_signer; +use vault_signer::*; + mod vault_transit_signer; use vault_transit_signer::*; @@ -48,7 +51,7 @@ use mockall::automock; pub enum SolanaSigner { Local(LocalSigner), - Vault(LocalSigner), + Vault(VaultSigner), VaultTransit(VaultTransitSigner), Turnkey(TurnkeySigner), GoogleCloudKms(GoogleCloudKmsSigner), @@ -58,7 +61,8 @@ pub enum SolanaSigner { impl Signer for SolanaSigner { async fn address(&self) -> Result { match self { - Self::Local(signer) | Self::Vault(signer) => signer.address().await, + Self::Local(signer) => signer.address().await, + Self::Vault(signer) => signer.address().await, Self::VaultTransit(signer) => signer.address().await, Self::Turnkey(signer) => signer.address().await, Self::GoogleCloudKms(signer) => signer.address().await, @@ -70,7 +74,8 @@ impl Signer for SolanaSigner { transaction: NetworkTransactionData, ) -> Result { match self { - Self::Local(signer) | Self::Vault(signer) => signer.sign_transaction(transaction).await, + Self::Local(signer) => signer.sign_transaction(transaction).await, + Self::Vault(signer) => signer.sign_transaction(transaction).await, Self::VaultTransit(signer) => signer.sign_transaction(transaction).await, Self::Turnkey(signer) => signer.sign_transaction(transaction).await, Self::GoogleCloudKms(signer) => signer.sign_transaction(transaction).await, @@ -104,7 +109,8 @@ pub trait SolanaSignTrait: Sync + Send { impl SolanaSignTrait for SolanaSigner { async fn pubkey(&self) -> Result { match self { - Self::Local(signer) | Self::Vault(signer) => signer.pubkey().await, + Self::Local(signer) => signer.pubkey().await, + Self::Vault(signer) => signer.pubkey().await, Self::VaultTransit(signer) => signer.pubkey().await, Self::Turnkey(signer) => signer.pubkey().await, Self::GoogleCloudKms(signer) => signer.pubkey().await, @@ -113,7 +119,8 @@ impl SolanaSignTrait for SolanaSigner { async fn sign(&self, message: &[u8]) -> Result { match self { - Self::Local(signer) | Self::Vault(signer) => Ok(signer.sign(message).await?), + Self::Local(signer) => Ok(signer.sign(message).await?), + Self::Vault(signer) => Ok(signer.sign(message).await?), Self::VaultTransit(signer) => Ok(signer.sign(message).await?), Self::Turnkey(signer) => Ok(signer.sign(message).await?), Self::GoogleCloudKms(signer) => Ok(signer.sign(message).await?), @@ -128,8 +135,26 @@ impl SolanaSignerFactory { signer_model: &SignerRepoModel, ) -> Result { let signer = match &signer_model.config { - SignerConfig::Local(_) | SignerConfig::Vault(_) => { - SolanaSigner::Local(LocalSigner::new(signer_model)?) + SignerConfig::Local(_) => SolanaSigner::Local(LocalSigner::new(signer_model)?), + SignerConfig::Vault(config) => { + let vault_config = VaultConfig::new( + config.address.clone(), + config.role_id.clone(), + config.secret_id.clone(), + config.namespace.clone(), + config + .mount_point + .clone() + .unwrap_or_else(|| "secret".to_string()), + None, + ); + let vault_service = VaultService::new(vault_config); + + return Ok(SolanaSigner::Vault(VaultSigner::new( + signer_model.id.clone(), + config.clone(), + vault_service, + ))); } SignerConfig::VaultTransit(vault_transit_signer_config) => { let vault_service = VaultService::new(VaultConfig { @@ -253,8 +278,8 @@ mod solana_signer_factory_tests { let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); match signer { - SolanaSigner::Local(_) => {} - _ => panic!("Expected Local signer"), + SolanaSigner::Vault(_) => {} + _ => panic!("Expected Vault signer"), } } @@ -352,27 +377,27 @@ mod solana_signer_factory_tests { assert_eq!(test_key_bytes_pubkey(), signer_address); assert_eq!(test_key_bytes_pubkey(), signer_pubkey); } - #[tokio::test] - async fn test_address_solana_signer_vault() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::Vault(VaultSignerConfig { - address: "https://vault.test.com".to_string(), - namespace: Some("test-namespace".to_string()), - role_id: crate::models::SecretString::new("test-role-id"), - secret_id: crate::models::SecretString::new("test-secret-id"), - key_name: "test-key".to_string(), - mount_point: Some("secret".to_string()), - }), - }; - - let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); - let signer_address = signer.address().await.unwrap(); - let signer_pubkey = signer.pubkey().await.unwrap(); - - assert_eq!(test_key_bytes_pubkey(), signer_address); - assert_eq!(test_key_bytes_pubkey(), signer_pubkey); - } + // #[tokio::test] + // async fn test_address_solana_signer_vault() { + // let signer_model = SignerRepoModel { + // id: "test".to_string(), + // config: SignerConfig::Vault(VaultSignerConfig { + // address: "https://vault.test.com".to_string(), + // namespace: Some("test-namespace".to_string()), + // role_id: crate::models::SecretString::new("test-role-id"), + // secret_id: crate::models::SecretString::new("test-secret-id"), + // key_name: "test-key".to_string(), + // mount_point: Some("secret".to_string()), + // }), + // }; + + // let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); + // let signer_address = signer.address().await.unwrap(); + // let signer_pubkey = signer.pubkey().await.unwrap(); + + // assert_eq!(test_key_bytes_pubkey(), signer_address); + // assert_eq!(test_key_bytes_pubkey(), signer_pubkey); + // } #[tokio::test] async fn test_address_solana_signer_vault_transit() { @@ -490,24 +515,24 @@ mod solana_signer_factory_tests { assert!(signature.is_ok()); } - #[tokio::test] - async fn test_sign_solana_signer_vault() { - let signer_model = SignerRepoModel { - id: "test".to_string(), - config: SignerConfig::Vault(VaultSignerConfig { - address: "https://vault.test.com".to_string(), - namespace: Some("test-namespace".to_string()), - role_id: crate::models::SecretString::new("test-role-id"), - secret_id: crate::models::SecretString::new("test-secret-id"), - key_name: "test-key".to_string(), - mount_point: Some("secret".to_string()), - }), - }; - - let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); - let message = b"test message"; - let signature = signer.sign(message).await; - - assert!(signature.is_ok()); - } + // #[tokio::test] + // async fn test_sign_solana_signer_vault() { + // let signer_model = SignerRepoModel { + // id: "test".to_string(), + // config: SignerConfig::Vault(VaultSignerConfig { + // address: "https://vault.test.com".to_string(), + // namespace: Some("test-namespace".to_string()), + // role_id: crate::models::SecretString::new("test-role-id"), + // secret_id: crate::models::SecretString::new("test-secret-id"), + // key_name: "test-key".to_string(), + // mount_point: Some("secret".to_string()), + // }), + // }; + + // let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); + // let message = b"test message"; + // let signature = signer.sign(message).await; + + // assert!(signature.is_ok()); + // } } diff --git a/src/services/signer/solana/vault_signer.rs b/src/services/signer/solana/vault_signer.rs new file mode 100644 index 000000000..cc2ede473 --- /dev/null +++ b/src/services/signer/solana/vault_signer.rs @@ -0,0 +1,373 @@ +//! # Vault Signer for Solana +//! +//! This module provides an Solana signer implementation that uses HashiCorp Vault's KV2 engine +//! for secure key management. The private key is fetched once during signer creation and cached +//! in memory for optimal performance. + +use async_trait::async_trait; +use once_cell::sync::Lazy; +use secrets::SecretVec; +use solana_sdk::signature::Signature; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use zeroize::Zeroizing; + +use crate::services::SolanaSignTrait; +use crate::{ + domain::{SignDataRequest, SignDataResponse, SignTransactionResponse, SignTypedDataRequest}, + models::{Address, NetworkTransactionData, SignerError, SignerRepoModel, VaultSignerConfig}, + services::{ + signer::solana::local_signer::LocalSigner, + vault::{VaultService, VaultServiceTrait}, + Signer, + }, +}; + +#[derive(Clone, Eq)] +struct VaultCacheKey { + signer_id: String, + address: String, + namespace: Option, + key_name: String, + mount_point: String, +} + +impl PartialEq for VaultCacheKey { + fn eq(&self, other: &Self) -> bool { + self.signer_id == other.signer_id + && self.key_name == other.key_name + && self.mount_point == other.mount_point + && self.address == other.address + && self.namespace == other.namespace + } +} + +impl Hash for VaultCacheKey { + fn hash(&self, state: &mut H) { + self.signer_id.hash(state); + self.key_name.hash(state); + self.mount_point.hash(state); + self.address.hash(state); + self.namespace.hash(state); + } +} + +// Global signer cache - HashMap keyed by VaultCacheKey +static VAULT_SIGNER_CACHE: Lazy>>> = + Lazy::new(|| RwLock::new(HashMap::new())); + +/// EVM signer that fetches private keys from HashiCorp Vault KV2 engine +#[derive(Clone)] +pub struct VaultSigner +where + T: VaultServiceTrait + Clone, +{ + signer_id: String, + key_name: String, + address: String, + namespace: Option, + mount_point: Option, + vault_service: T, + /// Cached local signer, wrapped in Arc> for thread-safe lazy initialization + local_signer: Arc>>>, +} + +impl VaultSigner { + pub fn new(signer_id: String, vault_config: VaultSignerConfig, vault_service: T) -> Self { + Self { + signer_id, + key_name: vault_config.key_name, + address: vault_config.address, + namespace: vault_config.namespace, + mount_point: vault_config.mount_point, + vault_service, + local_signer: Arc::new(Mutex::new(None)), + } + } + + /// Ensures the local signer is loaded, using caching for performance + async fn get_local_signer(&self) -> Result, SignerError> { + // Fast path: check if already loaded + { + let guard = self.local_signer.lock().await; + if let Some(ref signer) = *guard { + return Ok(Arc::clone(signer)); + } + } + + // Check global cache + let cache_key = self.create_cache_key()?; + { + let cache = VAULT_SIGNER_CACHE.read().await; + if let Some(signer) = cache.get(&cache_key) { + // Update local cache + let mut guard = self.local_signer.lock().await; + *guard = Some(Arc::clone(signer)); + return Ok(Arc::clone(signer)); + } + } + + // Need to load from vault + let signer = self.load_signer_from_vault().await?; + let arc_signer = Arc::new(signer); + + // Update both caches + { + let mut cache = VAULT_SIGNER_CACHE.write().await; + cache.insert(cache_key, Arc::clone(&arc_signer)); + } + { + let mut guard = self.local_signer.lock().await; + *guard = Some(Arc::clone(&arc_signer)); + } + + Ok(arc_signer) + } + + /// Loads a new signer from vault + async fn load_signer_from_vault(&self) -> Result { + let raw_key = self.fetch_private_key().await?; + let local_config = crate::models::LocalSignerConfig { raw_key }; + let local_model = SignerRepoModel { + id: self.key_name.clone(), + config: crate::models::SignerConfig::Local(local_config), + }; + + LocalSigner::new(&local_model) + } + + /// Fetches private key from vault with proper error handling + async fn fetch_private_key(&self) -> Result, SignerError> { + let hex_secret = Zeroizing::new( + self.vault_service + .retrieve_secret(&self.key_name) + .await + .map_err(SignerError::VaultError)?, + ); + + // Validate hex format before decoding + let trimmed = hex_secret.trim(); + if trimmed.is_empty() { + return Err(SignerError::KeyError( + "Empty key received from vault".to_string(), + )); + } + + // Remove '0x' prefix if present + let hex_str = if trimmed.starts_with("0x") || trimmed.starts_with("0X") { + &trimmed[2..] + } else { + trimmed + }; + + // Validate hex characters + if !hex_str.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SignerError::KeyError( + "Invalid hex characters in vault secret".to_string(), + )); + } + + // Validate key length (32 bytes = 64 hex chars for secp256k1) + if hex_str.len() != 64 { + return Err(SignerError::KeyError(format!( + "Invalid key length: expected 64 hex characters, got {}", + hex_str.len() + ))); + } + + let decoded_bytes = hex::decode(hex_str) + .map_err(|e| SignerError::KeyError(format!("Failed to decode hex: {}", e)))?; + + Ok(SecretVec::new(decoded_bytes.len(), |buffer| { + buffer.copy_from_slice(&decoded_bytes); + })) + } + + fn create_cache_key(&self) -> Result { + Ok(VaultCacheKey { + signer_id: self.signer_id.clone(), + address: self.address.clone(), + namespace: self.namespace.clone(), + key_name: self.key_name.clone(), + mount_point: self + .mount_point + .clone() + .unwrap_or_else(|| "secret".to_string()), + }) + } +} + +#[async_trait] +impl Signer for VaultSigner { + async fn address(&self) -> Result { + let signer = self.get_local_signer().await?; + signer.address().await + } + + async fn sign_transaction( + &self, + transaction: NetworkTransactionData, + ) -> Result { + let signer = self.get_local_signer().await?; + signer.sign_transaction(transaction).await + } +} + +#[async_trait] +impl SolanaSignTrait for VaultSigner { + async fn sign(&self, message: &[u8]) -> Result { + let signer = self.get_local_signer().await?; + signer.sign(message).await + } + + async fn pubkey(&self) -> Result { + let signer = self.get_local_signer().await?; + signer.pubkey().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{SecretString, SignerConfig, VaultSignerConfig}; + use crate::services::vault::VaultError; + use async_trait::async_trait; + + // Mock VaultService for testing + #[derive(Clone)] + struct MockVaultService { + mock_secret: String, + } + + impl MockVaultService { + fn new(mock_secret: String) -> Self { + Self { mock_secret } + } + } + + #[async_trait] + impl VaultServiceTrait for MockVaultService { + async fn retrieve_secret(&self, _key_name: &str) -> Result { + Ok(self.mock_secret.clone()) + } + + async fn sign(&self, _key_name: &str, _message: &[u8]) -> Result { + Ok("mock_signature".to_string()) + } + } + + fn create_test_config(key_name: Option<&str>) -> VaultSignerConfig { + VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: SecretString::new("test-role-id"), + secret_id: SecretString::new("test-secret-id"), + key_name: key_name.unwrap_or("test-key").to_string(), + mount_point: Some("secret".to_string()), + } + } + + #[tokio::test] + async fn test_valid_private_key() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_service = MockVaultService::new(mock_private_key.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + let address_result = signer.address().await; + + assert!( + address_result.is_ok(), + "Signer should provide a valid address" + ); + } + + #[tokio::test] + async fn test_valid_private_key_with_0x_prefix() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let mock_private_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_service = MockVaultService::new(mock_private_key.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + let address_result = signer.address().await; + + assert!(address_result.is_ok(), "Signer should handle 0x prefix"); + } + + #[tokio::test] + async fn test_invalid_hex_characters() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let invalid_hex = "invalid_hex_string_with_non_hex_chars"; + let mock_service = MockVaultService::new(invalid_hex.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + let result = signer.address().await; + + assert!(result.is_err(), "Should fail with invalid hex characters"); + if let Err(SignerError::KeyError(msg)) = result { + assert!( + msg.contains("Invalid hex characters"), + "Error should mention invalid hex characters" + ); + } else { + panic!("Expected KeyError for invalid hex characters"); + } + } + + #[tokio::test] + async fn test_invalid_key_length() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let short_key = "4c0883a69102937d"; // Too short + let mock_service = MockVaultService::new(short_key.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + let result = signer.address().await; + + assert!(result.is_err(), "Should fail with invalid key length"); + if let Err(SignerError::KeyError(msg)) = result { + assert!( + msg.contains("Invalid key length"), + "Error should mention invalid key length" + ); + } else { + panic!("Expected KeyError for invalid key length"); + } + } + + #[tokio::test] + async fn test_empty_key() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let empty_key = ""; + let mock_service = MockVaultService::new(empty_key.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + let result = signer.address().await; + + assert!(result.is_err(), "Should fail with empty key"); + if let Err(SignerError::KeyError(msg)) = result { + assert!(msg.contains("Empty key"), "Error should mention empty key"); + } else { + panic!("Expected KeyError for empty key"); + } + } + + #[tokio::test] + async fn test_caching_behavior() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_service = MockVaultService::new(mock_private_key.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + + // First call should load from vault + let address1 = signer.address().await; + assert!(address1.is_ok()); + + // Second call should use cached version + let address2 = signer.address().await; + assert!(address2.is_ok()); + assert_eq!(address1.unwrap(), address2.unwrap()); + } +} From 486d52fbd080fcc55c0891f6cc00d1c5dab6b7cb Mon Sep 17 00:00:00 2001 From: Zeljko Date: Fri, 18 Jul 2025 14:11:31 +0200 Subject: [PATCH 21/59] chore: fixes --- src/api/routes/relayer.rs | 2 +- src/models/signer/mod.rs | 9 ++++ src/services/signer/evm/vault_signer.rs | 2 +- src/services/signer/solana/mod.rs | 42 ------------------- src/services/signer/solana/vault_signer.rs | 2 +- .../test_keys/unit-test-local-signer.json | 2 +- 6 files changed, 13 insertions(+), 46 deletions(-) diff --git a/src/api/routes/relayer.rs b/src/api/routes/relayer.rs index b98ea0a70..f6f913c3f 100644 --- a/src/api/routes/relayer.rs +++ b/src/api/routes/relayer.rs @@ -241,7 +241,7 @@ mod tests { }; network_repo.create(test_network).await.unwrap(); - // Create test signer first + // Create local signer first let test_signer = crate::models::SignerRepoModel { id: "test-signer".to_string(), config: crate::models::SignerConfig::Local(crate::models::LocalSignerConfig { diff --git a/src/models/signer/mod.rs b/src/models/signer/mod.rs index e2446d92c..e18a4879e 100644 --- a/src/models/signer/mod.rs +++ b/src/models/signer/mod.rs @@ -100,6 +100,15 @@ impl<'de> Deserialize<'de> for LocalSignerConfig { } /// AWS KMS signer configuration +/// The configuration supports: +/// - AWS Region (aws_region) - important for region-specific key +/// - KMS Key identification (key_id) +/// +/// The AWS authentication is carried out +/// through recommended credential providers as outlined in +/// https://docs.aws.amazon.com/sdk-for-rust/latest/dg/credproviders.html +/// Currently only EVM signing is supported since, as of June 2025, +/// AWS does not support ed25519 scheme #[derive(Debug, Clone, Serialize, Deserialize, Validate)] pub struct AwsKmsSignerConfig { #[validate(length(min = 1, message = "Region cannot be empty"))] diff --git a/src/services/signer/evm/vault_signer.rs b/src/services/signer/evm/vault_signer.rs index 6c373e980..5a4df520b 100644 --- a/src/services/signer/evm/vault_signer.rs +++ b/src/services/signer/evm/vault_signer.rs @@ -71,7 +71,7 @@ where namespace: Option, mount_point: Option, vault_service: T, - /// Cached local signer, wrapped in Arc> for thread-safe lazy initialization + /// Cached local signer local_signer: Arc>>>, } diff --git a/src/services/signer/solana/mod.rs b/src/services/signer/solana/mod.rs index 14a0dead9..7125e0a13 100644 --- a/src/services/signer/solana/mod.rs +++ b/src/services/signer/solana/mod.rs @@ -377,27 +377,6 @@ mod solana_signer_factory_tests { assert_eq!(test_key_bytes_pubkey(), signer_address); assert_eq!(test_key_bytes_pubkey(), signer_pubkey); } - // #[tokio::test] - // async fn test_address_solana_signer_vault() { - // let signer_model = SignerRepoModel { - // id: "test".to_string(), - // config: SignerConfig::Vault(VaultSignerConfig { - // address: "https://vault.test.com".to_string(), - // namespace: Some("test-namespace".to_string()), - // role_id: crate::models::SecretString::new("test-role-id"), - // secret_id: crate::models::SecretString::new("test-secret-id"), - // key_name: "test-key".to_string(), - // mount_point: Some("secret".to_string()), - // }), - // }; - - // let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); - // let signer_address = signer.address().await.unwrap(); - // let signer_pubkey = signer.pubkey().await.unwrap(); - - // assert_eq!(test_key_bytes_pubkey(), signer_address); - // assert_eq!(test_key_bytes_pubkey(), signer_pubkey); - // } #[tokio::test] async fn test_address_solana_signer_vault_transit() { @@ -514,25 +493,4 @@ mod solana_signer_factory_tests { assert!(signature.is_ok()); } - - // #[tokio::test] - // async fn test_sign_solana_signer_vault() { - // let signer_model = SignerRepoModel { - // id: "test".to_string(), - // config: SignerConfig::Vault(VaultSignerConfig { - // address: "https://vault.test.com".to_string(), - // namespace: Some("test-namespace".to_string()), - // role_id: crate::models::SecretString::new("test-role-id"), - // secret_id: crate::models::SecretString::new("test-secret-id"), - // key_name: "test-key".to_string(), - // mount_point: Some("secret".to_string()), - // }), - // }; - - // let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); - // let message = b"test message"; - // let signature = signer.sign(message).await; - - // assert!(signature.is_ok()); - // } } diff --git a/src/services/signer/solana/vault_signer.rs b/src/services/signer/solana/vault_signer.rs index cc2ede473..0f495ea48 100644 --- a/src/services/signer/solana/vault_signer.rs +++ b/src/services/signer/solana/vault_signer.rs @@ -70,7 +70,7 @@ where namespace: Option, mount_point: Option, vault_service: T, - /// Cached local signer, wrapped in Arc> for thread-safe lazy initialization + /// Cached local signer local_signer: Arc>>>, } diff --git a/tests/utils/test_keys/unit-test-local-signer.json b/tests/utils/test_keys/unit-test-local-signer.json index f578d757d..5047f6e2c 100644 --- a/tests/utils/test_keys/unit-test-local-signer.json +++ b/tests/utils/test_keys/unit-test-local-signer.json @@ -1 +1 @@ -{"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"1f22be94c4f5af8e1b7ed4839844718e"},"ciphertext":"3a5de39a934f05de412f8b6a503c9b37480e5e75bbd943b2ea4595c73a7c47b0","kdf":"scrypt","kdfparams":{"dklen":32,"n":8192,"p":1,"r":8,"salt":"016200642e10b4093d8f16fcafbd4fe1fa3dc39e6c173e61252274006f0c8b67"},"mac":"997687605cfb66ca440a321e56a2c989320c08a132271376b1b7412b2e431cf4"},"id":"2af4511c-b352-41e0-bf6d-a63bfd530ce9","version":3} +{"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"7c1cbf3ae5acf267f79911527f2460cb"},"ciphertext":"27915ffbb603ea3818718911b355b57310373e487c9b088869df1bc0a041d375","kdf":"scrypt","kdfparams":{"dklen":32,"n":8192,"p":1,"r":8,"salt":"f6ca684f462c64b2a5d810e23dc903a2e8d6e34a1dacd9cc1b24d65b866461a7"},"mac":"8210e4e76fa40b8964ba1b0c4260667253f53e68dbab7c682bdf9d7bc515a331"},"id":"7f5e335e-2dbb-4584-be97-738a79940b4a","version":3} \ No newline at end of file From 63aae5a0b81d6175d2666eb23d5acf416b37f638 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Fri, 18 Jul 2025 14:19:18 +0200 Subject: [PATCH 22/59] chore: add noboost comments --- src/services/signer/evm/vault_signer.rs | 2 +- src/services/signer/solana/vault_signer.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/signer/evm/vault_signer.rs b/src/services/signer/evm/vault_signer.rs index 5a4df520b..fa5335fc1 100644 --- a/src/services/signer/evm/vault_signer.rs +++ b/src/services/signer/evm/vault_signer.rs @@ -276,7 +276,7 @@ mod tests { #[tokio::test] async fn test_valid_private_key() { let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); - let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; // noboost let mock_service = MockVaultService::new(mock_private_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); diff --git a/src/services/signer/solana/vault_signer.rs b/src/services/signer/solana/vault_signer.rs index 0f495ea48..5db2c423c 100644 --- a/src/services/signer/solana/vault_signer.rs +++ b/src/services/signer/solana/vault_signer.rs @@ -287,7 +287,7 @@ mod tests { #[tokio::test] async fn test_valid_private_key_with_0x_prefix() { let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); - let mock_private_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_private_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; // noboost let mock_service = MockVaultService::new(mock_private_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); From c4c7ea84d54e4949e3207287cfa1c662bc601faa Mon Sep 17 00:00:00 2001 From: Zeljko Date: Fri, 18 Jul 2025 14:22:06 +0200 Subject: [PATCH 23/59] chore: typo --- src/services/signer/solana/vault_signer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/signer/solana/vault_signer.rs b/src/services/signer/solana/vault_signer.rs index 5db2c423c..bf3e73bbb 100644 --- a/src/services/signer/solana/vault_signer.rs +++ b/src/services/signer/solana/vault_signer.rs @@ -58,7 +58,7 @@ impl Hash for VaultCacheKey { static VAULT_SIGNER_CACHE: Lazy>>> = Lazy::new(|| RwLock::new(HashMap::new())); -/// EVM signer that fetches private keys from HashiCorp Vault KV2 engine +/// Solana signer that fetches private keys from HashiCorp Vault KV2 engine #[derive(Clone)] pub struct VaultSigner where From 7a63ab9e7e59beffa1ec6c0df3d2ad05b6d0f870 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Fri, 18 Jul 2025 14:31:19 +0200 Subject: [PATCH 24/59] chore: stellar --- src/services/signer/stellar/mod.rs | 41 ++- src/services/signer/stellar/vault_signer.rs | 358 ++++++++++++++++++++ 2 files changed, 391 insertions(+), 8 deletions(-) create mode 100644 src/services/signer/stellar/vault_signer.rs diff --git a/src/services/signer/stellar/mod.rs b/src/services/signer/stellar/mod.rs index d00ae00f7..a1ab1c043 100644 --- a/src/services/signer/stellar/mod.rs +++ b/src/services/signer/stellar/mod.rs @@ -2,28 +2,34 @@ //! Stellar signer implementation (local keystore) mod local_signer; +mod vault_signer; + use async_trait::async_trait; use local_signer::*; +use vault_signer::*; use crate::{ domain::{SignDataRequest, SignDataResponse, SignTransactionResponse, SignTypedDataRequest}, models::{Address, NetworkTransactionData, SignerConfig, SignerRepoModel}, - services::signer::{SignerError, SignerFactoryError}, - services::Signer, + services::{ + signer::{SignerError, SignerFactoryError}, + Signer, VaultConfig, VaultService, + }, }; use super::DataSignerTrait; pub enum StellarSigner { Local(LocalSigner), - Vault(LocalSigner), + Vault(VaultSigner), } #[async_trait] impl Signer for StellarSigner { async fn address(&self) -> Result { match self { - Self::Local(s) | Self::Vault(s) => s.address().await, + Self::Local(s) => s.address().await, + Self::Vault(s) => s.address().await, } } @@ -32,7 +38,8 @@ impl Signer for StellarSigner { tx: NetworkTransactionData, ) -> Result { match self { - Self::Local(s) | Self::Vault(s) => s.sign_transaction(tx).await, + Self::Local(s) => s.sign_transaction(tx).await, + Self::Vault(s) => s.sign_transaction(tx).await, } } } @@ -41,9 +48,27 @@ pub struct StellarSignerFactory; impl StellarSignerFactory { pub fn create_stellar_signer(m: &SignerRepoModel) -> Result { - let signer = match m.config { - SignerConfig::Local(_) | SignerConfig::Vault(_) => { - StellarSigner::Local(LocalSigner::new(m)?) + let signer = match &m.config { + SignerConfig::Local(_) => StellarSigner::Local(LocalSigner::new(m)?), + SignerConfig::Vault(config) => { + let vault_config = VaultConfig::new( + config.address.clone(), + config.role_id.clone(), + config.secret_id.clone(), + config.namespace.clone(), + config + .mount_point + .clone() + .unwrap_or_else(|| "secret".to_string()), + None, + ); + let vault_service = VaultService::new(vault_config); + + StellarSigner::Vault(VaultSigner::new( + m.id.clone(), + config.clone(), + vault_service, + )) } SignerConfig::AwsKms(_) => { return Err(SignerFactoryError::UnsupportedType("AWS KMS".into())) diff --git a/src/services/signer/stellar/vault_signer.rs b/src/services/signer/stellar/vault_signer.rs new file mode 100644 index 000000000..85fde1346 --- /dev/null +++ b/src/services/signer/stellar/vault_signer.rs @@ -0,0 +1,358 @@ +//! # Vault Signer for Stellar +//! +//! This module provides an Stellar signer implementation that uses HashiCorp Vault's KV2 engine +//! for secure key management. The private key is fetched once during signer creation and cached +//! in memory for optimal performance. + +use async_trait::async_trait; +use once_cell::sync::Lazy; +use secrets::SecretVec; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use zeroize::Zeroizing; + +use crate::{ + domain::{SignDataRequest, SignDataResponse, SignTransactionResponse, SignTypedDataRequest}, + models::{Address, NetworkTransactionData, SignerError, SignerRepoModel, VaultSignerConfig}, + services::{ + signer::stellar::local_signer::LocalSigner, + vault::{VaultService, VaultServiceTrait}, + Signer, + }, +}; + +#[derive(Clone, Eq)] +struct VaultCacheKey { + signer_id: String, + address: String, + namespace: Option, + key_name: String, + mount_point: String, +} + +impl PartialEq for VaultCacheKey { + fn eq(&self, other: &Self) -> bool { + self.signer_id == other.signer_id + && self.key_name == other.key_name + && self.mount_point == other.mount_point + && self.address == other.address + && self.namespace == other.namespace + } +} + +impl Hash for VaultCacheKey { + fn hash(&self, state: &mut H) { + self.signer_id.hash(state); + self.key_name.hash(state); + self.mount_point.hash(state); + self.address.hash(state); + self.namespace.hash(state); + } +} + +// Global signer cache - HashMap keyed by VaultCacheKey +static VAULT_SIGNER_CACHE: Lazy>>> = + Lazy::new(|| RwLock::new(HashMap::new())); + +/// Stellar signer that fetches private keys from HashiCorp Vault KV2 engine +#[derive(Clone)] +pub struct VaultSigner +where + T: VaultServiceTrait + Clone, +{ + signer_id: String, + key_name: String, + address: String, + namespace: Option, + mount_point: Option, + vault_service: T, + /// Cached local signer + local_signer: Arc>>>, +} + +impl VaultSigner { + pub fn new(signer_id: String, vault_config: VaultSignerConfig, vault_service: T) -> Self { + Self { + signer_id, + key_name: vault_config.key_name, + address: vault_config.address, + namespace: vault_config.namespace, + mount_point: vault_config.mount_point, + vault_service, + local_signer: Arc::new(Mutex::new(None)), + } + } + + /// Ensures the local signer is loaded, using caching for performance + async fn get_local_signer(&self) -> Result, SignerError> { + // Fast path: check if already loaded + { + let guard = self.local_signer.lock().await; + if let Some(ref signer) = *guard { + return Ok(Arc::clone(signer)); + } + } + + // Check global cache + let cache_key = self.create_cache_key()?; + { + let cache = VAULT_SIGNER_CACHE.read().await; + if let Some(signer) = cache.get(&cache_key) { + // Update local cache + let mut guard = self.local_signer.lock().await; + *guard = Some(Arc::clone(signer)); + return Ok(Arc::clone(signer)); + } + } + + // Need to load from vault + let signer = self.load_signer_from_vault().await?; + let arc_signer = Arc::new(signer); + + // Update both caches + { + let mut cache = VAULT_SIGNER_CACHE.write().await; + cache.insert(cache_key, Arc::clone(&arc_signer)); + } + { + let mut guard = self.local_signer.lock().await; + *guard = Some(Arc::clone(&arc_signer)); + } + + Ok(arc_signer) + } + + /// Loads a new signer from vault + async fn load_signer_from_vault(&self) -> Result { + let raw_key = self.fetch_private_key().await?; + let local_config = crate::models::LocalSignerConfig { raw_key }; + let local_model = SignerRepoModel { + id: self.key_name.clone(), + config: crate::models::SignerConfig::Local(local_config), + }; + + LocalSigner::new(&local_model) + } + + /// Fetches private key from vault with proper error handling + async fn fetch_private_key(&self) -> Result, SignerError> { + let hex_secret = Zeroizing::new( + self.vault_service + .retrieve_secret(&self.key_name) + .await + .map_err(SignerError::VaultError)?, + ); + + // Validate hex format before decoding + let trimmed = hex_secret.trim(); + if trimmed.is_empty() { + return Err(SignerError::KeyError( + "Empty key received from vault".to_string(), + )); + } + + // Remove '0x' prefix if present + let hex_str = if trimmed.starts_with("0x") || trimmed.starts_with("0X") { + &trimmed[2..] + } else { + trimmed + }; + + // Validate hex characters + if !hex_str.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SignerError::KeyError( + "Invalid hex characters in vault secret".to_string(), + )); + } + + // Validate key length (32 bytes = 64 hex chars for secp256k1) + if hex_str.len() != 64 { + return Err(SignerError::KeyError(format!( + "Invalid key length: expected 64 hex characters, got {}", + hex_str.len() + ))); + } + + let decoded_bytes = hex::decode(hex_str) + .map_err(|e| SignerError::KeyError(format!("Failed to decode hex: {}", e)))?; + + Ok(SecretVec::new(decoded_bytes.len(), |buffer| { + buffer.copy_from_slice(&decoded_bytes); + })) + } + + fn create_cache_key(&self) -> Result { + Ok(VaultCacheKey { + signer_id: self.signer_id.clone(), + address: self.address.clone(), + namespace: self.namespace.clone(), + key_name: self.key_name.clone(), + mount_point: self + .mount_point + .clone() + .unwrap_or_else(|| "secret".to_string()), + }) + } +} + +#[async_trait] +impl Signer for VaultSigner { + async fn address(&self) -> Result { + let signer = self.get_local_signer().await?; + signer.address().await + } + + async fn sign_transaction( + &self, + transaction: NetworkTransactionData, + ) -> Result { + let signer = self.get_local_signer().await?; + signer.sign_transaction(transaction).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{SecretString, VaultSignerConfig}; + use crate::services::vault::VaultError; + use async_trait::async_trait; + + // Mock VaultService for testing + #[derive(Clone)] + struct MockVaultService { + mock_secret: String, + } + + impl MockVaultService { + fn new(mock_secret: String) -> Self { + Self { mock_secret } + } + } + + #[async_trait] + impl VaultServiceTrait for MockVaultService { + async fn retrieve_secret(&self, _key_name: &str) -> Result { + Ok(self.mock_secret.clone()) + } + + async fn sign(&self, _key_name: &str, _message: &[u8]) -> Result { + Ok("mock_signature".to_string()) + } + } + + fn create_test_config(key_name: Option<&str>) -> VaultSignerConfig { + VaultSignerConfig { + address: "https://vault.test.com".to_string(), + namespace: Some("test-namespace".to_string()), + role_id: SecretString::new("test-role-id"), + secret_id: SecretString::new("test-secret-id"), + key_name: key_name.unwrap_or("test-key").to_string(), + mount_point: Some("secret".to_string()), + } + } + + #[tokio::test] + async fn test_valid_private_key() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_service = MockVaultService::new(mock_private_key.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + let address_result = signer.address().await; + + assert!( + address_result.is_ok(), + "Signer should provide a valid address" + ); + } + + #[tokio::test] + async fn test_valid_private_key_with_0x_prefix() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let mock_private_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; // noboost + let mock_service = MockVaultService::new(mock_private_key.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + let address_result = signer.address().await; + + assert!(address_result.is_ok(), "Signer should handle 0x prefix"); + } + + #[tokio::test] + async fn test_invalid_hex_characters() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let invalid_hex = "invalid_hex_string_with_non_hex_chars"; + let mock_service = MockVaultService::new(invalid_hex.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + let result = signer.address().await; + + assert!(result.is_err(), "Should fail with invalid hex characters"); + if let Err(SignerError::KeyError(msg)) = result { + assert!( + msg.contains("Invalid hex characters"), + "Error should mention invalid hex characters" + ); + } else { + panic!("Expected KeyError for invalid hex characters"); + } + } + + #[tokio::test] + async fn test_invalid_key_length() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let short_key = "4c0883a69102937d"; // Too short + let mock_service = MockVaultService::new(short_key.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + let result = signer.address().await; + + assert!(result.is_err(), "Should fail with invalid key length"); + if let Err(SignerError::KeyError(msg)) = result { + assert!( + msg.contains("Invalid key length"), + "Error should mention invalid key length" + ); + } else { + panic!("Expected KeyError for invalid key length"); + } + } + + #[tokio::test] + async fn test_empty_key() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let empty_key = ""; + let mock_service = MockVaultService::new(empty_key.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + let result = signer.address().await; + + assert!(result.is_err(), "Should fail with empty key"); + if let Err(SignerError::KeyError(msg)) = result { + assert!(msg.contains("Empty key"), "Error should mention empty key"); + } else { + panic!("Expected KeyError for empty key"); + } + } + + #[tokio::test] + async fn test_caching_behavior() { + let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_service = MockVaultService::new(mock_private_key.to_string()); + let signer_id = uuid::Uuid::new_v4().to_string(); + let signer = VaultSigner::new(signer_id, config, mock_service); + + // First call should load from vault + let address1 = signer.address().await; + assert!(address1.is_ok()); + + // Second call should use cached version + let address2 = signer.address().await; + assert!(address2.is_ok()); + assert_eq!(address1.unwrap(), address2.unwrap()); + } +} From 21d798ed7d47608510a395fe2693b56298e9dcf9 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Fri, 18 Jul 2025 15:33:32 +0200 Subject: [PATCH 25/59] chore: noboost --- src/services/signer/evm/vault_signer.rs | 4 ++-- src/services/signer/solana/vault_signer.rs | 4 ++-- src/services/signer/stellar/vault_signer.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/services/signer/evm/vault_signer.rs b/src/services/signer/evm/vault_signer.rs index fa5335fc1..37ebd3a46 100644 --- a/src/services/signer/evm/vault_signer.rs +++ b/src/services/signer/evm/vault_signer.rs @@ -291,7 +291,7 @@ mod tests { #[tokio::test] async fn test_valid_private_key_with_0x_prefix() { let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); - let mock_private_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_private_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; // noboost let mock_service = MockVaultService::new(mock_private_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); @@ -360,7 +360,7 @@ mod tests { #[tokio::test] async fn test_caching_behavior() { let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); - let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; // noboost let mock_service = MockVaultService::new(mock_private_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); diff --git a/src/services/signer/solana/vault_signer.rs b/src/services/signer/solana/vault_signer.rs index bf3e73bbb..fe85fc9c3 100644 --- a/src/services/signer/solana/vault_signer.rs +++ b/src/services/signer/solana/vault_signer.rs @@ -272,7 +272,7 @@ mod tests { #[tokio::test] async fn test_valid_private_key() { let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); - let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; // noboost let mock_service = MockVaultService::new(mock_private_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); @@ -356,7 +356,7 @@ mod tests { #[tokio::test] async fn test_caching_behavior() { let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); - let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; // noboost let mock_service = MockVaultService::new(mock_private_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); diff --git a/src/services/signer/stellar/vault_signer.rs b/src/services/signer/stellar/vault_signer.rs index 85fde1346..2a9dc3467 100644 --- a/src/services/signer/stellar/vault_signer.rs +++ b/src/services/signer/stellar/vault_signer.rs @@ -257,7 +257,7 @@ mod tests { #[tokio::test] async fn test_valid_private_key() { let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); - let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; // noboost let mock_service = MockVaultService::new(mock_private_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); @@ -341,7 +341,7 @@ mod tests { #[tokio::test] async fn test_caching_behavior() { let config = create_test_config(Some(uuid::Uuid::new_v4().to_string().as_str())); - let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let mock_private_key = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; // noboost let mock_service = MockVaultService::new(mock_private_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); From 23bac0cd0166cb5fced9c591eea7d5669c5954f6 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 22 Jul 2025 00:29:04 +0200 Subject: [PATCH 26/59] feat: plat-6845 implement relayer models --- src/bootstrap/config_processor.rs | 9 +- src/config/config_file/mod.rs | 4 +- src/config/config_file/relayer.rs | 1079 ----------------- src/constants/relayer.rs | 40 +- src/domain/relayer/evm/evm_relayer.rs | 6 +- src/domain/relayer/evm/validations.rs | 19 +- src/domain/relayer/solana/dex/mod.rs | 62 +- .../solana/rpc/methods/fee_estimate.rs | 15 +- .../rpc/methods/get_supported_tokens.rs | 3 +- .../solana/rpc/methods/prepare_transaction.rs | 23 +- .../rpc/methods/sign_and_send_transaction.rs | 5 +- .../solana/rpc/methods/sign_transaction.rs | 17 +- .../relayer/solana/rpc/methods/test_setup.rs | 36 +- .../rpc/methods/transfer_transaction.rs | 25 +- .../relayer/solana/rpc/methods/utils.rs | 4 +- .../relayer/solana/rpc/methods/validations.rs | 29 +- src/domain/relayer/solana/solana_relayer.rs | 27 +- src/domain/transaction/evm/evm_transaction.rs | 23 +- src/domain/transaction/evm/replacement.rs | 2 +- src/jobs/handlers/notification_handler.rs | 20 +- src/models/error/relayer.rs | 3 + src/models/mod.rs | 11 +- src/models/relayer/config.rs | 483 ++++++++ src/models/relayer/mod.rs | 760 +++++++++++- src/models/relayer/repository.rs | 460 +------ src/models/relayer/request.rs | 146 +++ src/models/relayer/response.rs | 441 +++---- src/models/transaction/repository.rs | 2 +- src/models/transaction/request/evm.rs | 4 +- src/repositories/relayer/mod.rs | 12 +- src/repositories/relayer/relayer_in_memory.rs | 14 +- src/repositories/relayer/relayer_redis.rs | 10 +- src/utils/mocks.rs | 4 +- 33 files changed, 1847 insertions(+), 1951 deletions(-) delete mode 100644 src/config/config_file/relayer.rs create mode 100644 src/models/relayer/config.rs create mode 100644 src/models/relayer/request.rs diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index c9418caeb..c69729993 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -209,8 +209,10 @@ where let signers = app_state.signer_repository.list_all().await?; let relayer_futures = config_file.relayers.iter().map(|relayer| async { - let mut repo_model = RelayerRepoModel::try_from(relayer.clone()) - .wrap_err("Failed to convert relayer config")?; + // Convert config to domain model first, then to repository model + let domain_relayer = crate::models::Relayer::try_from(relayer.clone()) + .wrap_err("Failed to convert relayer config to domain model")?; + let mut repo_model = RelayerRepoModel::from(domain_relayer); let signer_model = signers .iter() .find(|s| s.id == repo_model.signer_id) @@ -340,9 +342,10 @@ where mod tests { use super::*; use crate::{ - config::{ConfigFileNetworkType, NetworksFileConfig, PluginFileConfig, RelayerFileConfig}, + config::{ConfigFileNetworkType, NetworksFileConfig, PluginFileConfig}, constants::DEFAULT_PLUGIN_TIMEOUT_SECONDS, jobs::MockJobProducerTrait, + models::relayer::RelayerFileConfig, models::{ AppState, AwsKmsSignerFileConfig, GoogleCloudKmsKeyFileConfig, GoogleCloudKmsServiceAccountFileConfig, GoogleCloudKmsSignerFileConfig, diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index 0d6e30b5c..7895777f8 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -24,6 +24,7 @@ use crate::{ config::ConfigFileError, models::{ + relayer::{RelayerFileConfig, RelayersFileConfig}, signer::{SignerFileConfig, SignersFileConfig}, NotificationConfig, NotificationConfigs, }, @@ -34,9 +35,6 @@ use std::{ fs::{self}, }; -mod relayer; -pub use relayer::*; - mod plugin; pub use plugin::*; diff --git a/src/config/config_file/relayer.rs b/src/config/config_file/relayer.rs deleted file mode 100644 index 937c64e27..000000000 --- a/src/config/config_file/relayer.rs +++ /dev/null @@ -1,1079 +0,0 @@ -//! Configuration file definitions for relayer services. -//! -//! Provides configuration structures and validation for relayer settings: -//! - Network configuration (EVM, Solana, Stellar) -//! - Gas/fee policies -//! - Transaction validation rules -//! - Network endpoints -use super::{ConfigFileError, ConfigFileNetworkType, NetworksFileConfig}; -use crate::models::RpcConfig; -use apalis_cron::Schedule; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use std::str::FromStr; - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "lowercase")] -pub enum ConfigFileRelayerNetworkPolicy { - Evm(ConfigFileRelayerEvmPolicy), - Solana(ConfigFileRelayerSolanaPolicy), - Stellar(ConfigFileRelayerStellarPolicy), -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct ConfigFileRelayerEvmPolicy { - pub gas_price_cap: Option, - pub whitelist_receivers: Option>, - pub eip1559_pricing: Option, - pub private_transactions: Option, - pub min_balance: Option, - pub gas_limit_estimation: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct AllowedTokenSwapConfig { - /// Conversion slippage percentage for token. Optional. - pub slippage_percentage: Option, - /// Minimum amount of tokens to swap. Optional. - pub min_amount: Option, - /// Maximum amount of tokens to swap. Optional. - pub max_amount: Option, - /// Minimum amount of tokens to retain after swap. Optional. - pub retain_min_amount: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct AllowedToken { - pub mint: String, - /// Maximum supported token fee (in lamports) for a transaction. Optional. - pub max_allowed_fee: Option, - /// Swap configuration for the token. Optional. - pub swap_config: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum ConfigFileRelayerSolanaFeePaymentStrategy { - User, - Relayer, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "kebab-case")] -pub enum ConfigFileRelayerSolanaSwapStrategy { - JupiterSwap, - JupiterUltra, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JupiterSwapOptions { - /// Maximum priority fee (in lamports) for a transaction. Optional. - pub priority_fee_max_lamports: Option, - /// Priority. Optional. - pub priority_level: Option, - - pub dynamic_compute_unit_limit: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct ConfigFileRelayerSolanaSwapPolicy { - /// DEX strategy to use for token swaps. - pub strategy: Option, - - /// Cron schedule for executing token swap logic to keep relayer funded. Optional. - pub cron_schedule: Option, - - /// Min sol balance to execute token swap logic to keep relayer funded. Optional. - pub min_balance_threshold: Option, - - /// Swap options for JupiterSwap strategy. Optional. - pub jupiter_swap_options: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct ConfigFileRelayerSolanaPolicy { - /// Determines if the relayer pays the transaction fee or the user. Optional. - pub fee_payment_strategy: Option, - - /// Fee margin percentage for the relayer. Optional. - pub fee_margin_percentage: Option, - - /// Minimum balance required for the relayer (in lamports). Optional. - pub min_balance: Option, - - /// List of allowed tokens by their identifiers. Only these tokens are supported if provided. - pub allowed_tokens: Option>, - - /// List of allowed programs by their identifiers. Only these programs are supported if - /// provided. - pub allowed_programs: Option>, - - /// List of allowed accounts by their public keys. The relayer will only operate with these - /// accounts if provided. - pub allowed_accounts: Option>, - - /// List of disallowed accounts by their public keys. These accounts will be explicitly - /// blocked. - pub disallowed_accounts: Option>, - - /// Maximum transaction size. Optional. - pub max_tx_data_size: Option, - - /// Maximum supported signatures. Optional. - pub max_signatures: Option, - - /// Maximum allowed fee (in lamports) for a transaction. Optional. - pub max_allowed_fee_lamports: Option, - - /// Swap dex config to use for token swaps. Optional. - pub swap_config: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct ConfigFileRelayerStellarPolicy { - pub max_fee: Option, - pub timeout_seconds: Option, - pub min_balance: Option, -} - -#[derive(Debug, Serialize, Clone)] -pub struct RelayerFileConfig { - pub id: String, - pub name: String, - pub network: String, - pub paused: bool, - #[serde(flatten)] - pub network_type: ConfigFileNetworkType, - #[serde(default)] - pub policies: Option, - pub signer_id: String, - #[serde(default)] - pub notification_id: Option, - #[serde(default)] - pub custom_rpc_urls: Option>, -} -use serde::{de, Deserializer}; -use serde_json::Value; - -impl<'de> Deserialize<'de> for RelayerFileConfig { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - // Deserialize as a generic JSON object - let mut value: Value = Value::deserialize(deserializer)?; - - // Extract and validate required fields - let id = value - .get("id") - .and_then(Value::as_str) - .ok_or_else(|| de::Error::missing_field("id"))? - .to_string(); - - let name = value - .get("name") - .and_then(Value::as_str) - .ok_or_else(|| de::Error::missing_field("name"))? - .to_string(); - - let network = value - .get("network") - .and_then(Value::as_str) - .ok_or_else(|| de::Error::missing_field("network"))? - .to_string(); - - let paused = value - .get("paused") - .and_then(Value::as_bool) - .ok_or_else(|| de::Error::missing_field("paused"))?; - - // Deserialize `network_type` using `ConfigFileNetworkType` - let network_type: ConfigFileNetworkType = serde_json::from_value( - value - .get("network_type") - .cloned() - .ok_or_else(|| de::Error::missing_field("network_type"))?, - ) - .map_err(de::Error::custom)?; - - let signer_id = value - .get("signer_id") - .and_then(Value::as_str) - .ok_or_else(|| de::Error::missing_field("signer_id"))? - .to_string(); - - let notification_id = value - .get("notification_id") - .and_then(Value::as_str) - .map(|s| s.to_string()); - - // Handle `policies`, using `network_type` to determine how to deserialize - let policies = if let Some(policy_value) = value.get_mut("policies") { - match network_type { - ConfigFileNetworkType::Evm => { - serde_json::from_value::(policy_value.clone()) - .map(ConfigFileRelayerNetworkPolicy::Evm) - .map(Some) - .map_err(de::Error::custom) - } - ConfigFileNetworkType::Solana => { - serde_json::from_value::(policy_value.clone()) - .map(ConfigFileRelayerNetworkPolicy::Solana) - .map(Some) - .map_err(de::Error::custom) - } - ConfigFileNetworkType::Stellar => { - serde_json::from_value::(policy_value.clone()) - .map(ConfigFileRelayerNetworkPolicy::Stellar) - .map(Some) - .map_err(de::Error::custom) - } - } - } else { - Ok(None) // `policies` is optional - }?; - - let custom_rpc_urls = value - .get("custom_rpc_urls") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| { - // Handle both string format (legacy) and object format (new) - if let Some(url_str) = v.as_str() { - // Convert string to RpcConfig with default weight - Some(RpcConfig::new(url_str.to_string())) - } else { - // Try to parse as a RpcConfig object - serde_json::from_value::(v.clone()).ok() - } - }) - .collect() - }); - - Ok(RelayerFileConfig { - id, - name, - network, - paused, - network_type, - policies, - signer_id, - notification_id, - custom_rpc_urls, - }) - } -} - -impl RelayerFileConfig { - const MAX_ID_LENGTH: usize = 36; - - fn validate_solana_pub_keys(&self, keys: &Option>) -> Result<(), ConfigFileError> { - if let Some(keys) = keys { - let solana_pub_key_regex = - Regex::new(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$").map_err(|e| { - ConfigFileError::InternalError(format!("Regex compilation error: {}", e)) - })?; - for key in keys { - if !solana_pub_key_regex.is_match(key) { - return Err(ConfigFileError::InvalidPolicy( - "Value must contain only letters, numbers, dashes and underscores".into(), - )); - } - } - } - Ok(()) - } - - fn validate_solana_fee_margin_percentage( - &self, - fee_margin_percentage: Option, - ) -> Result<(), ConfigFileError> { - if let Some(value) = fee_margin_percentage { - if value < 0f32 { - return Err(ConfigFileError::InvalidPolicy( - "Negative values are not accepted".into(), - )); - } - } - Ok(()) - } - - fn validate_solana_swap_config( - &self, - policy: &ConfigFileRelayerSolanaPolicy, - network: &str, - ) -> Result<(), ConfigFileError> { - let swap_config = match &policy.swap_config { - Some(config) => config, - None => return Ok(()), - }; - - if let Some(fee_payment_strategy) = &policy.fee_payment_strategy { - match fee_payment_strategy { - ConfigFileRelayerSolanaFeePaymentStrategy::User => {} - ConfigFileRelayerSolanaFeePaymentStrategy::Relayer => { - return Err(ConfigFileError::InvalidPolicy( - "Swap config only supported for user fee payment strategy".into(), - )); - } - } - } - - if let Some(strategy) = &swap_config.strategy { - match strategy { - ConfigFileRelayerSolanaSwapStrategy::JupiterSwap => { - if network != "mainnet-beta" { - return Err(ConfigFileError::InvalidPolicy( - "JupiterSwap strategy is only supported on mainnet-beta".into(), - )); - } - } - ConfigFileRelayerSolanaSwapStrategy::JupiterUltra => { - if network != "mainnet-beta" { - return Err(ConfigFileError::InvalidPolicy( - "JupiterUltra strategy is only supported on mainnet-beta".into(), - )); - } - } - } - } - - if let Some(cron_schedule) = &swap_config.cron_schedule { - if cron_schedule.is_empty() { - return Err(ConfigFileError::InvalidPolicy( - "Empty cron schedule is not accepted".into(), - )); - } - } - - if let Some(schedule) = &swap_config.cron_schedule { - Schedule::from_str(schedule).map_err(|_| { - ConfigFileError::InvalidPolicy("Invalid cron schedule format".into()) - })?; - } - - if let Some(strategy) = &swap_config.jupiter_swap_options { - // strategy must be jupiter_swap - if swap_config.strategy != Some(ConfigFileRelayerSolanaSwapStrategy::JupiterSwap) { - return Err(ConfigFileError::InvalidPolicy( - "JupiterSwap options are only valid for JupiterSwap strategy".into(), - )); - } - if let Some(max_lamports) = strategy.priority_fee_max_lamports { - if max_lamports == 0 { - return Err(ConfigFileError::InvalidPolicy( - "Max lamports must be greater than 0".into(), - )); - } - } - if let Some(priority_level) = &strategy.priority_level { - if priority_level.is_empty() { - return Err(ConfigFileError::InvalidPolicy( - "Priority level cannot be empty".into(), - )); - } - let valid_levels = ["medium", "high", "veryHigh"]; - if !valid_levels.contains(&priority_level.as_str()) { - return Err(ConfigFileError::InvalidPolicy( - "Priority level must be one of: medium, high, veryHigh".into(), - )); - } - } - - if strategy.priority_level.is_some() && strategy.priority_fee_max_lamports.is_none() { - return Err(ConfigFileError::InvalidPolicy( - "Priority Fee Max lamports must be set if priority level is set".into(), - )); - } - if strategy.priority_fee_max_lamports.is_some() && strategy.priority_level.is_none() { - return Err(ConfigFileError::InvalidPolicy( - "Priority level must be set if priority fee max lamports is set".into(), - )); - } - } - - Ok(()) - } - - fn validate_policies(&self) -> Result<(), ConfigFileError> { - match self.network_type { - ConfigFileNetworkType::Solana => { - if let Some(ConfigFileRelayerNetworkPolicy::Solana(policy)) = &self.policies { - self.validate_solana_pub_keys(&policy.allowed_accounts)?; - self.validate_solana_pub_keys(&policy.disallowed_accounts)?; - let allowed_token_keys = policy.allowed_tokens.as_ref().map(|tokens| { - tokens - .iter() - .map(|token| token.mint.clone()) - .collect::>() - }); - self.validate_solana_pub_keys(&allowed_token_keys)?; - self.validate_solana_pub_keys(&policy.allowed_programs)?; - self.validate_solana_fee_margin_percentage(policy.fee_margin_percentage)?; - self.validate_solana_swap_config(policy, &self.network)?; - // check if both allowed_accounts and disallowed_accounts are present - if policy.allowed_accounts.is_some() && policy.disallowed_accounts.is_some() { - return Err(ConfigFileError::InvalidPolicy( - "allowed_accounts and disallowed_accounts cannot be both present" - .into(), - )); - } - } - } - ConfigFileNetworkType::Evm => {} - ConfigFileNetworkType::Stellar => {} - } - Ok(()) - } - - fn validate_custom_rpc_urls(&self) -> Result<(), ConfigFileError> { - if let Some(configs) = &self.custom_rpc_urls { - for config in configs { - reqwest::Url::parse(&config.url).map_err(|_| { - ConfigFileError::InvalidFormat(format!("Invalid RPC URL: {}", config.url)) - })?; - - if config.weight > 100 { - return Err(ConfigFileError::InvalidFormat( - "RPC URL weight must be in range 0-100".to_string(), - )); - } - } - } - Ok(()) - } - - // TODO add validation that multiple relayers on same network cannot use same signer - pub fn validate(&self) -> Result<(), ConfigFileError> { - if self.id.is_empty() { - return Err(ConfigFileError::MissingField("relayer id".into())); - } - let id_regex = Regex::new(r"^[a-zA-Z0-9-_]+$").map_err(|e| { - ConfigFileError::InternalError(format!("Regex compilation error: {}", e)) - })?; - if !id_regex.is_match(&self.id) { - return Err(ConfigFileError::InvalidIdFormat( - "ID must contain only letters, numbers, dashes and underscores".into(), - )); - } - - if self.id.len() > Self::MAX_ID_LENGTH { - return Err(ConfigFileError::InvalidIdLength(format!( - "ID length must not exceed {} characters", - Self::MAX_ID_LENGTH - ))); - } - if self.name.is_empty() { - return Err(ConfigFileError::MissingField("relayer name".into())); - } - if self.network.is_empty() { - return Err(ConfigFileError::MissingField("network".into())); - } - - self.validate_policies()?; - self.validate_custom_rpc_urls()?; - Ok(()) - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct RelayersFileConfig { - pub relayers: Vec, -} - -impl RelayersFileConfig { - pub fn new(relayers: Vec) -> Self { - Self { relayers } - } - - pub fn validate(&self, networks: &NetworksFileConfig) -> Result<(), ConfigFileError> { - if self.relayers.is_empty() { - return Err(ConfigFileError::MissingField("relayers".into())); - } - - let mut ids = HashSet::new(); - for relayer in &self.relayers { - if relayer.network.is_empty() { - return Err(ConfigFileError::InvalidFormat( - "relayer.network cannot be empty".into(), - )); - } - - if networks - .get_network(relayer.network_type, &relayer.network) - .is_none() - { - return Err(ConfigFileError::InvalidReference(format!( - "Relayer '{}' references non-existent network '{}' for type '{:?}'", - relayer.id, relayer.network, relayer.network_type - ))); - } - relayer.validate()?; - if !ids.insert(relayer.id.clone()) { - return Err(ConfigFileError::DuplicateId(relayer.id.clone())); - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use crate::config::{EvmNetworkConfig, NetworkConfigCommon, NetworkFileConfig}; - use crate::constants::DEFAULT_RPC_WEIGHT; - - use super::*; - use serde_json::json; - - #[test] - fn test_solana_policy_duplicate_entries() { - let config = json!({ - "id": "solana-relayer", - "name": "Solana Mainnet Relayer", - "network": "mainnet", - "network_type": "solana", - "signer_id": "solana-signer", - "paused": false, - "policies": { - "allowed_accounts": ["EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"], - "disallowed_accounts": ["EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"], - } - }); - - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - - let err = relayer.validate_policies().unwrap_err(); - - assert_eq!( - err.to_string(), - "Invalid policy: allowed_accounts and disallowed_accounts cannot be both present" - ); - } - - #[test] - fn test_solana_policy_format() { - let config = json!({ - "id": "solana-relayer", - "name": "Solana Mainnet Relayer", - "network": "mainnet", - "network_type": "solana", - "signer_id": "solana-signer", - "paused": false, - "policies": { - "min_balance": 100, - "allowed_tokens": [ {"mint": "token1"}, {"mint": "token2"}], - } - }); - - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - - let err = relayer.validate_policies().unwrap_err(); - - assert_eq!( - err.to_string(), - "Invalid policy: Value must contain only letters, numbers, dashes and underscores" - ); - } - - #[test] - fn test_valid_evm_relayer() { - let config = json!({ - "id": "test-relayer", - "name": "Test Relayer", - "network": "mainnet", - "network_type": "evm", - "signer_id": "test-signer", - "paused": false, - "policies": { - "gas_price_cap": 100, - "whitelist_receivers": ["0x1234"], - "eip1559_pricing": true - } - }); - - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - assert!(relayer.validate().is_ok()); - assert_eq!(relayer.id, "test-relayer"); - assert_eq!(relayer.network_type, ConfigFileNetworkType::Evm); - } - - #[test] - fn test_valid_solana_relayer() { - let config = json!({ - "id": "solana-relayer", - "name": "Solana Mainnet Relayer", - "network": "mainnet-beta", - "network_type": "solana", - "signer_id": "solana-signer", - "paused": false, - "policies": { - "min_balance": 100, - "disallowed_accounts": ["HCKHoE2jyk1qfAwpHQghvYH3cEfT8euCygBzF9AV6bhY"], - } - }); - - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - assert!(relayer.validate().is_ok()); - assert_eq!(relayer.id, "solana-relayer"); - assert_eq!(relayer.network_type, ConfigFileNetworkType::Solana); - } - - #[test] - fn test_valid_stellar_relayer() { - let config = json!({ - "id": "stellar-relayer", - "name": "Stellar Public Relayer", - "network": "mainnet", - "network_type": "stellar", - "signer_id": "stellar_signer", - "paused": false, - "policies": { - "max_fee": 100, - "timeout_seconds": 10, - "min_balance": 100 - } - }); - - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - assert!(relayer.validate().is_ok()); - assert_eq!(relayer.id, "stellar-relayer"); - assert_eq!(relayer.network_type, ConfigFileNetworkType::Stellar); - } - - #[test] - fn test_invalid_network_type() { - let config = json!({ - "id": "test-relayer", - "network_type": "invalid", - "signer_id": "test-signer" - }); - - let result = serde_json::from_value::(config); - assert!(result.is_err()); - } - - #[test] - #[should_panic(expected = "missing field `name`")] - fn test_missing_required_fields() { - let config = json!({ - "id": "test-relayer" - }); - - let _relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - } - - #[test] - fn test_valid_custom_rpc_urls() { - let config = json!({ - "id": "test-relayer", - "name": "Test Relayer", - "network": "mainnet", - "network_type": "evm", - "signer_id": "test-signer", - "paused": false, - "custom_rpc_urls": [ - { "url": "https://api.example.com/rpc", "weight": 2 }, - { "url": "https://rpc.example.com" } - ] - }); - - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - assert!(relayer.validate().is_ok()); - - let rpc_urls = relayer.custom_rpc_urls.unwrap(); - assert_eq!(rpc_urls.len(), 2); - assert_eq!(rpc_urls[0].url, "https://api.example.com/rpc"); - assert_eq!(rpc_urls[0].weight, 2_u8); - assert_eq!(rpc_urls[1].url, "https://rpc.example.com"); - assert_eq!(rpc_urls[1].weight, DEFAULT_RPC_WEIGHT); - assert_eq!(rpc_urls[1].get_weight(), DEFAULT_RPC_WEIGHT); - } - - #[test] - fn test_valid_custom_rpc_urls_string_format() { - let config = json!({ - "id": "test-relayer", - "name": "Test Relayer", - "network": "mainnet", - "network_type": "evm", - "signer_id": "test-signer", - "paused": false, - "custom_rpc_urls": [ - "https://api.example.com/rpc", - "https://rpc.example.com" - ] - }); - - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - assert!(relayer.validate().is_ok()); - - let rpc_urls = relayer.custom_rpc_urls.unwrap(); - assert_eq!(rpc_urls.len(), 2); - assert_eq!(rpc_urls[0].url, "https://api.example.com/rpc"); - assert_eq!(rpc_urls[0].weight, DEFAULT_RPC_WEIGHT); - assert_eq!(rpc_urls[0].get_weight(), DEFAULT_RPC_WEIGHT); - assert_eq!(rpc_urls[1].url, "https://rpc.example.com"); - assert_eq!(rpc_urls[1].weight, DEFAULT_RPC_WEIGHT); - assert_eq!(rpc_urls[1].get_weight(), DEFAULT_RPC_WEIGHT); - } - - #[test] - fn test_invalid_custom_rpc_urls() { - let config = json!({ - "id": "test-relayer", - "name": "Test Relayer", - "network": "mainnet", - "network_type": "evm", - "signer_id": "test-signer", - "paused": false, - "custom_rpc_urls": [ - { "url": "not-a-url", "weight": 1 }, - { "url": "https://api.example.com/rpc", "weight": 2 } - ] - }); - - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - let result = relayer.validate(); - assert!(result.is_err()); - if let Err(ConfigFileError::InvalidFormat(msg)) = result { - assert!(msg.contains("Invalid RPC URL")); - } else { - panic!("Expected ConfigFileError::InvalidFormat"); - } - } - - #[test] - fn test_invalid_custom_rpc_urls_weight() { - let config = json!({ - "id": "test-relayer", - "name": "Test Relayer", - "network": "mainnet", - "network_type": "evm", - "signer_id": "test-signer", - "paused": false, - "custom_rpc_urls": [ - { "url": "https://api.example.com/rpc", "weight": 200 } - ] - }); - - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - let result = relayer.validate(); - assert!(result.is_err()); - } - - #[test] - fn test_empty_custom_rpc_urls() { - let config = json!({ - "id": "test-relayer", - "name": "Test Relayer", - "network": "mainnet", - "network_type": "evm", - "signer_id": "test-signer", - "paused": false, - "custom_rpc_urls": [] - }); - - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - assert!(relayer.validate().is_ok()); - } - - #[test] - fn test_no_custom_rpc_urls() { - let config = json!({ - "id": "test-relayer", - "name": "Test Relayer", - "network": "mainnet", - "network_type": "evm", - "signer_id": "test-signer", - "paused": false - }); - - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - assert!(relayer.validate().is_ok()); - } - - /// Helper to build a minimal RelayerFileConfig JSON for Solana with given swap_config - fn make_relayer_config_with_solana_swap_config( - swap_config: serde_json::Value, - ) -> serde_json::Value { - json!({ - "id": "test-relayer", - "name": "Test Relayer", - "network": "mainnet-beta", - "network_type": "solana", - "signer_id": "test-signer", - "paused": false, - "policies": { - "fee_payment_strategy": "user", - "swap_config": swap_config - } - }) - } - - #[test] - fn invalid_jupiter_swap_options_without_strategy() { - let swap_cfg = json!({ - "cron_schedule": "0 * * * * *", - "min_balance_threshold": 1, - "jupiter_swap_options": { - "priority_level": "high", - "priority_fee_max_lamports": 1000, - "dynamic_compute_unit_limit": true - } - }); - let cfg = make_relayer_config_with_solana_swap_config(swap_cfg); - let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap(); - let err = relayer.validate().unwrap_err(); - assert_eq!( - err.to_string(), - "Invalid policy: JupiterSwap options are only valid for JupiterSwap strategy" - ); - } - - #[test] - fn invalid_priority_fee_zero() { - let swap_cfg = json!({ - "strategy": "jupiter-swap", - "cron_schedule": "0 * * * * *", - "min_balance_threshold": 1, - "jupiter_swap_options": { - "priority_level": "medium", - "priority_fee_max_lamports": 0, - "dynamic_compute_unit_limit": false - } - }); - let cfg = make_relayer_config_with_solana_swap_config(swap_cfg); - let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap(); - let err = relayer.validate().unwrap_err(); - assert_eq!( - err.to_string(), - "Invalid policy: Max lamports must be greater than 0" - ); - } - - #[test] - fn invalid_empty_priority_level() { - let swap_cfg = json!({ - "strategy": "jupiter-swap", - "cron_schedule": "0 * * * * *", - "min_balance_threshold": 1, - "jupiter_swap_options": { - "priority_level": "", - "priority_fee_max_lamports": 100, - "dynamic_compute_unit_limit": false - } - }); - let cfg = make_relayer_config_with_solana_swap_config(swap_cfg); - let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap(); - let err = relayer.validate().unwrap_err(); - assert_eq!( - err.to_string(), - "Invalid policy: Priority level cannot be empty" - ); - } - - #[test] - fn invalid_priority_level_value() { - let swap_cfg = json!({ - "strategy": "jupiter-swap", - "cron_schedule": "0 * * * * *", - "min_balance_threshold": 1, - "jupiter_swap_options": { - "priority_level": "urgent", - "priority_fee_max_lamports": 100, - "dynamic_compute_unit_limit": true - } - }); - let cfg = make_relayer_config_with_solana_swap_config(swap_cfg); - let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap(); - let err = relayer.validate().unwrap_err(); - assert_eq!( - err.to_string(), - "Invalid policy: Priority level must be one of: medium, high, veryHigh" - ); - } - - #[test] - fn valid_jupiter_swap_config() { - let swap_cfg = json!({ - "strategy": "jupiter-swap", - "cron_schedule": "0 * * * * *", - "min_balance_threshold": 10, - "jupiter_swap_options": { - "priority_level": "medium", - "priority_fee_max_lamports": 2000, - "dynamic_compute_unit_limit": true - } - }); - let cfg = make_relayer_config_with_solana_swap_config(swap_cfg); - let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap(); - assert!(relayer.validate().is_ok()); - } - - #[test] - fn valid_jupiter_ultra_config() { - let swap_cfg = json!({ - "strategy": "jupiter-ultra", - "cron_schedule": "0 * * * * *", - "min_balance_threshold": 10, - }); - let cfg = make_relayer_config_with_solana_swap_config(swap_cfg); - let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap(); - assert!(relayer.validate().is_ok()); - } - - #[test] - fn invalid_jupiter_swap_options_value_for_ultra() { - let swap_cfg = json!({ - "strategy": "jupiter-ultra", - "cron_schedule": "0 * * * * *", - "min_balance_threshold": 10, - "jupiter_swap_options": { - "priority_level": "medium", - "priority_fee_max_lamports": 2000, - "dynamic_compute_unit_limit": true - } - }); - let cfg = make_relayer_config_with_solana_swap_config(swap_cfg); - let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap(); - let err = relayer.validate().unwrap_err(); - assert_eq!( - err.to_string(), - "Invalid policy: JupiterSwap options are only valid for JupiterSwap strategy" - ); - } - - #[test] - fn invalid_swap_config_empty_cron() { - let swap_cfg = json!({ - "strategy": "jupiter-ultra", - "cron_schedule": "", - "min_balance_threshold": 10, - }); - let cfg = make_relayer_config_with_solana_swap_config(swap_cfg); - let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap(); - let err = relayer.validate().unwrap_err(); - assert_eq!( - err.to_string(), - "Invalid policy: Empty cron schedule is not accepted" - ); - } - - #[test] - fn invalid_swap_config_invalid_cron() { - let swap_cfg = json!({ - "strategy": "jupiter-ultra", - "cron_schedule": "* 1 *", - "min_balance_threshold": 10, - }); - let cfg = make_relayer_config_with_solana_swap_config(swap_cfg); - let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap(); - let err = relayer.validate().unwrap_err(); - assert_eq!( - err.to_string(), - "Invalid policy: Invalid cron schedule format" - ); - } - - #[test] - fn invalid_swap_config_invalid_network_jupiter_swap() { - let config = json!({ - "id": "test-relayer", - "name": "Test Relayer", - "network": "devnet", - "network_type": "solana", - "signer_id": "test-signer", - "paused": false, - "policies": { - "fee_payment_strategy": "user", - "swap_config": { - "strategy": "jupiter-swap", - "cron_schedule": "* 1 *", - "min_balance_threshold": 10, - } - } - }); - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - let err = relayer.validate().unwrap_err(); - assert_eq!( - err.to_string(), - "Invalid policy: JupiterSwap strategy is only supported on mainnet-beta" - ); - } - - #[test] - fn invalid_swap_config_invalid_network_jupiter_ultra() { - let config = json!({ - "id": "test-relayer", - "name": "Test Relayer", - "network": "devnet", - "network_type": "solana", - "signer_id": "test-signer", - "paused": false, - "policies": { - "fee_payment_strategy": "user", - "swap_config": { - "strategy": "jupiter-ultra", - "cron_schedule": "* 1 *", - "min_balance_threshold": 10, - } - } - }); - let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap(); - let err = relayer.validate().unwrap_err(); - assert_eq!( - err.to_string(), - "Invalid policy: JupiterUltra strategy is only supported on mainnet-beta" - ); - } - - #[test] - fn test_relayer_with_non_existent_network_fails_validation() { - let relayers = vec![RelayerFileConfig { - id: "test-relayer".to_string(), - name: "Test Relayer".to_string(), - network: "non-existent-network".to_string(), - paused: false, - network_type: ConfigFileNetworkType::Evm, - policies: None, - signer_id: "test-signer".to_string(), - notification_id: None, - custom_rpc_urls: None, - }]; - - let networks = NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig { - common: NetworkConfigCommon { - network: "existing-network".to_string(), - from: None, - rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]), - explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]), - average_blocktime_ms: Some(12000), - is_testnet: Some(true), - tags: Some(vec!["test".to_string()]), - }, - chain_id: Some(31337), - required_confirmations: Some(1), - features: None, - symbol: Some("ETH".to_string()), - })]) - .expect("Failed to create NetworksFileConfig for test"); - - let relayers_config = RelayersFileConfig::new(relayers); - let result = relayers_config.validate(&networks); - - assert!(result.is_err()); - if let Err(ConfigFileError::InvalidReference(msg)) = result { - assert!(msg.contains("non-existent network 'non-existent-network'")); - assert!(msg.contains("Relayer 'test-relayer'")); - } else { - panic!("Expected InvalidReference error, got: {:?}", result); - } - } -} diff --git a/src/constants/relayer.rs b/src/constants/relayer.rs index 5829e080f..292d4c680 100644 --- a/src/constants/relayer.rs +++ b/src/constants/relayer.rs @@ -1,8 +1,42 @@ -//! Default minimum balance constants for different blockchain networks -//! These values are used to ensure relayers maintain sufficient funds for operation. +//! Default constants for relayer configuration across different blockchain networks +//! These values are used to ensure relayers maintain sufficient funds and operate with safe defaults. + +// === Network Minimum Balance Defaults === pub const DEFAULT_EVM_MIN_BALANCE: u128 = 1; // 0.001 ETH in wei pub const DEFAULT_STELLAR_MIN_BALANCE: u64 = 1_000_000; // 1 XLM -pub const DEFAULT_SOLANA_MIN_BALANCE: u64 = 10_000_000; // 0.01 Lamport +pub const DEFAULT_SOLANA_MIN_BALANCE: u64 = 10_000_000; // 0.01 SOL in lamports + +// === EVM Policy Defaults === +/// Default gas price cap: 100 gwei in wei +pub const DEFAULT_EVM_GAS_PRICE_CAP: u128 = 100_000_000_000; +/// Default EIP-1559 pricing enabled +pub const DEFAULT_EVM_EIP1559_ENABLED: bool = true; +/// Default gas limit estimation enabled +pub const DEFAULT_EVM_GAS_LIMIT_ESTIMATION: bool = true; +/// Default private transactions disabled +pub const DEFAULT_EVM_PRIVATE_TRANSACTIONS: bool = false; + +// === Solana Policy Defaults === +/// Default fee margin percentage for Solana transactions +pub const DEFAULT_SOLANA_FEE_MARGIN_PERCENTAGE: f32 = 5.0; // 5% +/// Default maximum transaction data size for Solana +pub const DEFAULT_SOLANA_MAX_TX_DATA_SIZE: u16 = 1232; +/// Default maximum signatures for Solana transactions +pub const DEFAULT_SOLANA_MAX_SIGNATURES: u8 = 8; +/// Default maximum allowed fee for Solana transactions (0.1 SOL) +pub const DEFAULT_SOLANA_MAX_ALLOWED_FEE: u64 = 100_000_000; // lamports + +// === Stellar Policy Defaults === +/// Default maximum fee for Stellar transactions (10 stroops) +pub const DEFAULT_STELLAR_MAX_FEE: u32 = 10; +/// Default timeout for Stellar transactions (30 seconds) +pub const DEFAULT_STELLAR_TIMEOUT_SECONDS: u64 = 30; + +// === Token Swap Defaults === +/// Default slippage percentage for token swaps +pub const DEFAULT_TOKEN_SWAP_SLIPPAGE: f32 = 1.0; // 1% + +// === Legacy Constants === pub const MAX_SOLANA_TX_DATA_SIZE: u16 = 1232; pub const EVM_SMALLEST_UNIT_NAME: &str = "wei"; pub const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; diff --git a/src/domain/relayer/evm/evm_relayer.rs b/src/domain/relayer/evm/evm_relayer.rs index ef32d3141..c02cccbce 100644 --- a/src/domain/relayer/evm/evm_relayer.rs +++ b/src/domain/relayer/evm/evm_relayer.rs @@ -633,11 +633,11 @@ mod tests { signer_id: "test-signer-id".to_string(), notification_id: Some("test-notification-id".to_string()), policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy { - min_balance: 100000000000000000u128, // 0.1 ETH + min_balance: Some(100000000000000000u128), // 0.1 ETH whitelist_receivers: Some(vec!["0xRecipient".to_string()]), gas_price_cap: Some(100000000000), // 100 Gwei - eip1559_pricing: Some(false), - private_transactions: false, + eip1559_pricing: Some(true), + private_transactions: Some(false), gas_limit_estimation: Some(true), }), network_type: NetworkType::Evm, diff --git a/src/domain/relayer/evm/validations.rs b/src/domain/relayer/evm/validations.rs index 79adde863..f10b768ea 100644 --- a/src/domain/relayer/evm/validations.rs +++ b/src/domain/relayer/evm/validations.rs @@ -1,7 +1,7 @@ use thiserror::Error; use crate::{ - models::{RelayerEvmPolicy, U256}, + models::{types::U256, RelayerEvmPolicy}, services::EvmProviderTrait, }; @@ -28,12 +28,12 @@ impl EvmTransactionValidator { .await .map_err(|e| EvmTransactionValidationError::ProviderError(e.to_string()))?; - let min_balance = U256::from(policy.min_balance); + let min_balance = U256::from(policy.min_balance.unwrap_or_default()); if balance < min_balance { return Err(EvmTransactionValidationError::InsufficientBalance(format!( - "Relayer balance {balance} is less than the minimum enforced balance of {}", - policy.min_balance + "Relayer balance ({}) is below minimum required balance ({})", + balance, min_balance ))); } @@ -51,7 +51,8 @@ impl EvmTransactionValidator { .await .map_err(|e| EvmTransactionValidationError::ProviderError(e.to_string()))?; - let min_balance = U256::from(policy.min_balance); + let min_balance = U256::from(policy.min_balance.unwrap_or_default()); + let remaining_balance = balance.saturating_sub(balance_to_use); // Check if balance is insufficient to cover transaction cost @@ -64,7 +65,7 @@ impl EvmTransactionValidator { // Check if remaining balance would fall below minimum requirement if !min_balance.is_zero() && remaining_balance < min_balance { return Err(EvmTransactionValidationError::InsufficientBalance( - format!("Relayer balance {balance} is insufficient to cover {balance_to_use}, with an enforced minimum balance of {}", policy.min_balance) + format!("Relayer balance {balance} is insufficient to cover {balance_to_use}, with an enforced minimum balance of {}", policy.min_balance.unwrap_or_default()) )); } @@ -83,12 +84,12 @@ mod tests { fn create_test_policy(min_balance: u128) -> RelayerEvmPolicy { RelayerEvmPolicy { + min_balance: Some(min_balance), + gas_limit_estimation: Some(true), gas_price_cap: None, whitelist_receivers: None, eip1559_pricing: None, - private_transactions: false, - min_balance, - gas_limit_estimation: Some(true), + private_transactions: Some(false), } } diff --git a/src/domain/relayer/solana/dex/mod.rs b/src/domain/relayer/solana/dex/mod.rs index 31d22ff17..86018accd 100644 --- a/src/domain/relayer/solana/dex/mod.rs +++ b/src/domain/relayer/solana/dex/mod.rs @@ -97,7 +97,7 @@ fn resolve_strategy(relayer: &RelayerRepoModel) -> SolanaSwapStrategy { .get_solana_policy() .get_swap_config() .and_then(|cfg| cfg.strategy) - .unwrap_or(SolanaSwapStrategy::Noop) // Provide a default strategy + .unwrap_or_default() // Provide a default strategy } pub struct NoopDex; @@ -148,10 +148,10 @@ mod tests { use crate::{ models::{ - LocalSignerConfig, RelayerSolanaPolicy, RelayerSolanaSwapConfig, RpcConfig, - SignerConfig, SignerRepoModel, + LocalSignerConfig, RelayerSolanaPolicy, RelayerSolanaSwapPolicy, SignerConfig, + SignerRepoModel, }, - services::SolanaSignerFactory, + services::{MockSolanaProviderTrait, SolanaSignerFactory}, }; use super::*; @@ -169,7 +169,7 @@ mod tests { fn test_create_network_dex_jupiter_swap_explicit() { let mut relayer = RelayerRepoModel::default(); let policy = crate::models::RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - swap_config: Some(RelayerSolanaSwapConfig { + swap_config: Some(RelayerSolanaSwapPolicy { strategy: Some(SolanaSwapStrategy::JupiterSwap), cron_schedule: None, min_balance_threshold: None, @@ -180,16 +180,8 @@ mod tests { relayer.policies = policy; - let provider = Arc::new( - SolanaProvider::new( - vec![RpcConfig { - url: "https://api.mainnet-beta.solana.com".to_string(), - weight: 100, - }], - 10, - ) - .unwrap(), - ); + let provider = Arc::new(MockSolanaProviderTrait::new()); + let signer_service = Arc::new( SolanaSignerFactory::create_solana_signer(&create_test_signer_model()).unwrap(), ); @@ -209,7 +201,7 @@ mod tests { fn test_create_network_dex_jupiter_ultra_explicit() { let mut relayer = RelayerRepoModel::default(); let policy = crate::models::RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - swap_config: Some(RelayerSolanaSwapConfig { + swap_config: Some(RelayerSolanaSwapPolicy { strategy: Some(SolanaSwapStrategy::JupiterUltra), cron_schedule: None, min_balance_threshold: None, @@ -220,16 +212,8 @@ mod tests { relayer.policies = policy; - let provider = Arc::new( - SolanaProvider::new( - vec![RpcConfig { - url: "https://api.mainnet-beta.solana.com".to_string(), - weight: 100, - }], - 10, - ) - .unwrap(), - ); + let provider = Arc::new(MockSolanaProviderTrait::new()); + let signer_service = Arc::new( SolanaSignerFactory::create_solana_signer(&create_test_signer_model()).unwrap(), ); @@ -249,7 +233,7 @@ mod tests { fn test_create_network_dex_default_when_no_strategy() { let mut relayer = RelayerRepoModel::default(); let policy = crate::models::RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - swap_config: Some(RelayerSolanaSwapConfig { + swap_config: Some(RelayerSolanaSwapPolicy { strategy: None, cron_schedule: None, min_balance_threshold: None, @@ -260,16 +244,8 @@ mod tests { relayer.policies = policy; - let provider = Arc::new( - SolanaProvider::new( - vec![RpcConfig { - url: "https://api.mainnet-beta.solana.com".to_string(), - weight: 100, - }], - 10, - ) - .unwrap(), - ); + let provider = Arc::new(MockSolanaProviderTrait::new()); + let signer_service = Arc::new( SolanaSignerFactory::create_solana_signer(&create_test_signer_model()).unwrap(), ); @@ -295,16 +271,8 @@ mod tests { relayer.policies = policy; - let provider = Arc::new( - SolanaProvider::new( - vec![RpcConfig { - url: "https://api.mainnet-beta.solana.com".to_string(), - weight: 100, - }], - 10, - ) - .unwrap(), - ); + let provider = Arc::new(MockSolanaProviderTrait::new()); + let signer_service = Arc::new( SolanaSignerFactory::create_solana_signer(&create_test_signer_model()).unwrap(), ); diff --git a/src/domain/relayer/solana/rpc/methods/fee_estimate.rs b/src/domain/relayer/solana/rpc/methods/fee_estimate.rs index e9ed153f9..05e5831f0 100644 --- a/src/domain/relayer/solana/rpc/methods/fee_estimate.rs +++ b/src/domain/relayer/solana/rpc/methods/fee_estimate.rs @@ -118,7 +118,8 @@ where fee_token: &str, ) -> Result<(Transaction, FeeQuote), SolanaRpcError> { let policies = self.relayer.policies.get_solana_policy(); - let user_pays_fee = policies.fee_payment_strategy == SolanaFeePaymentStrategy::User; + let user_pays_fee = + policies.fee_payment_strategy.unwrap_or_default() == SolanaFeePaymentStrategy::User; // Get latest blockhash let recent_blockhash = self @@ -213,8 +214,8 @@ mod tests { setup_test_context, setup_test_context_single_tx_user_fee_strategy, SolanaRpcMethods, }, models::{ - RelayerNetworkPolicy, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, - SolanaAllowedTokensSwapConfig, + AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, RelayerNetworkPolicy, + RelayerSolanaPolicy, SolanaAllowedTokensPolicy, }, services::{ MockSolanaProviderTrait, QuoteResponse, RoutePlan, SolanaProviderError, SwapInfo, @@ -233,7 +234,7 @@ mod tests { // Set up policy with allowed token relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: "USDC".to_string(), symbol: Some("USDC".to_string()), @@ -490,7 +491,7 @@ mod tests { setup_test_context(); relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), // USDT mint symbol: Some("USDT".to_string()), @@ -582,7 +583,7 @@ mod tests { // Set up policy with UNI token (decimals = 8) relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: "8qJSyQprMC57TWKaYEmetUR3UUiTP2M3hXW6D2evU9Tt".to_string(), // UNI mint symbol: Some("UNI".to_string()), @@ -673,7 +674,7 @@ mod tests { // Set up policy with WSOL token relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: WRAPPED_SOL_MINT.to_string(), symbol: Some("SOL".to_string()), diff --git a/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs b/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs index bc14b1a97..60eddcf3f 100644 --- a/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs +++ b/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs @@ -70,8 +70,9 @@ mod tests { use crate::{ domain::{setup_test_context, SolanaRpcMethodsImpl}, models::{ + AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, GetSupportedTokensRequestParams, RelayerNetworkPolicy, RelayerSolanaPolicy, - SolanaAllowedTokensPolicy, SolanaAllowedTokensSwapConfig, + SolanaAllowedTokensPolicy, }, }; diff --git a/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs b/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs index c87d6bdad..5b984e95d 100644 --- a/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs @@ -125,7 +125,8 @@ where fee_token: &str, ) -> Result<(Transaction, (Hash, u64), u64, FeeQuote), SolanaRpcError> { let policies = self.relayer.policies.get_solana_policy(); - let user_pays_fee = policies.fee_payment_strategy == SolanaFeePaymentStrategy::User; + let user_pays_fee = + policies.fee_payment_strategy.unwrap_or_default() == SolanaFeePaymentStrategy::User; let result = if user_pays_fee { // First create draft transaction with minimal fee to get structure right @@ -233,8 +234,8 @@ mod tests { use crate::{ constants::WRAPPED_SOL_MINT, models::{ - RelayerNetworkPolicy, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, - SolanaAllowedTokensSwapConfig, + AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, RelayerNetworkPolicy, + RelayerSolanaPolicy, SolanaAllowedTokensPolicy, }, services::{QuoteResponse, RoutePlan, SwapInfo}, }; @@ -252,7 +253,7 @@ mod tests { // Setup policy with WSOL relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: WRAPPED_SOL_MINT.to_string(), symbol: Some("SOL".to_string()), @@ -518,8 +519,8 @@ mod tests { // Set high minimum balance requirement relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, - min_balance: 100_000_000, // 0.1 SOL minimum balance + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), + min_balance: Some(100_000_000), // 0.1 SOL minimum balance allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: WRAPPED_SOL_MINT.to_string(), symbol: Some("SOL".to_string()), @@ -581,8 +582,8 @@ mod tests { setup_test_context(); relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, - min_balance: 100_000_000, // 0.1 SOL minimum balance + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), + min_balance: Some(100_000_000), // 0.1 SOL minimum balance allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: WRAPPED_SOL_MINT.to_string(), symbol: Some("SOL".to_string()), @@ -668,8 +669,8 @@ mod tests { relayer.address = relayer_keypair.pubkey().to_string(); relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, - min_balance: 100_000_000, // 0.1 SOL minimum balance + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), + min_balance: Some(100_000_000), // 0.1 SOL minimum balance allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: WRAPPED_SOL_MINT.to_string(), symbol: Some("SOL".to_string()), @@ -762,7 +763,7 @@ mod tests { // Configure policy with allowed tokens relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: "AllowedToken111111111111111111111111111111".to_string(), symbol: Some("ALLOWED".to_string()), diff --git a/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs b/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs index 023f652a4..3ffd2beee 100644 --- a/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs @@ -60,7 +60,8 @@ where SolanaRpcError::Estimation(e.to_string()) })?; - let user_pays_fee = policy.fee_payment_strategy == SolanaFeePaymentStrategy::User; + let user_pays_fee = + policy.fee_payment_strategy.unwrap_or_default() == SolanaFeePaymentStrategy::User; if user_pays_fee { self.confirm_user_fee_payment(&transaction_request, total_fee) @@ -715,7 +716,7 @@ mod tests { Err(SolanaRpcError::InsufficientFunds(err)) => { let error_string = err.to_string(); assert!( - error_string.contains("Insufficient funds:"), + error_string.contains("Insufficient balance:"), "Unexpected error message: {}", err ); diff --git a/src/domain/relayer/solana/rpc/methods/sign_transaction.rs b/src/domain/relayer/solana/rpc/methods/sign_transaction.rs index 28c30e9f1..cfc921d73 100644 --- a/src/domain/relayer/solana/rpc/methods/sign_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/sign_transaction.rs @@ -58,7 +58,8 @@ where SolanaRpcError::Estimation(e.to_string()) })?; - let user_pays_fee = policy.fee_payment_strategy == SolanaFeePaymentStrategy::User; + let user_pays_fee = + policy.fee_payment_strategy.unwrap_or_default() == SolanaFeePaymentStrategy::User; if user_pays_fee { self.confirm_user_fee_payment(&transaction_request, total_fee) @@ -444,7 +445,7 @@ mod tests { Err(SolanaRpcError::InsufficientFunds(err)) => { let error_string = err.to_string(); assert!( - error_string.contains("Insufficient funds:"), + error_string.contains("Insufficient balance:"), "Unexpected error message: {}", err ); @@ -598,7 +599,7 @@ mod tests { Err(SolanaRpcError::InsufficientFunds(err)) => { let error_string = err.to_string(); assert!( - error_string.contains("Insufficient funds:"), + error_string.contains("Insufficient balance:"), "Unexpected error message: {}", err ); @@ -613,7 +614,7 @@ mod tests { setup_test_context(); relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), ..Default::default() }); @@ -644,7 +645,7 @@ mod tests { setup_test_context(); // Update policy with low max signatures relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), max_signatures: Some(0), ..Default::default() }); @@ -760,7 +761,7 @@ mod tests { // Update policy with small max data size relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - max_tx_data_size: 10, + max_tx_data_size: Some(10), ..Default::default() }); @@ -865,7 +866,7 @@ mod tests { // Update policy with disallowed accounts relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { disallowed_accounts: Some(vec![Pubkey::new_unique().to_string()]), - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), ..Default::default() }); @@ -928,7 +929,7 @@ mod tests { // Set max allowed transfer amount in policy relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { max_allowed_fee_lamports: Some(500), - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), ..Default::default() }); diff --git a/src/domain/relayer/solana/rpc/methods/test_setup.rs b/src/domain/relayer/solana/rpc/methods/test_setup.rs index e5b809c07..2813689f6 100644 --- a/src/domain/relayer/solana/rpc/methods/test_setup.rs +++ b/src/domain/relayer/solana/rpc/methods/test_setup.rs @@ -10,9 +10,9 @@ use std::str::FromStr; use crate::{ jobs::MockJobProducerTrait, models::{ - EncodedSerializedTransaction, NetworkType, RelayerNetworkPolicy, RelayerRepoModel, - RelayerSolanaPolicy, SolanaAllowedTokensPolicy, SolanaAllowedTokensSwapConfig, - SolanaFeePaymentStrategy, + AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, EncodedSerializedTransaction, + NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerSolanaPolicy, + SolanaAllowedTokensPolicy, SolanaFeePaymentStrategy, }, services::{MockJupiterServiceTrait, MockSolanaProviderTrait, MockSolanaSignTrait}, }; @@ -43,16 +43,16 @@ pub fn setup_test_context() -> ( paused: false, network_type: NetworkType::Solana, policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, - fee_margin_percentage: Some(0.5), - allowed_accounts: None, - allowed_tokens: None, - min_balance: 10000, allowed_programs: None, max_signatures: Some(10), + max_tx_data_size: Some(1000), + min_balance: Some(10000), + allowed_tokens: None, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), + fee_margin_percentage: Some(0.5), + allowed_accounts: None, disallowed_accounts: None, max_allowed_fee_lamports: None, - max_tx_data_size: 1000, swap_config: None, }), signer_id: "test".to_string(), @@ -142,16 +142,16 @@ pub fn setup_test_context_relayer_fee_strategy() -> RelayerFeeStrategyTestContex paused: false, network_type: NetworkType::Solana, policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), fee_margin_percentage: Some(0.5), allowed_accounts: None, allowed_tokens: None, - min_balance: 10000, + min_balance: Some(10000), allowed_programs: None, max_signatures: Some(10), disallowed_accounts: None, max_allowed_fee_lamports: None, - max_tx_data_size: 1000, + max_tx_data_size: Some(1000), swap_config: None, }), signer_id: "test".to_string(), @@ -263,7 +263,7 @@ pub fn setup_test_context_user_fee_strategy() -> UserFeeStrategyTestContext { paused: false, network_type: NetworkType::Solana, policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::User, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), fee_margin_percentage: Some(0.5), allowed_accounts: None, allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { @@ -276,12 +276,12 @@ pub fn setup_test_context_user_fee_strategy() -> UserFeeStrategyTestContext { ..Default::default() }), }]), - min_balance: 10000, + min_balance: Some(10000), allowed_programs: None, max_signatures: Some(10), disallowed_accounts: None, max_allowed_fee_lamports: None, - max_tx_data_size: 1000, + max_tx_data_size: Some(1000), swap_config: None, }), signer_id: "test".to_string(), @@ -382,7 +382,7 @@ pub fn setup_test_context_single_tx_user_fee_strategy() -> UserFeeStrategySingle paused: false, network_type: NetworkType::Solana, policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::User, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), fee_margin_percentage: Some(0.5), allowed_accounts: None, allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { @@ -395,12 +395,12 @@ pub fn setup_test_context_single_tx_user_fee_strategy() -> UserFeeStrategySingle ..Default::default() }), }]), - min_balance: 10000, + min_balance: Some(10000), allowed_programs: None, max_signatures: Some(10), disallowed_accounts: None, max_allowed_fee_lamports: None, - max_tx_data_size: 1000, + max_tx_data_size: Some(1000), swap_config: None, }), signer_id: "test".to_string(), diff --git a/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs b/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs index ff860ffbe..51b0886c5 100644 --- a/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs @@ -148,7 +148,8 @@ where amount: u64, ) -> Result<(Transaction, (Hash, u64), u64, FeeQuote), SolanaRpcError> { let policies = self.relayer.policies.get_solana_policy(); - let user_pays_fee = policies.fee_payment_strategy == SolanaFeePaymentStrategy::User; + let user_pays_fee = + policies.fee_payment_strategy.unwrap_or_default() == SolanaFeePaymentStrategy::User; let token_transfer_instruction = self .handle_token_transfer(source, destination, token_mint, amount) .await?; @@ -240,8 +241,8 @@ mod tests { use crate::{ constants::WRAPPED_SOL_MINT, models::{ - NetworkType, RelayerNetworkPolicy, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, - SolanaAllowedTokensSwapConfig, + AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, NetworkType, + RelayerNetworkPolicy, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, }, services::{QuoteResponse, RoutePlan, SwapInfo}, }; @@ -375,7 +376,7 @@ mod tests { Account::pack(token_account, &mut account_data).unwrap(); relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::User, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: test_token.to_string(), symbol: Some("SOL".to_string()), @@ -600,7 +601,7 @@ mod tests { spl_token::state::Account::pack(source_token_account, &mut source_account_data).unwrap(); ctx.relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: ctx.token.to_string(), symbol: Some("USDC".to_string()), @@ -753,9 +754,10 @@ mod tests { paused: false, network_type: NetworkType::Solana, policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::Relayer, - fee_margin_percentage: Some(0.5), - allowed_accounts: None, + allowed_programs: None, + max_signatures: Some(10), + max_tx_data_size: Some(1000), + min_balance: Some(10000), allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { mint: test_token.to_string(), symbol: Some("USDC".to_string()), @@ -766,12 +768,11 @@ mod tests { ..Default::default() }), }]), - min_balance: 10000, - allowed_programs: None, - max_signatures: Some(10), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), + fee_margin_percentage: Some(0.5), + allowed_accounts: None, disallowed_accounts: None, max_allowed_fee_lamports: None, - max_tx_data_size: 1000, swap_config: None, }), signer_id: "test".to_string(), diff --git a/src/domain/relayer/solana/rpc/methods/utils.rs b/src/domain/relayer/solana/rpc/methods/utils.rs index 886e6fb4e..5c11b41a4 100644 --- a/src/domain/relayer/solana/rpc/methods/utils.rs +++ b/src/domain/relayer/solana/rpc/methods/utils.rs @@ -887,8 +887,8 @@ mod tests { use crate::{ constants::WRAPPED_SOL_MINT, models::{ - RelayerNetworkPolicy, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, - SolanaAllowedTokensSwapConfig, + AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, RelayerNetworkPolicy, + RelayerSolanaPolicy, SolanaAllowedTokensPolicy, }, services::{QuoteResponse, RoutePlan, SwapInfo}, }; diff --git a/src/domain/relayer/solana/rpc/methods/validations.rs b/src/domain/relayer/solana/rpc/methods/validations.rs index 21ffdf2ba..021586e58 100644 --- a/src/domain/relayer/solana/rpc/methods/validations.rs +++ b/src/domain/relayer/solana/rpc/methods/validations.rs @@ -44,6 +44,8 @@ pub enum SolanaTransactionValidationError { FeePayer(String), #[error("Insufficient funds: {0}")] InsufficientFunds(String), + #[error("Insufficient balance: {0}")] + InsufficientBalance(String), } #[allow(dead_code)] @@ -245,7 +247,7 @@ impl SolanaTransactionValidator { tx: &Transaction, config: &RelayerSolanaPolicy, ) -> Result<(), SolanaTransactionValidationError> { - let max_size: usize = config.max_tx_data_size.into(); + let max_size: usize = config.max_tx_data_size.unwrap_or_default().into(); let tx_bytes = bincode::serialize(tx) .map_err(|e| SolanaTransactionValidationError::DeserializeError(e.to_string()))?; @@ -327,16 +329,14 @@ impl SolanaTransactionValidator { .map_err(|e| SolanaTransactionValidationError::ValidationError(e.to_string()))?; // Ensure minimum balance policy is maintained - let min_balance = policy.min_balance; + let min_balance = policy.min_balance.unwrap_or_default(); let required_balance = fee + min_balance; if balance < required_balance { - return Err(SolanaTransactionValidationError::InsufficientFunds( - format!( - "Relayer balance {} is insufficient to cover fee {} plus minimum balance {}", - balance, fee, min_balance - ), - )); + return Err(SolanaTransactionValidationError::InsufficientBalance(format!( + "Insufficient relayer balance. Required: {}, Available: {}, Fee: {}, Min balance: {}", + required_balance, balance, fee, min_balance + ))); } Ok(()) @@ -557,7 +557,10 @@ impl SolanaTransactionValidator { #[cfg(test)] mod tests { use crate::{ - models::{SolanaAllowedTokensPolicy, SolanaAllowedTokensSwapConfig}, + models::{ + AllowedToken as SolanaAllowedTokensPolicy, + AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, + }, services::{MockSolanaProviderTrait, SolanaProviderError}, }; @@ -1008,7 +1011,7 @@ mod tests { let tx = create_test_transaction(&payer.pubkey()); let policy = RelayerSolanaPolicy { - max_tx_data_size: 1500, + max_tx_data_size: Some(1500), ..Default::default() }; @@ -1022,7 +1025,7 @@ mod tests { let tx = create_test_transaction(&payer.pubkey()); let policy = RelayerSolanaPolicy { - max_tx_data_size: 10, + max_tx_data_size: Some(10), ..Default::default() }; @@ -1052,7 +1055,7 @@ mod tests { let tx = Transaction::new_unsigned(message); let policy = RelayerSolanaPolicy { - max_tx_data_size: 500, + max_tx_data_size: Some(500), ..Default::default() }; @@ -1074,7 +1077,7 @@ mod tests { let tx = Transaction::new_unsigned(message); let policy = RelayerSolanaPolicy { - max_tx_data_size: 1500, + max_tx_data_size: Some(1500), ..Default::default() }; diff --git a/src/domain/relayer/solana/solana_relayer.rs b/src/domain/relayer/solana/solana_relayer.rs index 48f9a4d72..696d89e70 100644 --- a/src/domain/relayer/solana/solana_relayer.rs +++ b/src/domain/relayer/solana/solana_relayer.rs @@ -163,13 +163,13 @@ where .get_token_metadata_from_pubkey(&token.mint) .await .map_err(|e| RelayerError::ProviderError(e.to_string()))?; - Ok::(SolanaAllowedTokensPolicy::new( - token_metadata.mint, - Some(token_metadata.decimals), - Some(token_metadata.symbol.to_string()), - token.max_allowed_fee, - token.swap_config.clone(), - )) + Ok::(SolanaAllowedTokensPolicy { + mint: token_metadata.mint, + decimals: Some(token_metadata.decimals as u8), + symbol: Some(token_metadata.symbol.to_string()), + max_allowed_fee: token.max_allowed_fee, + swap_config: token.swap_config.clone(), + }) }); let updated_allowed_tokens = try_join_all(token_metadata_futures).await?; @@ -661,7 +661,7 @@ where let policy = self.relayer.policies.get_solana_policy(); - if balance < policy.min_balance { + if balance < policy.min_balance.unwrap_or_default() { return Err(RelayerError::InsufficientBalanceError( "Insufficient balance".to_string(), )); @@ -742,9 +742,10 @@ mod tests { domain::create_network_dex_generic, jobs::MockJobProducerTrait, models::{ - EncodedSerializedTransaction, FeeEstimateRequestParams, - GetFeaturesEnabledRequestParams, JsonRpcId, NetworkConfigData, NetworkRepoModel, - RelayerSolanaSwapConfig, SolanaAllowedTokensSwapConfig, SolanaRpcResult, + AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, EncodedSerializedTransaction, + FeeEstimateRequestParams, GetFeaturesEnabledRequestParams, JsonRpcId, + NetworkConfigData, NetworkRepoModel, + RelayerSolanaSwapPolicy as RelayerSolanaSwapConfig, SolanaRpcResult, SolanaSwapStrategy, }, repositories::{MockNetworkRepository, MockRelayerRepository, MockTransactionRepository}, @@ -1675,7 +1676,7 @@ mod tests { let mut model = create_test_relayer(); model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - min_balance: 50, + min_balance: Some(50), ..Default::default() }); @@ -1699,7 +1700,7 @@ mod tests { let mut model = create_test_relayer(); model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - min_balance: 50, + min_balance: Some(50), ..Default::default() }); diff --git a/src/domain/transaction/evm/evm_transaction.rs b/src/domain/transaction/evm/evm_transaction.rs index ef34ebcfb..32c8c1046 100644 --- a/src/domain/transaction/evm/evm_transaction.rs +++ b/src/domain/transaction/evm/evm_transaction.rs @@ -11,7 +11,7 @@ use log::{debug, error, info, warn}; use std::sync::Arc; use crate::{ - constants::GAS_LIMIT_BUFFER_MULTIPLIER, + constants::{DEFAULT_EVM_GAS_LIMIT_ESTIMATION, GAS_LIMIT_BUFFER_MULTIPLIER}, domain::{ transaction::{ evm::{is_pending_transaction, PriceCalculator, PriceCalculatorTrait}, @@ -268,7 +268,14 @@ where evm_data: &EvmTransactionData, relayer_policy: &RelayerEvmPolicy, ) -> Result { - if !relayer_policy.gas_limit_estimation.unwrap_or(true) { + println!( + "relayer_policy: {:?}", + relayer_policy.gas_limit_estimation.unwrap_or_default() + ); + if !relayer_policy + .gas_limit_estimation + .unwrap_or(DEFAULT_EVM_GAS_LIMIT_ESTIMATION) + { warn!( "Gas limit estimation is disabled for relayer: {:?}", self.relayer().id @@ -825,12 +832,12 @@ mod tests { // Helper to create a relayer model with specific configuration for these tests fn create_test_relayer() -> RelayerRepoModel { create_test_relayer_with_policy(crate::models::RelayerEvmPolicy { - min_balance: 100000000000000000u128, // 0.1 ETH - whitelist_receivers: Some(vec!["0xRecipient".to_string()]), + min_balance: Some(100000000000000000u128), // 0.1 ETH + gas_limit_estimation: Some(true), gas_price_cap: Some(100000000000), // 100 Gwei + whitelist_receivers: Some(vec!["0xRecipient".to_string()]), eip1559_pricing: Some(false), - private_transactions: false, - gas_limit_estimation: Some(true), + private_transactions: Some(false), }) } @@ -993,7 +1000,7 @@ mod tests { let relayer = create_test_relayer_with_policy(RelayerEvmPolicy { gas_limit_estimation: Some(false), - min_balance: 100000000000000000u128, + min_balance: Some(100000000000000000u128), ..Default::default() }); let test_tx = create_test_transaction(); @@ -1765,7 +1772,7 @@ mod tests { // Create test relayer with gas limit estimation enabled let relayer = create_test_relayer_with_policy(RelayerEvmPolicy { gas_limit_estimation: Some(true), - min_balance: 100000000000000000u128, + min_balance: Some(100000000000000000u128), ..Default::default() }); diff --git a/src/domain/transaction/evm/replacement.rs b/src/domain/transaction/evm/replacement.rs index a9f65cf47..28b695fcf 100644 --- a/src/domain/transaction/evm/replacement.rs +++ b/src/domain/transaction/evm/replacement.rs @@ -138,7 +138,7 @@ pub fn validate_explicit_price_bump( .policies .get_evm_policy() .gas_price_cap - .unwrap_or(u128::MAX); + .unwrap_or_default(); // Check if gas prices exceed gas price cap if let Some(gas_price) = new_evm_data.gas_price { diff --git a/src/jobs/handlers/notification_handler.rs b/src/jobs/handlers/notification_handler.rs index 1df766738..33997cb0c 100644 --- a/src/jobs/handlers/notification_handler.rs +++ b/src/jobs/handlers/notification_handler.rs @@ -65,8 +65,8 @@ async fn handle_request( mod tests { use super::*; use crate::models::{ - EvmPolicyResponse, EvmTransactionResponse, NetworkPolicyResponse, NetworkType, - RelayerDisabledPayload, RelayerResponse, TransactionResponse, TransactionStatus, + EvmTransactionResponse, NetworkType, RelayerDisabledPayload, RelayerEvmPolicy, + RelayerNetworkPolicy, RelayerResponse, TransactionResponse, TransactionStatus, WebhookNotification, WebhookPayload, U256, }; @@ -150,15 +150,19 @@ mod tests { network: "ethereum".to_string(), network_type: NetworkType::Evm, paused: false, - policies: NetworkPolicyResponse::Evm(EvmPolicyResponse { + policies: Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy { gas_price_cap: None, whitelist_receivers: None, eip1559_pricing: None, - private_transactions: false, - min_balance: 0, - }), - address: "0xabc".to_string(), - system_disabled: false, + private_transactions: Some(false), + min_balance: Some(0), + gas_limit_estimation: None, + })), + signer_id: "signer-1".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: Some("0xabc".to_string()), + system_disabled: Some(false), }, disable_reason: "test".to_string(), })); diff --git a/src/models/error/relayer.rs b/src/models/error/relayer.rs index 4f66b4adf..ec894c125 100644 --- a/src/models/error/relayer.rs +++ b/src/models/error/relayer.rs @@ -35,6 +35,8 @@ pub enum RelayerError { TransactionSequenceError(#[from] TransactionCounterError), #[error("Insufficient balance error: {0}")] InsufficientBalanceError(String), + #[error("Insufficient relayer balance: {0}")] + InsufficientRelayerBalance(String), #[error("Relayer Policy configuration error: {0}")] PolicyConfigurationError(String), #[error("Invalid Dex name : {0}")] @@ -60,6 +62,7 @@ impl From for ApiError { RelayerError::RelayerPaused => ApiError::ForbiddenError("Relayer paused".to_string()), RelayerError::TransactionSequenceError(err) => ApiError::InternalError(err.to_string()), RelayerError::InsufficientBalanceError(msg) => ApiError::BadRequest(msg), + RelayerError::InsufficientRelayerBalance(msg) => ApiError::BadRequest(msg), RelayerError::UnderlyingProvider(err) => ApiError::InternalError(err.to_string()), RelayerError::UnderlyingSolanaProvider(err) => ApiError::InternalError(err.to_string()), RelayerError::PolicyConfigurationError(msg) => ApiError::InternalError(msg), diff --git a/src/models/mod.rs b/src/models/mod.rs index d06a0ac50..5a4e6c540 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -14,9 +14,16 @@ pub use api_response::*; mod transaction; pub use transaction::*; -mod relayer; +pub mod relayer; pub use relayer::*; +// Type aliases for backward compatibility with domain logic +pub use relayer::{ + AllowedToken as SolanaAllowedTokensPolicy, + RelayerSolanaFeePaymentStrategy as SolanaFeePaymentStrategy, + RelayerSolanaSwapStrategy as SolanaSwapStrategy, +}; + mod error; pub use error::*; @@ -35,7 +42,7 @@ pub use notification::*; mod rpc; pub use rpc::*; -mod types; +pub mod types; pub use types::*; mod secret_string; diff --git a/src/models/relayer/config.rs b/src/models/relayer/config.rs new file mode 100644 index 000000000..e59162b33 --- /dev/null +++ b/src/models/relayer/config.rs @@ -0,0 +1,483 @@ +//! Configuration file representation and parsing for relayers. +//! +//! This module handles the configuration file format for relayers, providing: +//! +//! - **Config Models**: Structures that match the configuration file schema +//! - **Validation**: Config-specific validation rules and constraints +//! - **Conversions**: Bidirectional mapping between config and domain models +//! - **Collections**: Container types for managing multiple relayer configurations +//! +//! Used primarily during application startup to parse relayer settings from config files. +//! Validation is handled by the domain model in mod.rs to ensure reusability. + +use super::{Relayer, RelayerNetworkPolicy, RelayerValidationError, RpcConfig}; +use crate::config::{ConfigFileError, ConfigFileNetworkType, NetworksFileConfig}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum ConfigFileRelayerNetworkPolicy { + Evm(ConfigFileRelayerEvmPolicy), + Solana(ConfigFileRelayerSolanaPolicy), + Stellar(ConfigFileRelayerStellarPolicy), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct ConfigFileRelayerEvmPolicy { + pub gas_price_cap: Option, + pub whitelist_receivers: Option>, + pub eip1559_pricing: Option, + pub private_transactions: Option, + pub min_balance: Option, + pub gas_limit_estimation: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct AllowedTokenSwapConfig { + /// Conversion slippage percentage for token. Optional. + pub slippage_percentage: Option, + /// Minimum amount of tokens to swap. Optional. + pub min_amount: Option, + /// Maximum amount of tokens to swap. Optional. + pub max_amount: Option, + /// Minimum amount of tokens to retain after swap. Optional. + pub retain_min_amount: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct AllowedToken { + pub mint: String, + /// Decimals for the token. Optional. + pub decimals: Option, + /// Symbol for the token. Optional. + pub symbol: Option, + /// Maximum supported token fee (in lamports) for a transaction. Optional. + pub max_allowed_fee: Option, + /// Swap configuration for the token. Optional. + pub swap_config: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ConfigFileRelayerSolanaFeePaymentStrategy { + User, + Relayer, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum ConfigFileRelayerSolanaSwapStrategy { + JupiterSwap, + JupiterUltra, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct JupiterSwapOptions { + /// Maximum priority fee (in lamports) for a transaction. Optional. + pub priority_fee_max_lamports: Option, + /// Priority. Optional. + pub priority_level: Option, + + pub dynamic_compute_unit_limit: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct ConfigFileRelayerSolanaSwapPolicy { + /// DEX strategy to use for token swaps. + pub strategy: Option, + + /// Cron schedule for executing token swap logic to keep relayer funded. Optional. + pub cron_schedule: Option, + + /// Min sol balance to execute token swap logic to keep relayer funded. Optional. + pub min_balance_threshold: Option, + + /// Swap options for JupiterSwap strategy. Optional. + pub jupiter_swap_options: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct ConfigFileRelayerSolanaPolicy { + /// Determines if the relayer pays the transaction fee or the user. Optional. + pub fee_payment_strategy: Option, + + /// Fee margin percentage for the relayer. Optional. + pub fee_margin_percentage: Option, + + /// Minimum balance required for the relayer (in lamports). Optional. + pub min_balance: Option, + + /// List of allowed tokens by their identifiers. Only these tokens are supported if provided. + pub allowed_tokens: Option>, + + /// List of allowed programs by their identifiers. Only these programs are supported if + /// provided. + pub allowed_programs: Option>, + + /// List of allowed accounts by their public keys. The relayer will only operate with these + /// accounts if provided. + pub allowed_accounts: Option>, + + /// List of disallowed accounts by their public keys. These accounts will be explicitly + /// blocked. + pub disallowed_accounts: Option>, + + /// Maximum transaction size. Optional. + pub max_tx_data_size: Option, + + /// Maximum supported signatures. Optional. + pub max_signatures: Option, + + /// Maximum allowed fee (in lamports) for a transaction. Optional. + pub max_allowed_fee_lamports: Option, + + /// Swap dex config to use for token swaps. Optional. + pub swap_config: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(deny_unknown_fields)] +pub struct ConfigFileRelayerStellarPolicy { + pub max_fee: Option, + pub timeout_seconds: Option, + pub min_balance: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct RelayerFileConfig { + pub id: String, + pub name: String, + pub network: String, + pub paused: bool, + #[serde(flatten)] + pub network_type: ConfigFileNetworkType, + #[serde(default)] + pub policies: Option, + pub signer_id: String, + #[serde(default)] + pub notification_id: Option, + #[serde(default)] + pub custom_rpc_urls: Option>, +} + +use serde::{de, Deserializer}; +use serde_json::Value; + +impl<'de> Deserialize<'de> for RelayerFileConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Deserialize as a generic JSON object + let mut value: Value = Value::deserialize(deserializer)?; + + // Extract and validate required fields + let id = value + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| de::Error::missing_field("id"))? + .to_string(); + + let name = value + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| de::Error::missing_field("name"))? + .to_string(); + + let network = value + .get("network") + .and_then(Value::as_str) + .ok_or_else(|| de::Error::missing_field("network"))? + .to_string(); + + let paused = value + .get("paused") + .and_then(Value::as_bool) + .ok_or_else(|| de::Error::missing_field("paused"))?; + + // Deserialize `network_type` using `ConfigFileNetworkType` + let network_type: ConfigFileNetworkType = serde_json::from_value( + value + .get("network_type") + .cloned() + .ok_or_else(|| de::Error::missing_field("network_type"))?, + ) + .map_err(de::Error::custom)?; + + let signer_id = value + .get("signer_id") + .and_then(Value::as_str) + .ok_or_else(|| de::Error::missing_field("signer_id"))? + .to_string(); + + let notification_id = value + .get("notification_id") + .and_then(Value::as_str) + .map(|s| s.to_string()); + + // Handle `policies`, using `network_type` to determine how to deserialize + let policies = if let Some(policy_value) = value.get_mut("policies") { + match network_type { + ConfigFileNetworkType::Evm => { + serde_json::from_value::(policy_value.clone()) + .map(ConfigFileRelayerNetworkPolicy::Evm) + .map(Some) + .map_err(de::Error::custom) + } + ConfigFileNetworkType::Solana => { + serde_json::from_value::(policy_value.clone()) + .map(ConfigFileRelayerNetworkPolicy::Solana) + .map(Some) + .map_err(de::Error::custom) + } + ConfigFileNetworkType::Stellar => { + serde_json::from_value::(policy_value.clone()) + .map(ConfigFileRelayerNetworkPolicy::Stellar) + .map(Some) + .map_err(de::Error::custom) + } + } + } else { + Ok(None) // `policies` is optional + }?; + + let custom_rpc_urls = value + .get("custom_rpc_urls") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| { + // Handle both string format (legacy) and object format (new) + if let Some(url_str) = v.as_str() { + // Convert string to RpcConfig with default weight + Some(RpcConfig::new(url_str.to_string())) + } else { + // Try to parse as a RpcConfig object + serde_json::from_value::(v.clone()).ok() + } + }) + .collect() + }); + + Ok(RelayerFileConfig { + id, + name, + network, + paused, + network_type, + policies, + signer_id, + notification_id, + custom_rpc_urls, + }) + } +} + +impl TryFrom for Relayer { + type Error = ConfigFileError; + + fn try_from(config: RelayerFileConfig) -> Result { + // Convert config policies to domain model policies + let policies = if let Some(config_policies) = config.policies { + Some(convert_config_policies_to_domain(config_policies)?) + } else { + None + }; + + // Create domain relayer + let relayer = Relayer::new( + config.id, + config.name, + config.network, + config.paused, + config.network_type.into(), + policies, + config.signer_id, + config.notification_id, + config.custom_rpc_urls, + ); + + // Validate using domain validation logic + relayer.validate().map_err(|e| match e { + RelayerValidationError::EmptyId => ConfigFileError::MissingField("relayer id".into()), + RelayerValidationError::InvalidIdFormat => ConfigFileError::InvalidIdFormat( + "ID must contain only letters, numbers, dashes and underscores".into(), + ), + RelayerValidationError::IdTooLong => { + ConfigFileError::InvalidIdLength("ID length must not exceed 36 characters".into()) + } + RelayerValidationError::EmptyName => { + ConfigFileError::MissingField("relayer name".into()) + } + RelayerValidationError::EmptyNetwork => ConfigFileError::MissingField("network".into()), + RelayerValidationError::InvalidPolicy(msg) => ConfigFileError::InvalidPolicy(msg), + RelayerValidationError::InvalidRpcUrl(msg) => { + ConfigFileError::InvalidFormat(format!("Invalid RPC URL: {}", msg)) + } + RelayerValidationError::InvalidRpcWeight => { + ConfigFileError::InvalidFormat("RPC URL weight must be in range 0-100".to_string()) + } + })?; + + Ok(relayer) + } +} + +fn convert_config_policies_to_domain( + config_policies: ConfigFileRelayerNetworkPolicy, +) -> Result { + match config_policies { + ConfigFileRelayerNetworkPolicy::Evm(evm_policy) => { + Ok(RelayerNetworkPolicy::Evm(super::RelayerEvmPolicy { + min_balance: Some(evm_policy.min_balance.unwrap_or_default()), + gas_limit_estimation: evm_policy.gas_limit_estimation, + gas_price_cap: evm_policy.gas_price_cap, + whitelist_receivers: evm_policy.whitelist_receivers, + eip1559_pricing: evm_policy.eip1559_pricing, + private_transactions: evm_policy.private_transactions, + })) + } + ConfigFileRelayerNetworkPolicy::Solana(solana_policy) => { + let swap_config = if let Some(config_swap) = solana_policy.swap_config { + Some(super::RelayerSolanaSwapPolicy { + strategy: config_swap.strategy.map(|s| match s { + ConfigFileRelayerSolanaSwapStrategy::JupiterSwap => { + super::RelayerSolanaSwapStrategy::JupiterSwap + } + ConfigFileRelayerSolanaSwapStrategy::JupiterUltra => { + super::RelayerSolanaSwapStrategy::JupiterUltra + } + }), + cron_schedule: config_swap.cron_schedule, + min_balance_threshold: config_swap.min_balance_threshold, + jupiter_swap_options: config_swap.jupiter_swap_options.map(|opts| { + super::JupiterSwapOptions { + priority_fee_max_lamports: opts.priority_fee_max_lamports, + priority_level: opts.priority_level, + dynamic_compute_unit_limit: opts.dynamic_compute_unit_limit, + } + }), + }) + } else { + None + }; + + Ok(RelayerNetworkPolicy::Solana(super::RelayerSolanaPolicy { + allowed_programs: solana_policy.allowed_programs, + max_signatures: solana_policy.max_signatures, + max_tx_data_size: solana_policy.max_tx_data_size, + min_balance: solana_policy.min_balance, + allowed_tokens: solana_policy.allowed_tokens.map(|tokens| { + tokens + .into_iter() + .map(|t| super::AllowedToken { + mint: t.mint, + decimals: t.decimals, + symbol: t.symbol, + max_allowed_fee: t.max_allowed_fee, + swap_config: t.swap_config.map(|sc| super::AllowedTokenSwapConfig { + slippage_percentage: sc.slippage_percentage, + min_amount: sc.min_amount, + max_amount: sc.max_amount, + retain_min_amount: sc.retain_min_amount, + }), + }) + .collect() + }), + fee_payment_strategy: solana_policy.fee_payment_strategy.map(|s| match s { + ConfigFileRelayerSolanaFeePaymentStrategy::User => { + super::RelayerSolanaFeePaymentStrategy::User + } + ConfigFileRelayerSolanaFeePaymentStrategy::Relayer => { + super::RelayerSolanaFeePaymentStrategy::Relayer + } + }), + fee_margin_percentage: solana_policy.fee_margin_percentage, + allowed_accounts: solana_policy.allowed_accounts, + disallowed_accounts: solana_policy.disallowed_accounts, + max_allowed_fee_lamports: solana_policy.max_allowed_fee_lamports, + swap_config, + })) + } + ConfigFileRelayerNetworkPolicy::Stellar(stellar_policy) => { + Ok(RelayerNetworkPolicy::Stellar(super::RelayerStellarPolicy { + min_balance: stellar_policy.min_balance, + max_fee: stellar_policy.max_fee, + timeout_seconds: stellar_policy.timeout_seconds, + })) + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct RelayersFileConfig { + pub relayers: Vec, +} + +impl RelayersFileConfig { + pub fn new(relayers: Vec) -> Self { + Self { relayers } + } + + pub fn validate(&self, networks: &NetworksFileConfig) -> Result<(), ConfigFileError> { + if self.relayers.is_empty() { + return Err(ConfigFileError::MissingField("relayers".into())); + } + + let mut ids = HashSet::new(); + for relayer_config in &self.relayers { + if relayer_config.network.is_empty() { + return Err(ConfigFileError::InvalidFormat( + "relayer.network cannot be empty".into(), + )); + } + + if networks + .get_network(relayer_config.network_type, &relayer_config.network) + .is_none() + { + return Err(ConfigFileError::InvalidReference(format!( + "Relayer '{}' references non-existent network '{}' for type '{:?}'", + relayer_config.id, relayer_config.network, relayer_config.network_type + ))); + } + + // Convert to domain model and validate + let relayer = Relayer::try_from(relayer_config.clone())?; + relayer.validate().map_err(|e| match e { + RelayerValidationError::EmptyId => { + ConfigFileError::MissingField("relayer id".into()) + } + RelayerValidationError::InvalidIdFormat => ConfigFileError::InvalidIdFormat( + "ID must contain only letters, numbers, dashes and underscores".into(), + ), + RelayerValidationError::IdTooLong => ConfigFileError::InvalidIdLength( + "ID length must not exceed 36 characters".into(), + ), + RelayerValidationError::EmptyName => { + ConfigFileError::MissingField("relayer name".into()) + } + RelayerValidationError::EmptyNetwork => { + ConfigFileError::MissingField("network".into()) + } + RelayerValidationError::InvalidPolicy(msg) => ConfigFileError::InvalidPolicy(msg), + RelayerValidationError::InvalidRpcUrl(msg) => { + ConfigFileError::InvalidFormat(format!("Invalid RPC URL: {}", msg)) + } + RelayerValidationError::InvalidRpcWeight => ConfigFileError::InvalidFormat( + "RPC URL weight must be in range 0-100".to_string(), + ), + })?; + + if !ids.insert(relayer_config.id.clone()) { + return Err(ConfigFileError::DuplicateId(relayer_config.id.clone())); + } + } + Ok(()) + } +} diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index 1838eeab2..864d0b919 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -1,8 +1,764 @@ -mod repository; -pub use repository::*; +//! Relayer domain model and business logic. +//! +//! This module provides the central `Relayer` type that represents relayers +//! throughout the relayer system, including: +//! +//! - **Domain Model**: Core `Relayer` struct with validation and configuration +//! - **Business Logic**: Update operations and validation rules +//! - **Error Handling**: Comprehensive validation error types +//! - **Interoperability**: Conversions between API, config, and repository representations +//! +//! The relayer model supports multiple network types (EVM, Solana, Stellar) with +//! network-specific policies and configurations. + +mod config; +pub use config::*; + +mod request; +pub use request::*; mod response; pub use response::*; +pub mod repository; +pub use repository::*; + mod rpc_config; pub use rpc_config::*; + +use crate::{ + config::ConfigFileNetworkType, + constants::{ + DEFAULT_EVM_EIP1559_ENABLED, DEFAULT_EVM_GAS_LIMIT_ESTIMATION, DEFAULT_EVM_MIN_BALANCE, + DEFAULT_EVM_PRIVATE_TRANSACTIONS, DEFAULT_SOLANA_MAX_TX_DATA_SIZE, + DEFAULT_SOLANA_MIN_BALANCE, DEFAULT_STELLAR_MIN_BALANCE, ID_REGEX, + }, + utils::deserialize_optional_u128, +}; +use apalis_cron::Schedule; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use utoipa::ToSchema; +use validator::Validate; + +/// Network type enum for relayers +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum RelayerNetworkType { + Evm, + Solana, + Stellar, +} + +impl std::fmt::Display for RelayerNetworkType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RelayerNetworkType::Evm => write!(f, "evm"), + RelayerNetworkType::Solana => write!(f, "solana"), + RelayerNetworkType::Stellar => write!(f, "stellar"), + } + } +} + +impl From for RelayerNetworkType { + fn from(config_type: ConfigFileNetworkType) -> Self { + match config_type { + ConfigFileNetworkType::Evm => RelayerNetworkType::Evm, + ConfigFileNetworkType::Solana => RelayerNetworkType::Solana, + ConfigFileNetworkType::Stellar => RelayerNetworkType::Stellar, + } + } +} + +impl From for ConfigFileNetworkType { + fn from(domain_type: RelayerNetworkType) -> Self { + match domain_type { + RelayerNetworkType::Evm => ConfigFileNetworkType::Evm, + RelayerNetworkType::Solana => ConfigFileNetworkType::Solana, + RelayerNetworkType::Stellar => ConfigFileNetworkType::Stellar, + } + } +} + +/// EVM-specific relayer policy configuration +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq)] +pub struct RelayerEvmPolicy { + pub min_balance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gas_limit_estimation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(deserialize_with = "deserialize_optional_u128", default)] + pub gas_price_cap: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub whitelist_receivers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub eip1559_pricing: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub private_transactions: Option, +} + +impl Default for RelayerEvmPolicy { + fn default() -> Self { + Self { + min_balance: Some(DEFAULT_EVM_MIN_BALANCE), + gas_limit_estimation: Some(DEFAULT_EVM_GAS_LIMIT_ESTIMATION), + gas_price_cap: Some(u128::MAX), + whitelist_receivers: None, + eip1559_pricing: Some(DEFAULT_EVM_EIP1559_ENABLED), + private_transactions: Some(DEFAULT_EVM_PRIVATE_TRANSACTIONS), + } + } +} + +/// Solana token swap configuration +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq, Default)] +pub struct AllowedTokenSwapConfig { + /// Conversion slippage percentage for token. Optional. + pub slippage_percentage: Option, + /// Minimum amount of tokens to swap. Optional. + pub min_amount: Option, + /// Maximum amount of tokens to swap. Optional. + pub max_amount: Option, + /// Minimum amount of tokens to retain after swap. Optional. + pub retain_min_amount: Option, +} + +/// Configuration for allowed token handling on Solana +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +pub struct AllowedToken { + pub mint: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub decimals: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_allowed_fee: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub swap_config: Option, +} + +impl AllowedToken { + /// Create a new AllowedToken with required parameters + pub fn new( + mint: String, + max_allowed_fee: Option, + swap_config: Option, + ) -> Self { + Self { + mint, + decimals: None, + symbol: None, + max_allowed_fee, + swap_config, + } + } + + /// Create a new partial AllowedToken (alias for `new` for backward compatibility) + pub fn new_partial( + mint: String, + max_allowed_fee: Option, + swap_config: Option, + ) -> Self { + Self::new(mint, max_allowed_fee, swap_config) + } +} + +/// Solana fee payment strategy +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum RelayerSolanaFeePaymentStrategy { + User, + Relayer, +} + +impl Default for RelayerSolanaFeePaymentStrategy { + fn default() -> Self { + Self::User + } +} + +/// Solana swap strategy +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum RelayerSolanaSwapStrategy { + JupiterSwap, + JupiterUltra, + Noop, +} + +impl Default for RelayerSolanaSwapStrategy { + fn default() -> Self { + Self::Noop + } +} + +/// Jupiter swap options +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq)] +pub struct JupiterSwapOptions { + /// Maximum priority fee (in lamports) for a transaction. Optional. + pub priority_fee_max_lamports: Option, + /// Priority. Optional. + pub priority_level: Option, + pub dynamic_compute_unit_limit: Option, +} + +/// Solana swap policy configuration +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq)] +pub struct RelayerSolanaSwapPolicy { + /// DEX strategy to use for token swaps. + pub strategy: Option, + /// Cron schedule for executing token swap logic to keep relayer funded. Optional. + pub cron_schedule: Option, + /// Min sol balance to execute token swap logic to keep relayer funded. Optional. + pub min_balance_threshold: Option, + /// Swap options for JupiterSwap strategy. Optional. + pub jupiter_swap_options: Option, +} + +impl Default for RelayerSolanaSwapPolicy { + fn default() -> Self { + Self { + strategy: Some(RelayerSolanaSwapStrategy::default()), + cron_schedule: None, + min_balance_threshold: None, + jupiter_swap_options: None, + } + } +} + +/// Solana-specific relayer policy configuration +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +pub struct RelayerSolanaPolicy { + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_programs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_signatures: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tx_data_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_balance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_tokens: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub fee_payment_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fee_margin_percentage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_accounts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub disallowed_accounts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_allowed_fee_lamports: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub swap_config: Option, +} + +impl Default for RelayerSolanaPolicy { + fn default() -> Self { + Self { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_margin_percentage: None, + min_balance: Some(DEFAULT_SOLANA_MIN_BALANCE), + allowed_tokens: None, + allowed_programs: None, + allowed_accounts: None, + disallowed_accounts: None, + max_signatures: None, + max_tx_data_size: Some(DEFAULT_SOLANA_MAX_TX_DATA_SIZE), + max_allowed_fee_lamports: None, + swap_config: None, + } + } +} + +impl RelayerSolanaPolicy { + /// Get allowed tokens for this policy + pub fn get_allowed_tokens(&self) -> Vec { + self.allowed_tokens.clone().unwrap_or_default() + } + + /// Get allowed token entry by mint address + pub fn get_allowed_token_entry(&self, mint: &str) -> Option { + self.allowed_tokens + .clone() + .unwrap_or_default() + .into_iter() + .find(|entry| entry.mint == mint) + } + + /// Get swap configuration for this policy + pub fn get_swap_config(&self) -> Option { + self.swap_config.clone() + } + + /// Get allowed token decimals by mint address + pub fn get_allowed_token_decimals(&self, mint: &str) -> Option { + self.get_allowed_token_entry(mint) + .and_then(|entry| entry.decimals) + } +} +/// Stellar-specific relayer policy configuration +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq)] +pub struct RelayerStellarPolicy { + #[serde(skip_serializing_if = "Option::is_none")] + pub min_balance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_fee: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, +} + +impl Default for RelayerStellarPolicy { + fn default() -> Self { + Self { + max_fee: None, + timeout_seconds: None, + min_balance: Some(DEFAULT_STELLAR_MIN_BALANCE), + } + } +} + +/// Network-specific policy for relayers +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +#[serde(tag = "network_type")] +pub enum RelayerNetworkPolicy { + #[serde(rename = "evm")] + Evm(RelayerEvmPolicy), + #[serde(rename = "solana")] + Solana(RelayerSolanaPolicy), + #[serde(rename = "stellar")] + Stellar(RelayerStellarPolicy), +} + +impl RelayerNetworkPolicy { + /// Get EVM policy, returning default if not EVM + pub fn get_evm_policy(&self) -> RelayerEvmPolicy { + match self { + Self::Evm(policy) => policy.clone(), + _ => RelayerEvmPolicy::default(), + } + } + + /// Get Solana policy, returning default if not Solana + pub fn get_solana_policy(&self) -> RelayerSolanaPolicy { + match self { + Self::Solana(policy) => policy.clone(), + _ => RelayerSolanaPolicy::default(), + } + } + + /// Get Stellar policy, returning default if not Stellar + pub fn get_stellar_policy(&self) -> RelayerStellarPolicy { + match self { + Self::Stellar(policy) => policy.clone(), + _ => RelayerStellarPolicy::default(), + } + } +} + +/// Core relayer domain model +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct Relayer { + #[validate( + length(min = 1, max = 36, message = "ID must be between 1 and 36 characters"), + regex( + path = "*ID_REGEX", + message = "ID must contain only letters, numbers, dashes and underscores" + ) + )] + pub id: String, + + #[validate(length(min = 1, message = "Name cannot be empty"))] + pub name: String, + + #[validate(length(min = 1, message = "Network cannot be empty"))] + pub network: String, + + pub paused: bool, + pub network_type: RelayerNetworkType, + pub policies: Option, + + #[validate(length(min = 1, message = "Signer ID cannot be empty"))] + pub signer_id: String, + + pub notification_id: Option, + pub custom_rpc_urls: Option>, +} + +impl Relayer { + /// Creates a new relayer + pub fn new( + id: String, + name: String, + network: String, + paused: bool, + network_type: RelayerNetworkType, + policies: Option, + signer_id: String, + notification_id: Option, + custom_rpc_urls: Option>, + ) -> Self { + Self { + id, + name, + network, + paused, + network_type, + policies, + signer_id, + notification_id, + custom_rpc_urls, + } + } + + /// Validates the relayer using both validator crate and custom validation + pub fn validate(&self) -> Result<(), RelayerValidationError> { + // Check for empty ID specifically first + if self.id.is_empty() { + return Err(RelayerValidationError::EmptyId); + } + + // Check for ID too long + if self.id.len() > 36 { + return Err(RelayerValidationError::IdTooLong); + } + + // First run validator crate validation + Validate::validate(self).map_err(|validation_errors| { + // Convert validator errors to our custom error type + for (field, errors) in validation_errors.field_errors() { + if let Some(error) = errors.first() { + let field_str = field.as_ref(); + return match (field_str, error.code.as_ref()) { + ("id", "regex") => RelayerValidationError::InvalidIdFormat, + ("name", "length") => RelayerValidationError::EmptyName, + ("network", "length") => RelayerValidationError::EmptyNetwork, + ("signer_id", "length") => RelayerValidationError::InvalidPolicy( + "Signer ID cannot be empty".to_string(), + ), + _ => RelayerValidationError::InvalidIdFormat, // fallback + }; + } + } + // Fallback error + RelayerValidationError::InvalidIdFormat + })?; + + // Run custom validation + self.validate_policies()?; + self.validate_custom_rpc_urls()?; + + Ok(()) + } + + /// Validates network-specific policies + fn validate_policies(&self) -> Result<(), RelayerValidationError> { + match (&self.network_type, &self.policies) { + (RelayerNetworkType::Solana, Some(RelayerNetworkPolicy::Solana(policy))) => { + self.validate_solana_policy(policy)?; + } + (RelayerNetworkType::Evm, Some(RelayerNetworkPolicy::Evm(_))) => { + // EVM policies don't need special validation currently + } + (RelayerNetworkType::Stellar, Some(RelayerNetworkPolicy::Stellar(_))) => { + // Stellar policies don't need special validation currently + } + // Mismatched network type and policy type + (network_type, Some(policy)) => { + let policy_type = match policy { + RelayerNetworkPolicy::Evm(_) => "EVM", + RelayerNetworkPolicy::Solana(_) => "Solana", + RelayerNetworkPolicy::Stellar(_) => "Stellar", + }; + let network_type_str = format!("{:?}", network_type); + return Err(RelayerValidationError::InvalidPolicy(format!( + "Network type {} does not match policy type {}", + network_type_str, policy_type + ))); + } + // No policies is fine + (_, None) => {} + } + Ok(()) + } + + /// Validates Solana-specific policies + fn validate_solana_policy( + &self, + policy: &RelayerSolanaPolicy, + ) -> Result<(), RelayerValidationError> { + // Validate public keys + self.validate_solana_pub_keys(&policy.allowed_accounts)?; + self.validate_solana_pub_keys(&policy.disallowed_accounts)?; + self.validate_solana_pub_keys(&policy.allowed_programs)?; + + // Validate allowed tokens mint addresses + if let Some(tokens) = &policy.allowed_tokens { + let mint_keys: Vec = tokens.iter().map(|t| t.mint.clone()).collect(); + self.validate_solana_pub_keys(&Some(mint_keys))?; + } + + // Validate fee margin percentage + if let Some(fee_margin) = policy.fee_margin_percentage { + if fee_margin < 0.0 { + return Err(RelayerValidationError::InvalidPolicy( + "Negative fee margin percentage values are not accepted".into(), + )); + } + } + + // Check for conflicting allowed/disallowed accounts + if policy.allowed_accounts.is_some() && policy.disallowed_accounts.is_some() { + return Err(RelayerValidationError::InvalidPolicy( + "allowed_accounts and disallowed_accounts cannot be both present".into(), + )); + } + + // Validate swap configuration + if let Some(swap_config) = &policy.swap_config { + self.validate_solana_swap_config(swap_config, policy)?; + } + + Ok(()) + } + + /// Validates Solana public key format + fn validate_solana_pub_keys( + &self, + keys: &Option>, + ) -> Result<(), RelayerValidationError> { + if let Some(keys) = keys { + let solana_pub_key_regex = + Regex::new(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$").map_err(|e| { + RelayerValidationError::InvalidPolicy(format!("Regex compilation error: {}", e)) + })?; + + for key in keys { + if !solana_pub_key_regex.is_match(key) { + return Err(RelayerValidationError::InvalidPolicy( + "Public key must be a valid Solana address".into(), + )); + } + } + } + Ok(()) + } + + /// Validates Solana swap configuration + fn validate_solana_swap_config( + &self, + swap_config: &RelayerSolanaSwapPolicy, + policy: &RelayerSolanaPolicy, + ) -> Result<(), RelayerValidationError> { + // Swap config only supported for user fee payment strategy + if let Some(fee_payment_strategy) = &policy.fee_payment_strategy { + if *fee_payment_strategy == RelayerSolanaFeePaymentStrategy::Relayer { + return Err(RelayerValidationError::InvalidPolicy( + "Swap config only supported for user fee payment strategy".into(), + )); + } + } + + // Validate strategy-specific restrictions + if let Some(strategy) = &swap_config.strategy { + match strategy { + RelayerSolanaSwapStrategy::JupiterSwap + | RelayerSolanaSwapStrategy::JupiterUltra => { + if self.network != "mainnet-beta" { + return Err(RelayerValidationError::InvalidPolicy(format!( + "{:?} strategy is only supported on mainnet-beta", + strategy + ))); + } + } + RelayerSolanaSwapStrategy::Noop => { + // No-op strategy doesn't need validation + } + } + } + + // Validate cron schedule + if let Some(cron_schedule) = &swap_config.cron_schedule { + if cron_schedule.is_empty() { + return Err(RelayerValidationError::InvalidPolicy( + "Empty cron schedule is not accepted".into(), + )); + } + + Schedule::from_str(cron_schedule).map_err(|_| { + RelayerValidationError::InvalidPolicy("Invalid cron schedule format".into()) + })?; + } + + // Validate Jupiter swap options + if let Some(jupiter_options) = &swap_config.jupiter_swap_options { + // Jupiter options only valid for JupiterSwap strategy + if swap_config.strategy != Some(RelayerSolanaSwapStrategy::JupiterSwap) { + return Err(RelayerValidationError::InvalidPolicy( + "JupiterSwap options are only valid for JupiterSwap strategy".into(), + )); + } + + if let Some(max_lamports) = jupiter_options.priority_fee_max_lamports { + if max_lamports == 0 { + return Err(RelayerValidationError::InvalidPolicy( + "Max lamports must be greater than 0".into(), + )); + } + } + + if let Some(priority_level) = &jupiter_options.priority_level { + if priority_level.is_empty() { + return Err(RelayerValidationError::InvalidPolicy( + "Priority level cannot be empty".into(), + )); + } + + let valid_levels = ["medium", "high", "veryHigh"]; + if !valid_levels.contains(&priority_level.as_str()) { + return Err(RelayerValidationError::InvalidPolicy( + "Priority level must be one of: medium, high, veryHigh".into(), + )); + } + } + + // Priority level and max lamports must be used together + match ( + &jupiter_options.priority_level, + jupiter_options.priority_fee_max_lamports, + ) { + (Some(_), None) => { + return Err(RelayerValidationError::InvalidPolicy( + "Priority Fee Max lamports must be set if priority level is set".into(), + )); + } + (None, Some(_)) => { + return Err(RelayerValidationError::InvalidPolicy( + "Priority level must be set if priority fee max lamports is set".into(), + )); + } + _ => {} + } + } + + Ok(()) + } + + /// Validates custom RPC URL configurations + fn validate_custom_rpc_urls(&self) -> Result<(), RelayerValidationError> { + if let Some(configs) = &self.custom_rpc_urls { + for config in configs { + reqwest::Url::parse(&config.url) + .map_err(|_| RelayerValidationError::InvalidRpcUrl(config.url.clone()))?; + + if config.weight > 100 { + return Err(RelayerValidationError::InvalidRpcWeight); + } + } + } + Ok(()) + } + + /// Applies an update request to create a new validated relayer + /// + /// This method provides a domain-first approach where the core model handles + /// its own business rules and validation rather than having update logic + /// scattered across request models. + /// + /// # Arguments + /// * `request` - The update request containing partial data to apply + /// + /// # Returns + /// * `Ok(Relayer)` - A new validated relayer with updates applied + /// * `Err(RelayerValidationError)` - If the resulting relayer would be invalid + pub fn apply_update( + &self, + request: &UpdateRelayerRequest, + ) -> Result { + let mut updated = self.clone(); + + // Apply updates from request + if let Some(name) = &request.name { + updated.name = name.clone(); + } + + if let Some(paused) = request.paused { + updated.paused = paused; + } + + if let Some(network_type) = &request.network_type { + updated.network_type = network_type.clone(); + } + + if let Some(policies) = &request.policies { + updated.policies = Some(policies.clone()); + } + + if let Some(notification_id) = &request.notification_id { + updated.notification_id = if notification_id.is_empty() { + None + } else { + Some(notification_id.clone()) + }; + } + + if let Some(custom_rpc_urls) = &request.custom_rpc_urls { + updated.custom_rpc_urls = Some(custom_rpc_urls.clone()); + } + + // Validate the complete updated model + updated.validate()?; + + Ok(updated) + } +} + +/// Validation errors for relayers +#[derive(Debug, thiserror::Error)] +pub enum RelayerValidationError { + #[error("Relayer ID cannot be empty")] + EmptyId, + #[error("Relayer ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long")] + InvalidIdFormat, + #[error("Relayer ID must not exceed 36 characters")] + IdTooLong, + #[error("Relayer name cannot be empty")] + EmptyName, + #[error("Network cannot be empty")] + EmptyNetwork, + #[error("Invalid relayer policy: {0}")] + InvalidPolicy(String), + #[error("Invalid RPC URL: {0}")] + InvalidRpcUrl(String), + #[error("RPC URL weight must be in range 0-100")] + InvalidRpcWeight, +} + +/// Centralized conversion from RelayerValidationError to ApiError +impl From for crate::models::ApiError { + fn from(error: RelayerValidationError) -> Self { + use crate::models::ApiError; + + ApiError::BadRequest(match error { + RelayerValidationError::EmptyId => "ID cannot be empty".to_string(), + RelayerValidationError::InvalidIdFormat => { + "ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long".to_string() + } + RelayerValidationError::IdTooLong => { + "ID must not exceed 36 characters".to_string() + } + RelayerValidationError::EmptyName => "Name cannot be empty".to_string(), + RelayerValidationError::EmptyNetwork => "Network cannot be empty".to_string(), + RelayerValidationError::InvalidPolicy(msg) => { + format!("Invalid relayer policy: {}", msg) + } + RelayerValidationError::InvalidRpcUrl(url) => { + format!("Invalid RPC URL: {}", url) + } + RelayerValidationError::InvalidRpcWeight => { + "RPC URL weight must be in range 0-100".to_string() + } + }) + } +} diff --git a/src/models/relayer/repository.rs b/src/models/relayer/repository.rs index 11e6103b3..c23a9c22b 100644 --- a/src/models/relayer/repository.rs +++ b/src/models/relayer/repository.rs @@ -1,290 +1,12 @@ -use serde::{Deserialize, Serialize}; -use strum::Display; -use utoipa::ToSchema; - -use crate::{ - constants::{ - DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE, DEFAULT_EVM_MIN_BALANCE, - DEFAULT_SOLANA_MIN_BALANCE, DEFAULT_STELLAR_MIN_BALANCE, MAX_SOLANA_TX_DATA_SIZE, - }, - models::RelayerError, +use crate::models::{ + Relayer, RelayerError, RelayerEvmPolicy, RelayerSolanaPolicy, RelayerStellarPolicy, }; +use serde::{Deserialize, Serialize}; -use super::RpcConfig; - -#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash, Display, Deserialize, Copy, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum NetworkType { - Evm, - Stellar, - Solana, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum RelayerNetworkPolicy { - Evm(RelayerEvmPolicy), - Solana(RelayerSolanaPolicy), - Stellar(RelayerStellarPolicy), -} - -impl RelayerNetworkPolicy { - pub fn get_evm_policy(&self) -> RelayerEvmPolicy { - match self { - Self::Evm(policy) => policy.clone(), - _ => RelayerEvmPolicy::default(), - } - } - - pub fn get_solana_policy(&self) -> RelayerSolanaPolicy { - match self { - Self::Solana(policy) => policy.clone(), - _ => RelayerSolanaPolicy::default(), - } - } - - pub fn get_stellar_policy(&self) -> RelayerStellarPolicy { - match self { - Self::Stellar(policy) => policy.clone(), - _ => RelayerStellarPolicy::default(), - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct RelayerEvmPolicy { - pub gas_price_cap: Option, - pub whitelist_receivers: Option>, - pub eip1559_pricing: Option, - pub private_transactions: bool, - pub min_balance: u128, - pub gas_limit_estimation: Option, -} - -impl Default for RelayerEvmPolicy { - fn default() -> Self { - Self { - gas_price_cap: None, - whitelist_receivers: None, - eip1559_pricing: None, - private_transactions: false, - min_balance: DEFAULT_EVM_MIN_BALANCE, - gas_limit_estimation: Some(true), - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq, Default)] -pub struct SolanaAllowedTokensSwapConfig { - #[schema(nullable = false)] - pub slippage_percentage: Option, - #[schema(nullable = false)] - pub min_amount: Option, - #[schema(nullable = false)] - pub max_amount: Option, - #[schema(nullable = false)] - pub retain_min_amount: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -pub struct SolanaAllowedTokensPolicy { - pub mint: String, - #[schema(nullable = false)] - pub decimals: Option, - #[schema(nullable = false)] - pub symbol: Option, - #[schema(nullable = false)] - pub max_allowed_fee: Option, - #[schema(nullable = false)] - pub swap_config: Option, -} - -impl SolanaAllowedTokensPolicy { - pub fn new( - mint: String, - decimals: Option, - symbol: Option, - max_allowed_fee: Option, - swap_config: Option, - ) -> Self { - Self { - mint, - decimals, - symbol, - max_allowed_fee, - swap_config, - } - } - - // Create a new SolanaAllowedTokensPolicy with only the mint field - // We are creating partial entry while processing config file and later - // we will fill the rest of the fields - pub fn new_partial( - mint: String, - max_allowed_fee: Option, - swap_config: Option, - ) -> Self { - Self { - mint, - decimals: None, - symbol: None, - max_allowed_fee, - swap_config, - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum SolanaFeePaymentStrategy { - User, - Relayer, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -#[serde(rename_all = "kebab-case")] -pub enum SolanaSwapStrategy { - JupiterSwap, - JupiterUltra, - Noop, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct JupiterSwapOptions { - pub priority_fee_max_lamports: Option, - pub priority_level: Option, - pub dynamic_compute_unit_limit: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct RelayerSolanaSwapConfig { - #[schema(nullable = false)] - pub strategy: Option, - #[schema(nullable = false)] - pub cron_schedule: Option, - #[schema(nullable = false)] - pub min_balance_threshold: Option, - #[schema(nullable = false)] - pub jupiter_swap_options: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct RelayerSolanaPolicy { - pub fee_payment_strategy: SolanaFeePaymentStrategy, - pub fee_margin_percentage: Option, - pub min_balance: u64, - pub allowed_tokens: Option>, - pub allowed_programs: Option>, - pub allowed_accounts: Option>, - pub disallowed_accounts: Option>, - pub max_signatures: Option, - pub max_tx_data_size: u16, - pub max_allowed_fee_lamports: Option, - pub swap_config: Option, -} - -impl RelayerSolanaPolicy { - pub fn get_allowed_tokens(&self) -> Vec { - self.allowed_tokens.clone().unwrap_or_default() - } - - pub fn get_allowed_token_entry(&self, mint: &str) -> Option { - self.allowed_tokens - .clone() - .unwrap_or_default() - .into_iter() - .find(|entry| entry.mint == mint) - } - - pub fn get_allowed_token_decimals(&self, mint: &str) -> Option { - self.get_allowed_token_entry(mint) - .and_then(|entry| entry.decimals) - } - - pub fn get_swap_config(&self) -> Option { - self.swap_config.clone() - } - - pub fn get_allowed_token_slippage(&self, mint: &str) -> f32 { - self.get_allowed_token_entry(mint) - .and_then(|entry| { - entry - .swap_config - .and_then(|config| config.slippage_percentage) - }) - .unwrap_or(DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE) - } - - pub fn get_allowed_programs(&self) -> Vec { - self.allowed_programs.clone().unwrap_or_default() - } - - pub fn get_allowed_accounts(&self) -> Vec { - self.allowed_accounts.clone().unwrap_or_default() - } - - pub fn get_disallowed_accounts(&self) -> Vec { - self.disallowed_accounts.clone().unwrap_or_default() - } - - pub fn get_max_signatures(&self) -> u8 { - self.max_signatures.unwrap_or(1) - } - - pub fn get_max_allowed_fee_lamports(&self) -> u64 { - self.max_allowed_fee_lamports.unwrap_or(u64::MAX) - } - - pub fn get_max_tx_data_size(&self) -> u16 { - self.max_tx_data_size - } - - pub fn get_fee_margin_percentage(&self) -> f32 { - self.fee_margin_percentage.unwrap_or(0.0) - } - - pub fn get_fee_payment_strategy(&self) -> SolanaFeePaymentStrategy { - self.fee_payment_strategy.clone() - } -} - -impl Default for RelayerSolanaPolicy { - fn default() -> Self { - Self { - fee_payment_strategy: SolanaFeePaymentStrategy::User, - fee_margin_percentage: None, - min_balance: DEFAULT_SOLANA_MIN_BALANCE, - allowed_tokens: None, - allowed_programs: None, - allowed_accounts: None, - disallowed_accounts: None, - max_signatures: None, - max_tx_data_size: MAX_SOLANA_TX_DATA_SIZE, - max_allowed_fee_lamports: None, - swap_config: None, - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct RelayerStellarPolicy { - pub max_fee: Option, - pub timeout_seconds: Option, - pub min_balance: u64, -} +use super::{RelayerNetworkPolicy, RelayerNetworkType, RpcConfig}; -impl Default for RelayerStellarPolicy { - fn default() -> Self { - Self { - max_fee: None, - timeout_seconds: None, - min_balance: DEFAULT_STELLAR_MIN_BALANCE, - } - } -} +// Use the domain model RelayerNetworkType directly +pub type NetworkType = RelayerNetworkType; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayerRepoModel { @@ -333,144 +55,41 @@ impl Default for RelayerRepoModel { } } -impl TryFrom for RelayerRepoModel { - type Error = eyre::Report; - - fn try_from(config: crate::config::RelayerFileConfig) -> Result { - use crate::config::{ConfigFileNetworkType, ConfigFileRelayerNetworkPolicy}; - - // Convert network type - let network_type = match config.network_type { - ConfigFileNetworkType::Evm => NetworkType::Evm, - ConfigFileNetworkType::Solana => NetworkType::Solana, - ConfigFileNetworkType::Stellar => NetworkType::Stellar, - }; - - // Convert policies based on network type - let policies = match config.policies { - Some(ConfigFileRelayerNetworkPolicy::Evm(evm_policy)) => { - RelayerNetworkPolicy::Evm(RelayerEvmPolicy { - gas_price_cap: evm_policy.gas_price_cap, - whitelist_receivers: evm_policy.whitelist_receivers, - eip1559_pricing: evm_policy.eip1559_pricing, - private_transactions: evm_policy.private_transactions.unwrap_or(false), - min_balance: evm_policy.min_balance.unwrap_or(DEFAULT_EVM_MIN_BALANCE), - gas_limit_estimation: evm_policy.gas_limit_estimation, - }) - } - Some(ConfigFileRelayerNetworkPolicy::Solana(solana_policy)) => { - use crate::config::ConfigFileRelayerSolanaFeePaymentStrategy; - - let allowed_tokens = solana_policy.allowed_tokens.map(|tokens| { - tokens - .into_iter() - .map(|token| { - SolanaAllowedTokensPolicy::new_partial( - token.mint, - token.max_allowed_fee, - token.swap_config.map(|swap| SolanaAllowedTokensSwapConfig { - slippage_percentage: swap.slippage_percentage, - min_amount: swap.min_amount, - max_amount: swap.max_amount, - retain_min_amount: swap.retain_min_amount, - }), - ) - }) - .collect() - }); - - let swap_config = solana_policy.swap_config.map(|swap| { - use crate::config::ConfigFileRelayerSolanaSwapStrategy; - - RelayerSolanaSwapConfig { - strategy: Some(match swap.strategy { - Some(ConfigFileRelayerSolanaSwapStrategy::JupiterSwap) => { - SolanaSwapStrategy::JupiterSwap - } - Some(ConfigFileRelayerSolanaSwapStrategy::JupiterUltra) => { - SolanaSwapStrategy::JupiterUltra - } - None => SolanaSwapStrategy::Noop, - }), - cron_schedule: swap.cron_schedule, - min_balance_threshold: swap.min_balance_threshold, - jupiter_swap_options: swap.jupiter_swap_options.map(|opts| { - JupiterSwapOptions { - priority_fee_max_lamports: opts.priority_fee_max_lamports, - priority_level: opts.priority_level, - dynamic_compute_unit_limit: opts.dynamic_compute_unit_limit, - } - }), +impl From for RelayerRepoModel { + fn from(relayer: Relayer) -> Self { + Self { + id: relayer.id, + name: relayer.name, + network: relayer.network, + paused: relayer.paused, + network_type: relayer.network_type, + signer_id: relayer.signer_id, + policies: relayer.policies.unwrap_or_else(|| { + // Default policy based on network type + match relayer.network_type { + RelayerNetworkType::Evm => { + RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()) } - }); - - RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: match solana_policy.fee_payment_strategy { - Some(ConfigFileRelayerSolanaFeePaymentStrategy::User) => { - SolanaFeePaymentStrategy::User - } - Some(ConfigFileRelayerSolanaFeePaymentStrategy::Relayer) => { - SolanaFeePaymentStrategy::Relayer - } - None => SolanaFeePaymentStrategy::User, - }, - fee_margin_percentage: solana_policy.fee_margin_percentage, - min_balance: solana_policy - .min_balance - .unwrap_or(DEFAULT_SOLANA_MIN_BALANCE), - allowed_tokens, - allowed_programs: solana_policy.allowed_programs, - allowed_accounts: solana_policy.allowed_accounts, - disallowed_accounts: solana_policy.disallowed_accounts, - max_signatures: solana_policy.max_signatures, - max_tx_data_size: solana_policy - .max_tx_data_size - .unwrap_or(MAX_SOLANA_TX_DATA_SIZE), - max_allowed_fee_lamports: solana_policy.max_allowed_fee_lamports, - swap_config, - }) - } - Some(ConfigFileRelayerNetworkPolicy::Stellar(stellar_policy)) => { - RelayerNetworkPolicy::Stellar(RelayerStellarPolicy { - max_fee: stellar_policy.max_fee, - timeout_seconds: stellar_policy.timeout_seconds, - min_balance: stellar_policy - .min_balance - .unwrap_or(DEFAULT_STELLAR_MIN_BALANCE), - }) - } - None => { - // Use default policy based on network type - match network_type { - NetworkType::Evm => RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()), - NetworkType::Solana => { + RelayerNetworkType::Solana => { RelayerNetworkPolicy::Solana(RelayerSolanaPolicy::default()) } - NetworkType::Stellar => { + RelayerNetworkType::Stellar => { RelayerNetworkPolicy::Stellar(RelayerStellarPolicy::default()) } } - } - }; - - Ok(Self { - id: config.id, - name: config.name, - network: config.network, - paused: config.paused, - network_type, - signer_id: config.signer_id, - policies, + }), address: "".to_string(), // Will be filled in later by process_relayers - notification_id: config.notification_id, + notification_id: relayer.notification_id, system_disabled: false, - custom_rpc_urls: config.custom_rpc_urls, - }) + custom_rpc_urls: relayer.custom_rpc_urls, + } } } #[cfg(test)] mod tests { + use crate::models::RelayerEvmPolicy; + use super::*; fn create_test_relayer(paused: bool, system_disabled: bool) -> RelayerRepoModel { @@ -481,18 +100,16 @@ mod tests { system_disabled, network: "test_network".to_string(), network_type: NetworkType::Evm, - policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()), signer_id: "test_signer".to_string(), - address: "0x".to_string(), + policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()), + address: "0xtest".to_string(), notification_id: None, - custom_rpc_urls: Some(vec![RpcConfig::new( - "https://test-rpc.example.com".to_string(), - )]), + custom_rpc_urls: None, } } #[test] - fn test_validate_active_state_active() { + fn test_validate_active_state_success() { let relayer = create_test_relayer(false, false); assert!(relayer.validate_active_state().is_ok()); } @@ -501,20 +118,15 @@ mod tests { fn test_validate_active_state_paused() { let relayer = create_test_relayer(true, false); let result = relayer.validate_active_state(); - assert!(matches!(result, Err(RelayerError::RelayerPaused))); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RelayerError::RelayerPaused)); } #[test] fn test_validate_active_state_disabled() { let relayer = create_test_relayer(false, true); let result = relayer.validate_active_state(); - assert!(matches!(result, Err(RelayerError::RelayerDisabled))); - } - - #[test] - fn test_validate_active_state_paused_and_disabled() { - let relayer = create_test_relayer(true, true); - let result = relayer.validate_active_state(); - assert!(matches!(result, Err(RelayerError::RelayerPaused))); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RelayerError::RelayerDisabled)); } } diff --git a/src/models/relayer/request.rs b/src/models/relayer/request.rs new file mode 100644 index 000000000..ccca3bb50 --- /dev/null +++ b/src/models/relayer/request.rs @@ -0,0 +1,146 @@ +//! Request models for relayer API endpoints. +//! +//! This module provides request structures used by relayer CRUD API endpoints, +//! including: +//! +//! - **Create Requests**: New relayer creation +//! - **Update Requests**: Partial relayer updates +//! - **Validation**: Input validation and error handling +//! - **Conversions**: Mapping between API requests and domain models +//! +//! These models handle API-specific concerns like optional fields for updates +//! while delegating business logic validation to the domain model. + +use super::{RelayerNetworkPolicy, RelayerNetworkType, RpcConfig}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Request model for creating a new relayer +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateRelayerRequest { + pub id: String, + pub name: String, + pub network: String, + pub paused: bool, + pub network_type: RelayerNetworkType, + pub policies: Option, + pub signer_id: String, + pub notification_id: Option, + pub custom_rpc_urls: Option>, +} + +/// Request model for updating an existing relayer +/// All fields are optional to allow partial updates +/// Note: network and signer_id are not updateable after creation +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateRelayerRequest { + pub name: Option, + pub paused: Option, + pub network_type: Option, + pub policies: Option, + pub notification_id: Option, + pub custom_rpc_urls: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::relayer::{Relayer, RelayerEvmPolicy, RelayerNetworkType}; + + #[test] + fn test_valid_create_request() { + let request = CreateRelayerRequest { + id: "test-relayer".to_string(), + name: "Test Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Evm, + policies: Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + gas_price_cap: Some(100), + whitelist_receivers: None, + eip1559_pricing: Some(true), + private_transactions: None, + min_balance: None, + gas_limit_estimation: None, + })), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + // Convert to domain model and validate there + let domain_relayer = Relayer::new( + request.id, + request.name, + request.network, + request.paused, + request.network_type, + request.policies, + request.signer_id, + request.notification_id, + request.custom_rpc_urls, + ); + assert!(domain_relayer.validate().is_ok()); + } + + #[test] + fn test_invalid_create_request_empty_id() { + let request = CreateRelayerRequest { + id: "".to_string(), + name: "Test Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Evm, + policies: None, + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + // Convert to domain model and validate there - should fail due to empty ID + let domain_relayer = Relayer::new( + request.id, + request.name, + request.network, + request.paused, + request.network_type, + request.policies, + request.signer_id, + request.notification_id, + request.custom_rpc_urls, + ); + assert!(domain_relayer.validate().is_err()); + } + + #[test] + fn test_valid_update_request() { + let request = UpdateRelayerRequest { + name: Some("Updated Name".to_string()), + paused: Some(true), + network_type: None, + policies: None, + notification_id: Some("new-notification".to_string()), + custom_rpc_urls: None, + }; + + // Should serialize/deserialize without errors + let serialized = serde_json::to_string(&request).unwrap(); + let _deserialized: UpdateRelayerRequest = serde_json::from_str(&serialized).unwrap(); + } + + #[test] + fn test_update_request_all_none() { + let request = UpdateRelayerRequest { + name: None, + paused: None, + network_type: None, + policies: None, + notification_id: None, + custom_rpc_urls: None, + }; + + // Should serialize/deserialize without errors - all fields are optional + let serialized = serde_json::to_string(&request).unwrap(); + let _deserialized: UpdateRelayerRequest = serde_json::from_str(&serialized).unwrap(); + } +} diff --git a/src/models/relayer/response.rs b/src/models/relayer/response.rs index e7096ea28..0f46fce0f 100644 --- a/src/models/relayer/response.rs +++ b/src/models/relayer/response.rs @@ -1,12 +1,25 @@ -use crate::models::NetworkType; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +//! Response models for relayer API endpoints. +//! +//! This module provides response structures used by relayer API endpoints, +//! including: +//! +//! - **Response Models**: Structures returned by API endpoints +//! - **Status Models**: Relayer status and runtime information +//! - **Conversions**: Mapping from domain and repository models to API responses +//! - **API Compatibility**: Maintaining backward compatibility with existing API contracts +//! +//! These models handle API-specific formatting and serialization while working +//! with the domain model for business logic. use super::{ - RelayerNetworkPolicy, RelayerRepoModel, RelayerSolanaSwapConfig, SolanaAllowedTokensPolicy, - SolanaFeePaymentStrategy, + AllowedToken, Relayer, RelayerEvmPolicy, RelayerNetworkPolicy, RelayerNetworkType, + RelayerRepoModel, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, + RelayerSolanaSwapPolicy, RelayerStellarPolicy, RpcConfig, }; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +/// Response for delete pending transactions operation #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] pub struct DeletePendingTransactionsResponse { pub queued_for_cancellation_transaction_ids: Vec, @@ -14,19 +27,24 @@ pub struct DeletePendingTransactionsResponse { pub total_processed: u32, } +/// Relayer response model for API endpoints #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] pub struct RelayerResponse { pub id: String, pub name: String, pub network: String, - #[serde(rename = "network_type")] - pub network_type: NetworkType, + pub network_type: RelayerNetworkType, pub paused: bool, - pub policies: NetworkPolicyResponse, - pub address: String, - pub system_disabled: bool, + pub policies: Option, + pub signer_id: String, + pub notification_id: Option, + pub custom_rpc_urls: Option>, + // Runtime fields from repository model + pub address: Option, + pub system_disabled: Option, } +/// Relayer status with runtime information #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] #[serde(tag = "network_type")] pub enum RelayerStatus { @@ -48,281 +66,206 @@ pub enum RelayerStatus { paused: bool, sequence_number: String, }, + #[serde(rename = "solana")] + Solana { + balance: String, + pending_transactions_count: u64, + last_confirmed_transaction_timestamp: Option, + system_disabled: bool, + paused: bool, + }, } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -#[serde(untagged)] -pub enum NetworkPolicyResponse { - Evm(EvmPolicyResponse), - Solana(SolanaPolicyResponse), - Stellar(StellarPolicyResponse), -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -pub struct EvmPolicyResponse { - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub gas_price_cap: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub whitelist_receivers: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub eip1559_pricing: Option, - pub private_transactions: bool, - pub min_balance: u128, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -pub struct SolanaPolicyResponse { - fee_payment_strategy: SolanaFeePaymentStrategy, - #[serde(skip_serializing_if = "Option::is_none")] - pub fee_margin_percentage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub allowed_tokens: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub allowed_programs: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub allowed_accounts: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub disallowed_accounts: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub max_signatures: Option, - pub max_tx_data_size: u16, - pub min_balance: u64, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub max_allowed_fee_lamports: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub swap_config: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -pub struct StellarPolicyResponse { - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub max_fee: Option, - pub min_balance: u64, +impl From for RelayerResponse { + fn from(relayer: Relayer) -> Self { + Self { + id: relayer.id, + name: relayer.name, + network: relayer.network, + network_type: relayer.network_type, + paused: relayer.paused, + policies: relayer.policies, + signer_id: relayer.signer_id, + notification_id: relayer.notification_id, + custom_rpc_urls: relayer.custom_rpc_urls, + address: None, + system_disabled: None, + } + } } impl From for RelayerResponse { fn from(model: RelayerRepoModel) -> Self { - let policies = match model.policies { - RelayerNetworkPolicy::Evm(evm) => NetworkPolicyResponse::Evm(EvmPolicyResponse { - gas_price_cap: evm.gas_price_cap, - whitelist_receivers: evm.whitelist_receivers, - eip1559_pricing: evm.eip1559_pricing, - min_balance: evm.min_balance, - private_transactions: evm.private_transactions, - }), - RelayerNetworkPolicy::Solana(solana) => { - NetworkPolicyResponse::Solana(SolanaPolicyResponse { - fee_payment_strategy: solana.fee_payment_strategy, - fee_margin_percentage: solana.fee_margin_percentage, - min_balance: solana.min_balance, - allowed_tokens: solana.allowed_tokens, - allowed_programs: solana.allowed_programs, - allowed_accounts: solana.allowed_accounts, - disallowed_accounts: solana.disallowed_accounts, - max_signatures: solana.max_signatures, - max_tx_data_size: solana.max_tx_data_size, - max_allowed_fee_lamports: solana.max_allowed_fee_lamports, - swap_config: solana.swap_config, - }) - } - RelayerNetworkPolicy::Stellar(stellar) => { - NetworkPolicyResponse::Stellar(StellarPolicyResponse { - max_fee: stellar.max_fee, - min_balance: stellar.min_balance, - }) - } - }; - Self { id: model.id, name: model.name, network: model.network, - network_type: model.network_type, + network_type: model.network_type.into(), paused: model.paused, - policies, - address: model.address, - system_disabled: model.system_disabled, + policies: Some(model.policies.into()), + signer_id: model.signer_id, + notification_id: model.notification_id, + custom_rpc_urls: model.custom_rpc_urls, + address: Some(model.address), + system_disabled: Some(model.system_disabled), } } } #[cfg(test)] mod tests { - use crate::models::{ - RelayerEvmPolicy, RelayerSolanaPolicy, RelayerStellarPolicy, SolanaAllowedTokensSwapConfig, - SolanaFeePaymentStrategy, - }; - use super::*; + use crate::models::relayer::RelayerEvmPolicy; #[test] - fn test_from_relayer_repo_model_evm() { - let model = RelayerRepoModel { - id: "test-id".to_string(), - name: "Test Relayer".to_string(), - network: "ethereum".to_string(), - network_type: NetworkType::Evm, - paused: false, - policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy { - gas_price_cap: Some(100), - whitelist_receivers: Some(vec!["0x123".to_string()]), + fn test_from_domain_relayer() { + let relayer = Relayer::new( + "test-relayer".to_string(), + "Test Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Evm, + Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + gas_price_cap: Some(100_000_000_000), + whitelist_receivers: None, eip1559_pricing: Some(true), - min_balance: 1000, - private_transactions: true, - gas_limit_estimation: Some(true), - }), - address: "0xabc".to_string(), - system_disabled: false, - signer_id: "test-signer-id".to_string(), - notification_id: Some("test-notification-id".to_string()), - custom_rpc_urls: None, - }; - - let response: RelayerResponse = model.clone().into(); + private_transactions: None, + min_balance: None, + gas_limit_estimation: None, + })), + "test-signer".to_string(), + None, + None, + ); - assert_eq!(response.id, model.id); - assert_eq!(response.name, model.name); - assert_eq!(response.network, model.network); - assert_eq!(response.network_type, model.network_type); - assert_eq!(response.paused, model.paused); - assert_eq!(response.address, model.address); - assert_eq!(response.system_disabled, model.system_disabled); + let response: RelayerResponse = relayer.clone().into(); - if let NetworkPolicyResponse::Evm(evm) = response.policies { - if let RelayerNetworkPolicy::Evm(expected) = model.policies { - assert_eq!(evm.gas_price_cap, expected.gas_price_cap); - assert_eq!(evm.whitelist_receivers, expected.whitelist_receivers); - assert_eq!(evm.eip1559_pricing, expected.eip1559_pricing); - assert_eq!(evm.min_balance, expected.min_balance); - assert_eq!(evm.private_transactions, expected.private_transactions); - } else { - panic!("Expected EVM policy"); - } - } else { - panic!("Expected EVM policy response"); - } + assert_eq!(response.id, relayer.id); + assert_eq!(response.name, relayer.name); + assert_eq!(response.network, relayer.network); + assert_eq!(response.network_type, relayer.network_type); + assert_eq!(response.paused, relayer.paused); + assert_eq!(response.policies, relayer.policies); + assert_eq!(response.signer_id, relayer.signer_id); + assert_eq!(response.notification_id, relayer.notification_id); + assert_eq!(response.custom_rpc_urls, relayer.custom_rpc_urls); + assert_eq!(response.address, None); + assert_eq!(response.system_disabled, None); } #[test] - fn test_from_relayer_repo_model_solana() { - let model = RelayerRepoModel { - id: "test-id".to_string(), + fn test_response_serialization() { + let response = RelayerResponse { + id: "test-relayer".to_string(), name: "Test Relayer".to_string(), - network: "solana".to_string(), - network_type: NetworkType::Solana, - paused: true, - policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: SolanaFeePaymentStrategy::User, - fee_margin_percentage: Some(0.5), - min_balance: 5000, - allowed_tokens: Some(vec![SolanaAllowedTokensPolicy { - mint: "mint-address".to_string(), - decimals: Some(9), - symbol: Some("SOL".to_string()), - max_allowed_fee: Some(1000), - swap_config: Some(SolanaAllowedTokensSwapConfig { - slippage_percentage: Some(100.0), - max_amount: None, - min_amount: None, - retain_min_amount: None, - }), - }]), - allowed_programs: Some(vec!["program1".to_string()]), - allowed_accounts: Some(vec!["account1".to_string()]), - disallowed_accounts: Some(vec!["bad-account".to_string()]), - max_signatures: Some(10), - max_tx_data_size: 1024, - max_allowed_fee_lamports: Some(10000), - swap_config: None, - }), - address: "solana-address".to_string(), - system_disabled: false, - signer_id: "test-signer-id".to_string(), - notification_id: Some("test-notification-id".to_string()), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Evm, + paused: false, + policies: Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + gas_price_cap: Some(100_000_000_000), + whitelist_receivers: None, + eip1559_pricing: Some(true), + private_transactions: None, + min_balance: None, + gas_limit_estimation: None, + })), + signer_id: "test-signer".to_string(), + notification_id: None, custom_rpc_urls: None, + address: Some("0x123...".to_string()), + system_disabled: Some(false), }; - let response: RelayerResponse = model.clone().into(); + // Should serialize without errors + let serialized = serde_json::to_string(&response).unwrap(); + assert!(!serialized.is_empty()); - assert_eq!(response.id, model.id); - assert_eq!(response.name, model.name); - assert_eq!(response.network, model.network); - assert_eq!(response.network_type, model.network_type); - assert_eq!(response.paused, model.paused); - assert_eq!(response.address, model.address); - assert_eq!(response.system_disabled, model.system_disabled); - - if let NetworkPolicyResponse::Solana(solana) = response.policies { - if let RelayerNetworkPolicy::Solana(expected) = model.policies { - assert_eq!(solana.min_balance, expected.min_balance); - assert_eq!(solana.allowed_tokens, expected.allowed_tokens); - assert_eq!(solana.allowed_programs, expected.allowed_programs); - assert_eq!(solana.allowed_accounts, expected.allowed_accounts); - assert_eq!(solana.disallowed_accounts, expected.disallowed_accounts); - assert_eq!(solana.max_signatures, expected.max_signatures); - assert_eq!(solana.max_tx_data_size, expected.max_tx_data_size); - assert_eq!( - solana.max_allowed_fee_lamports, - expected.max_allowed_fee_lamports - ); - } else { - panic!("Expected Solana policy"); - } - } else { - panic!("Expected Solana policy response"); - } + // Should deserialize back to the same struct + let deserialized: RelayerResponse = serde_json::from_str(&serialized).unwrap(); + assert_eq!(response.id, deserialized.id); + assert_eq!(response.name, deserialized.name); } +} - #[test] - fn test_from_relayer_repo_model_stellar() { - let model = RelayerRepoModel { - id: "test-id".to_string(), - name: "Test Relayer".to_string(), - network: "stellar".to_string(), - network_type: NetworkType::Stellar, - paused: false, - policies: RelayerNetworkPolicy::Stellar(RelayerStellarPolicy { - max_fee: Some(200), - min_balance: 2000, - timeout_seconds: Some(100), - }), - address: "stellar-address".to_string(), - system_disabled: true, - signer_id: "test-signer-id".to_string(), - notification_id: Some("test-notification-id".to_string()), - custom_rpc_urls: None, - }; +/// Network policy response models for OpenAPI documentation +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +pub struct NetworkPolicyResponse { + #[serde(flatten)] + pub policy: RelayerNetworkPolicy, +} - let response: RelayerResponse = model.clone().into(); +/// EVM policy response model for OpenAPI documentation +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +pub struct EvmPolicyResponse { + pub min_balance: Option, + pub gas_limit_estimation: Option, + pub gas_price_cap: Option, + pub whitelist_receivers: Option>, + pub eip1559_pricing: Option, + pub private_transactions: bool, +} - assert_eq!(response.id, model.id); - assert_eq!(response.name, model.name); - assert_eq!(response.network, model.network); - assert_eq!(response.network_type, model.network_type); - assert_eq!(response.paused, model.paused); - assert_eq!(response.address, model.address); - assert_eq!(response.system_disabled, model.system_disabled); +/// Solana policy response model for OpenAPI documentation +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +pub struct SolanaPolicyResponse { + pub allowed_programs: Option>, + pub max_signatures: Option, + pub max_tx_data_size: Option, + pub min_balance: Option, + pub allowed_tokens: Option>, + pub fee_payment_strategy: Option, + pub fee_margin_percentage: Option, + pub allowed_accounts: Option>, + pub disallowed_accounts: Option>, + pub max_allowed_fee_lamports: Option, + pub swap_config: Option, +} + +/// Stellar policy response model for OpenAPI documentation +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +pub struct StellarPolicyResponse { + pub max_fee: Option, + pub timeout_seconds: Option, + pub min_balance: Option, +} - if let NetworkPolicyResponse::Stellar(stellar) = response.policies { - if let RelayerNetworkPolicy::Stellar(expected) = model.policies { - assert_eq!(stellar.max_fee, expected.max_fee); - assert_eq!(stellar.min_balance, expected.min_balance); - } else { - panic!("Expected Stellar policy"); - } - } else { - panic!("Expected Stellar policy response"); +impl From for EvmPolicyResponse { + fn from(policy: RelayerEvmPolicy) -> Self { + Self { + min_balance: policy.min_balance, + gas_limit_estimation: policy.gas_limit_estimation, + gas_price_cap: policy.gas_price_cap, + whitelist_receivers: policy.whitelist_receivers, + eip1559_pricing: policy.eip1559_pricing, + private_transactions: policy.private_transactions.unwrap_or(false), + } + } +} + +impl From for SolanaPolicyResponse { + fn from(policy: RelayerSolanaPolicy) -> Self { + Self { + allowed_programs: policy.allowed_programs, + max_signatures: policy.max_signatures, + max_tx_data_size: policy.max_tx_data_size, + min_balance: policy.min_balance, + allowed_tokens: policy.allowed_tokens, + fee_payment_strategy: policy.fee_payment_strategy, + fee_margin_percentage: policy.fee_margin_percentage, + allowed_accounts: policy.allowed_accounts, + disallowed_accounts: policy.disallowed_accounts, + max_allowed_fee_lamports: policy.max_allowed_fee_lamports, + swap_config: policy.swap_config, + } + } +} + +impl From for StellarPolicyResponse { + fn from(policy: RelayerStellarPolicy) -> Self { + Self { + min_balance: policy.min_balance, + max_fee: policy.max_fee, + timeout_seconds: policy.timeout_seconds, } } } diff --git a/src/models/transaction/repository.rs b/src/models/transaction/repository.rs index c4d5ea6b8..c1b33573b 100644 --- a/src/models/transaction/repository.rs +++ b/src/models/transaction/repository.rs @@ -1863,7 +1863,7 @@ mod tests { policies: RelayerNetworkPolicy::Stellar(RelayerStellarPolicy { max_fee: None, timeout_seconds: None, - min_balance: DEFAULT_STELLAR_MIN_BALANCE, + min_balance: Some(DEFAULT_STELLAR_MIN_BALANCE), }), address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(), notification_id: None, diff --git a/src/models/transaction/request/evm.rs b/src/models/transaction/request/evm.rs index 619b37eaf..f587f30a7 100644 --- a/src/models/transaction/request/evm.rs +++ b/src/models/transaction/request/evm.rs @@ -175,7 +175,7 @@ pub fn validate_price_params( if is_legacy { if let RelayerNetworkPolicy::Evm(evm_policy) = &relayer.policies { if let Some(gas_price_cap) = evm_policy.gas_price_cap { - if request.gas_price.unwrap_or(0) > gas_price_cap { + if request.gas_price.unwrap_or(0) > gas_price_cap as u128 { return Err(ApiError::BadRequest("Gas price is too high".to_string())); } } @@ -187,7 +187,7 @@ pub fn validate_price_params( #[cfg(test)] mod tests { - use crate::models::{NetworkType, RelayerEvmPolicy, RpcConfig}; + use crate::models::{NetworkType, RelayerEvmPolicy, RelayerNetworkPolicy, RpcConfig}; use super::*; use chrono::{Duration, Utc}; diff --git a/src/repositories/relayer/mod.rs b/src/repositories/relayer/mod.rs index 109bed663..5d47f7f60 100644 --- a/src/repositories/relayer/mod.rs +++ b/src/repositories/relayer/mod.rs @@ -254,12 +254,12 @@ mod tests { paused: false, network_type: NetworkType::Evm, policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + min_balance: Some(0), + gas_limit_estimation: Some(true), gas_price_cap: None, whitelist_receivers: None, eip1559_pricing: Some(false), - private_transactions: false, - min_balance: 0, - gas_limit_estimation: Some(true), + private_transactions: Some(false), }), signer_id: "test".to_string(), address: "0x".to_string(), @@ -337,12 +337,12 @@ mod tests { // Test update_policy let new_policy = RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + min_balance: Some(1000000000000000000), + gas_limit_estimation: Some(true), gas_price_cap: Some(50_000_000_000), whitelist_receivers: None, eip1559_pricing: Some(true), - private_transactions: false, - min_balance: 1000000000000000000, - gas_limit_estimation: Some(true), + private_transactions: Some(false), }); let policy_updated = impl_repo .update_policy(relayer.id.clone(), new_policy) diff --git a/src/repositories/relayer/relayer_in_memory.rs b/src/repositories/relayer/relayer_in_memory.rs index b652cea62..2783cadff 100644 --- a/src/repositories/relayer/relayer_in_memory.rs +++ b/src/repositories/relayer/relayer_in_memory.rs @@ -127,7 +127,7 @@ impl RelayerRepository for InMemoryRelayerRepository { let relayer = store.get_mut(&id).ok_or_else(|| { RepositoryError::NotFound(format!("Relayer with ID {} not found", id)) })?; - relayer.policies = policy; + relayer.policies = policy.into(); Ok(relayer.clone()) } @@ -282,8 +282,8 @@ mod tests { gas_price_cap: None, whitelist_receivers: None, eip1559_pricing: Some(false), - private_transactions: false, - min_balance: 0, + private_transactions: Some(false), + min_balance: Some(0), gas_limit_estimation: Some(true), }), signer_id: "test".to_string(), @@ -445,8 +445,8 @@ mod tests { gas_price_cap: Some(50000000000), whitelist_receivers: Some(vec!["0x1234".to_string()]), eip1559_pricing: Some(true), - private_transactions: true, - min_balance: 1000000, + private_transactions: Some(true), + min_balance: Some(1000000), gas_limit_estimation: Some(true), }); @@ -462,8 +462,8 @@ mod tests { assert_eq!(policy.gas_price_cap, Some(50000000000)); assert_eq!(policy.whitelist_receivers, Some(vec!["0x1234".to_string()])); assert_eq!(policy.eip1559_pricing, Some(true)); - assert!(policy.private_transactions); - assert_eq!(policy.min_balance, 1000000); + assert!(policy.private_transactions.unwrap_or(false)); + assert_eq!(policy.min_balance, Some(1000000)); } _ => panic!("Unexpected policy type"), } diff --git a/src/repositories/relayer/relayer_redis.rs b/src/repositories/relayer/relayer_redis.rs index c69652ebf..2fde5e471 100644 --- a/src/repositories/relayer/relayer_redis.rs +++ b/src/repositories/relayer/relayer_redis.rs @@ -533,7 +533,7 @@ impl RelayerRepository for RedisRelayerRepository { let mut relayer = self.get_by_id(id.clone()).await?; // Update the policy - relayer.policies = policy; + relayer.policies = policy.into(); // Update the relayer self.update(id, relayer).await @@ -849,8 +849,8 @@ mod tests { gas_price_cap: Some(50_000_000_000), whitelist_receivers: Some(vec!["0x123".to_string()]), eip1559_pricing: Some(true), - private_transactions: true, - min_balance: 1000000000000000000, + private_transactions: Some(true), + min_balance: Some(1000000000000000000), gas_limit_estimation: Some(true), }); @@ -865,8 +865,8 @@ mod tests { Some(vec!["0x123".to_string()]) ); assert_eq!(evm_policy.eip1559_pricing, Some(true)); - assert!(evm_policy.private_transactions); - assert_eq!(evm_policy.min_balance, 1000000000000000000); + assert!(evm_policy.private_transactions.unwrap_or(false)); + assert_eq!(evm_policy.min_balance, Some(1000000000000000000)); } else { panic!("Expected EVM policy"); } diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index 9e9535ed9..a8a997b4e 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -33,8 +33,8 @@ pub mod mockutils { gas_price_cap: None, whitelist_receivers: None, eip1559_pricing: Some(false), - private_transactions: false, - min_balance: 0, + private_transactions: Some(false), + min_balance: Some(0), gas_limit_estimation: Some(false), }), signer_id: "test".to_string(), From 7efc6252e43e83b80c7222bfb89a76d3bfca6a15 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 22 Jul 2025 10:06:00 +0200 Subject: [PATCH 27/59] chore: improvements --- src/api/controllers/relayer.rs | 155 +++++++++++++++++- src/api/routes/docs/relayer_docs.rs | 130 ++++++++++++++- src/api/routes/relayer.rs | 22 ++- src/models/notification/request.rs | 2 + src/models/relayer/mod.rs | 2 + src/models/relayer/request.rs | 64 ++++---- src/models/signer/request.rs | 9 + src/openapi.rs | 5 +- .../notification/notification_in_memory.rs | 6 +- .../notification/notification_redis.rs | 8 +- src/utils/mod.rs | 3 + src/utils/uuid.rs | 20 +++ 12 files changed, 381 insertions(+), 45 deletions(-) create mode 100644 src/utils/uuid.rs diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 69e8f74b5..4748fa4d7 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -3,6 +3,9 @@ //! Handles HTTP endpoints for relayer operations including: //! - Listing relayers //! - Getting relayer details +//! - Creating relayers +//! - Updating relayers +//! - Deleting relayers //! - Submitting transactions //! - Signing messages //! - JSON-RPC proxy @@ -10,14 +13,16 @@ use crate::{ domain::{ get_network_relayer, get_network_relayer_by_model, get_relayer_by_id, get_relayer_transaction_by_model, get_transaction_by_id as get_tx_by_id, Relayer, - RelayerUpdateRequest, SignDataRequest, SignDataResponse, SignTypedDataRequest, Transaction, + RelayerFactory, RelayerFactoryTrait, RelayerUpdateRequest, SignDataRequest, + SignDataResponse, SignTypedDataRequest, Transaction, }, models::{ - convert_to_internal_rpc_request, ApiError, ApiResponse, DefaultAppState, - NetworkTransactionRequest, NetworkType, PaginationMeta, PaginationQuery, RelayerResponse, - TransactionResponse, + convert_to_internal_rpc_request, ApiError, ApiResponse, CreateRelayerRequest, + DefaultAppState, NetworkTransactionRequest, NetworkType, PaginationMeta, PaginationQuery, + RelayerRepoModel, RelayerResponse, TransactionResponse, }, - repositories::{RelayerRepository, Repository, TransactionRepository}, + repositories::{NetworkRepository, RelayerRepository, Repository, TransactionRepository}, + services::{Signer, SignerFactory}, }; use actix_web::{web, HttpResponse}; use eyre::Result; @@ -72,6 +77,97 @@ pub async fn get_relayer( Ok(HttpResponse::Ok().json(ApiResponse::success(relayer_response))) } +/// Creates a new relayer. +/// +/// # Arguments +/// +/// * `request` - The relayer creation request. +/// * `state` - The application state containing the relayer repository. +/// +/// # Returns +/// +/// The created relayer or an error if creation fails. +/// +/// # Validation +/// +/// This endpoint performs comprehensive dependency validation before creating the relayer: +/// - **Signer Validation**: Ensures the specified signer exists in the system +/// - **Signer Uniqueness**: Validates that the signer is not already in use by another relayer on the same network +/// - **Notification Validation**: If a notification ID is provided, validates it exists +/// - **Network Validation**: Confirms the specified network exists for the given network type +/// +/// All validations must pass before the relayer is created, ensuring referential integrity and security constraints. +pub async fn create_relayer( + request: CreateRelayerRequest, + state: web::ThinData, +) -> Result { + // Convert request to domain relayer (validates automatically) + let relayer = crate::models::Relayer::try_from(request)?; + + // Validate dependencies before creating the relayer + let signer_model = state + .signer_repository + .get_by_id(relayer.signer_id.clone()) + .await?; + + // Check if network exists for the given network type + let network = state + .network_repository + .get_by_name(relayer.network_type.into(), &relayer.network) + .await?; + + if network.is_none() { + return Err(ApiError::BadRequest(format!( + "Network '{}' not found for network type '{}'. Please ensure the network configuration exists.", + relayer.network, + relayer.network_type + ))); + } + + // check if signer is already in use by another relayer on the same network + let relayers = state + .relayer_repository + .list_by_signer_id(&relayer.signer_id) + .await?; + if let Some(existing_relayer) = relayers.iter().find(|r| r.network == relayer.network) { + return Err(ApiError::BadRequest(format!( + "Cannot create relayer: signer '{}' is already in use by relayer '{}' on network '{}'. Each signer can only be connected to one relayer per network for security reasons. Please use a different signer or create the relayer on a different network.", + relayer.signer_id, existing_relayer.id, relayer.network + ))); + } + + // Check if notification exists (if provided) + if let Some(notification_id) = &relayer.notification_id { + let _notification = state + .notification_repository + .get_by_id(notification_id.clone()) + .await?; + } + + // Convert domain model to repository model + let mut relayer_model = RelayerRepoModel::from(relayer); + + // get address from signer and set it to relayer model + let signer_service = SignerFactory::create_signer(&relayer_model.network_type, &signer_model) + .await + .map_err(|e| ApiError::InternalError(e.to_string()))?; + let address = signer_service + .address() + .await + .map_err(|e| ApiError::InternalError(e.to_string()))?; + relayer_model.address = address.to_string(); + + let created_relayer = state.relayer_repository.create(relayer_model).await?; + + let relayer = + RelayerFactory::create_relayer(created_relayer.clone(), signer_model, &state).await?; + + relayer.initialize_relayer().await?; + + let response = RelayerResponse::from(created_relayer); + Ok(HttpResponse::Created().json(ApiResponse::success(response))) +} + /// Updates a relayer's information. /// /// # Arguments @@ -109,6 +205,55 @@ pub async fn update_relayer( Ok(HttpResponse::Ok().json(ApiResponse::success(relayer_response))) } +/// Deletes a relayer by ID. +/// +/// # Arguments +/// +/// * `relayer_id` - The ID of the relayer to delete. +/// * `state` - The application state containing the relayer repository. +/// +/// # Returns +/// +/// A success response or an error if deletion fails. +/// +/// # Security +/// +/// This endpoint ensures that relayers cannot be deleted if they have any pending +/// or active transactions. This prevents data loss and maintains system integrity. +pub async fn delete_relayer( + relayer_id: String, + state: web::ThinData, +) -> Result { + // First check if the relayer exists + let _relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; + + // Check if the relayer has any transactions (pending or otherwise) + use crate::models::PaginationQuery; + let transactions = state + .transaction_repository + .find_by_relayer_id( + &relayer_id, + PaginationQuery { + page: 1, + per_page: 1, + }, + ) + .await?; + + if transactions.total > 0 { + return Err(ApiError::BadRequest(format!( + "Cannot delete relayer '{}' because it has {} transaction(s). Please wait for all transactions to complete or cancel them before deleting the relayer.", + relayer_id, + transactions.total + ))); + } + + // Safe to delete - no transactions associated with this relayer + state.relayer_repository.delete_by_id(relayer_id).await?; + + Ok(HttpResponse::Ok().json(ApiResponse::success("Relayer deleted successfully"))) +} + /// Retrieves the status of a specific relayer. /// /// # Arguments diff --git a/src/api/routes/docs/relayer_docs.rs b/src/api/routes/docs/relayer_docs.rs index b77438b62..409d3c4f7 100644 --- a/src/api/routes/docs/relayer_docs.rs +++ b/src/api/routes/docs/relayer_docs.rs @@ -4,9 +4,9 @@ use crate::{ SignTypedDataRequest, }, models::{ - ApiResponse, DeletePendingTransactionsResponse, JsonRpcRequest, JsonRpcResponse, - NetworkRpcRequest, NetworkRpcResult, NetworkTransactionRequest, RelayerResponse, - RelayerStatus, TransactionResponse, + ApiResponse, CreateRelayerRequest, DeletePendingTransactionsResponse, JsonRpcRequest, + JsonRpcResponse, NetworkRpcRequest, NetworkRpcResult, NetworkTransactionRequest, + RelayerResponse, RelayerStatus, TransactionResponse, }, }; /// Relayer routes implementation @@ -150,6 +150,67 @@ fn doc_list_relayers() {} #[allow(dead_code)] fn doc_get_relayer() {} +/// Creates a new relayer. +#[utoipa::path( + post, + path = "/api/v1/relayers", + tag = "Relayers", + operation_id = "createRelayer", + security( + ("bearer_auth" = []) + ), + request_body = CreateRelayerRequest, + responses( + ( + status = 201, + description = "Relayer created successfully", + body = ApiResponse + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 409, + description = "Relayer with this ID already exists", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Relayer with this ID already exists", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_create_relayer() {} + /// Updates a relayer's information based on the provided update request. #[utoipa::path( patch, @@ -220,6 +281,69 @@ fn doc_get_relayer() {} #[allow(dead_code)] fn doc_update_relayer() {} +/// Deletes a relayer by ID. +#[utoipa::path( + delete, + path = "/api/v1/relayers/{relayer_id}", + tag = "Relayers", + operation_id = "deleteRelayer", + security( + ("bearer_auth" = []) + ), + params( + ("relayer_id" = String, Path, description = "The unique identifier of the relayer") + ), + responses( + ( + status = 200, + description = "Relayer deleted successfully", + body = ApiResponse + ), + ( + status = 400, + description = "Bad Request - Cannot delete relayer with active transactions", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Cannot delete relayer 'relayer_id' because it has N transaction(s). Please wait for all transactions to complete or cancel them before deleting the relayer.", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 404, + description = "Not Found", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Relayer with ID relayer_id not found", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_delete_relayer() {} + /// Fetches the current status of a specific relayer. #[utoipa::path( get, diff --git a/src/api/routes/relayer.rs b/src/api/routes/relayer.rs index f6f913c3f..08ad0fe1b 100644 --- a/src/api/routes/relayer.rs +++ b/src/api/routes/relayer.rs @@ -4,7 +4,7 @@ use crate::{ api::controllers::relayer, domain::{RelayerUpdateRequest, SignDataRequest, SignTypedDataRequest}, - models::{DefaultAppState, PaginationQuery}, + models::{CreateRelayerRequest, DefaultAppState, PaginationQuery}, }; use actix_web::{delete, get, patch, post, put, web, Responder}; use serde::Deserialize; @@ -28,6 +28,15 @@ async fn get_relayer( relayer::get_relayer(relayer_id.into_inner(), data).await } +/// Creates a new relayer. +#[post("/relayers")] +async fn create_relayer( + request: web::Json, + data: web::ThinData, +) -> impl Responder { + relayer::create_relayer(request.into_inner(), data).await +} + /// Updates a relayer's information based on the provided update request. #[patch("/relayers/{relayer_id}")] async fn update_relayer( @@ -38,6 +47,15 @@ async fn update_relayer( relayer::update_relayer(relayer_id.into_inner(), update_req.into_inner(), data).await } +/// Deletes a relayer by ID. +#[delete("/relayers/{relayer_id}")] +async fn delete_relayer( + relayer_id: web::Path, + data: web::ThinData, +) -> impl Responder { + relayer::delete_relayer(relayer_id.into_inner(), data).await +} + /// Fetches the current status of a specific relayer. #[get("/relayers/{relayer_id}/status")] async fn get_relayer_status( @@ -180,7 +198,9 @@ pub fn init(cfg: &mut web::ServiceConfig) { cfg.service(sign_typed_data); // /relayers/{id}/sign-typed-data cfg.service(rpc); // /relayers/{id}/rpc cfg.service(get_relayer); // /relayers/{id} + cfg.service(create_relayer); // /relayers cfg.service(update_relayer); // /relayers/{id} + cfg.service(delete_relayer); // /relayers/{id} cfg.service(list_relayers); // /relayers } diff --git a/src/models/notification/request.rs b/src/models/notification/request.rs index badf1eb38..11281899a 100644 --- a/src/models/notification/request.rs +++ b/src/models/notification/request.rs @@ -15,6 +15,7 @@ use utoipa::ToSchema; /// Request structure for creating a new notification #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[serde(deny_unknown_fields)] pub struct NotificationCreateRequest { pub id: Option, pub r#type: Option, @@ -25,6 +26,7 @@ pub struct NotificationCreateRequest { /// Request structure for updating an existing notification #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[serde(deny_unknown_fields)] pub struct NotificationUpdateRequest { pub r#type: Option, pub url: Option, diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index 864d0b919..cc93bc38c 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -84,6 +84,8 @@ impl From for ConfigFileNetworkType { /// EVM-specific relayer policy configuration #[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq)] pub struct RelayerEvmPolicy { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(deserialize_with = "deserialize_optional_u128", default)] pub min_balance: Option, #[serde(skip_serializing_if = "Option::is_none")] pub gas_limit_estimation: Option, diff --git a/src/models/relayer/request.rs b/src/models/relayer/request.rs index ccca3bb50..2c91a0222 100644 --- a/src/models/relayer/request.rs +++ b/src/models/relayer/request.rs @@ -11,14 +11,16 @@ //! These models handle API-specific concerns like optional fields for updates //! while delegating business logic validation to the domain model. -use super::{RelayerNetworkPolicy, RelayerNetworkType, RpcConfig}; +use super::{Relayer, RelayerNetworkPolicy, RelayerNetworkType, RpcConfig}; +use crate::{models::ApiError, utils::generate_uuid}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; /// Request model for creating a new relayer #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] pub struct CreateRelayerRequest { - pub id: String, + pub id: Option, pub name: String, pub network: String, pub paused: bool, @@ -33,6 +35,7 @@ pub struct CreateRelayerRequest { /// All fields are optional to allow partial updates /// Note: network and signer_id are not updateable after creation #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] pub struct UpdateRelayerRequest { pub name: Option, pub paused: Option, @@ -42,6 +45,31 @@ pub struct UpdateRelayerRequest { pub custom_rpc_urls: Option>, } +impl TryFrom for Relayer { + type Error = ApiError; + + fn try_from(request: CreateRelayerRequest) -> Result { + let id = request.id.unwrap_or_else(|| generate_uuid()); + // Create domain relayer + let relayer = Relayer::new( + id, + request.name, + request.network, + request.paused, + request.network_type, + request.policies, + request.signer_id, + request.notification_id, + request.custom_rpc_urls, + ); + + // Validate using domain model validation logic + relayer.validate().map_err(ApiError::from)?; + + Ok(relayer) + } +} + #[cfg(test)] mod tests { use super::*; @@ -50,7 +78,7 @@ mod tests { #[test] fn test_valid_create_request() { let request = CreateRelayerRequest { - id: "test-relayer".to_string(), + id: Some("test-relayer".to_string()), name: "Test Relayer".to_string(), network: "mainnet".to_string(), paused: false, @@ -69,24 +97,14 @@ mod tests { }; // Convert to domain model and validate there - let domain_relayer = Relayer::new( - request.id, - request.name, - request.network, - request.paused, - request.network_type, - request.policies, - request.signer_id, - request.notification_id, - request.custom_rpc_urls, - ); - assert!(domain_relayer.validate().is_ok()); + let domain_relayer = Relayer::try_from(request); + assert!(domain_relayer.is_ok()); } #[test] fn test_invalid_create_request_empty_id() { let request = CreateRelayerRequest { - id: "".to_string(), + id: Some("".to_string()), name: "Test Relayer".to_string(), network: "mainnet".to_string(), paused: false, @@ -98,18 +116,8 @@ mod tests { }; // Convert to domain model and validate there - should fail due to empty ID - let domain_relayer = Relayer::new( - request.id, - request.name, - request.network, - request.paused, - request.network_type, - request.policies, - request.signer_id, - request.notification_id, - request.custom_rpc_urls, - ); - assert!(domain_relayer.validate().is_err()); + let domain_relayer = Relayer::try_from(request); + assert!(domain_relayer.is_err()); } #[test] diff --git a/src/models/signer/request.rs b/src/models/signer/request.rs index aa9ea60d6..bd678961a 100644 --- a/src/models/signer/request.rs +++ b/src/models/signer/request.rs @@ -27,6 +27,7 @@ pub struct PlainSignerRequestConfig { /// AWS KMS signer configuration for API requests #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +#[serde(deny_unknown_fields)] pub struct AwsKmsSignerRequestConfig { pub region: String, pub key_id: String, @@ -34,6 +35,7 @@ pub struct AwsKmsSignerRequestConfig { /// Vault signer configuration for API requests #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +#[serde(deny_unknown_fields)] pub struct VaultSignerRequestConfig { pub address: String, pub namespace: Option, @@ -45,6 +47,7 @@ pub struct VaultSignerRequestConfig { /// Vault Transit signer configuration for API requests #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +#[serde(deny_unknown_fields)] pub struct VaultTransitSignerRequestConfig { pub key_name: String, pub address: String, @@ -57,6 +60,7 @@ pub struct VaultTransitSignerRequestConfig { /// Turnkey signer configuration for API requests #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +#[serde(deny_unknown_fields)] pub struct TurnkeySignerRequestConfig { pub api_public_key: String, pub api_private_key: String, @@ -67,6 +71,7 @@ pub struct TurnkeySignerRequestConfig { /// Google Cloud KMS service account configuration for API requests #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +#[serde(deny_unknown_fields)] pub struct GoogleCloudKmsSignerServiceAccountRequestConfig { pub private_key: String, pub private_key_id: String, @@ -82,6 +87,7 @@ pub struct GoogleCloudKmsSignerServiceAccountRequestConfig { /// Google Cloud KMS key configuration for API requests #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +#[serde(deny_unknown_fields)] pub struct GoogleCloudKmsSignerKeyRequestConfig { pub location: String, pub key_ring_id: String, @@ -91,6 +97,7 @@ pub struct GoogleCloudKmsSignerKeyRequestConfig { /// Google Cloud KMS signer configuration for API requests #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +#[serde(deny_unknown_fields)] pub struct GoogleCloudKmsSignerRequestConfig { pub service_account: GoogleCloudKmsSignerServiceAccountRequestConfig, pub key: GoogleCloudKmsSignerKeyRequestConfig, @@ -126,6 +133,7 @@ pub enum SignerConfigRequest { /// Request model for creating a new signer #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +#[serde(deny_unknown_fields)] pub struct SignerCreateRequest { /// Optional ID - if not provided, a UUID will be generated pub id: Option, @@ -137,6 +145,7 @@ pub struct SignerCreateRequest { /// Request model for updating an existing signer /// At the moment, we don't allow updating signers #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] +#[serde(deny_unknown_fields)] pub struct SignerUpdateRequest {} impl From for AwsKmsSignerConfig { diff --git a/src/openapi.rs b/src/openapi.rs index d385b00de..65ee88152 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -46,8 +46,10 @@ impl Modify for SecurityAddon { paths( relayer_docs::doc_get_relayer, relayer_docs::doc_list_relayers, - relayer_docs::doc_get_relayer_balance, + relayer_docs::doc_create_relayer, relayer_docs::doc_update_relayer, + relayer_docs::doc_delete_relayer, + relayer_docs::doc_get_relayer_balance, relayer_docs::doc_get_transaction_by_nonce, relayer_docs::doc_get_transaction_by_id, relayer_docs::doc_list_transactions, @@ -72,6 +74,7 @@ impl Modify for SecurityAddon { ), components(schemas( models::RelayerResponse, + models::CreateRelayerRequest, models::NetworkPolicyResponse, models::EvmPolicyResponse, models::SolanaPolicyResponse, diff --git a/src/repositories/notification/notification_in_memory.rs b/src/repositories/notification/notification_in_memory.rs index 81db160c0..993ff77bd 100644 --- a/src/repositories/notification/notification_in_memory.rs +++ b/src/repositories/notification/notification_in_memory.rs @@ -59,7 +59,7 @@ impl Repository for InMemoryNotificationRepositor let mut store = Self::acquire_lock(&self.store).await?; if store.contains_key(¬ification.id) { return Err(RepositoryError::ConstraintViolation(format!( - "Notification with ID {} already exists", + "Notification with ID '{}' already exists", notification.id ))); } @@ -72,7 +72,7 @@ impl Repository for InMemoryNotificationRepositor match store.get(&id) { Some(entity) => Ok(entity.clone()), None => Err(RepositoryError::NotFound(format!( - "Notification with ID {} not found", + "Notification with ID '{}' not found", id ))), } @@ -89,7 +89,7 @@ impl Repository for InMemoryNotificationRepositor // Check if notification exists if !store.contains_key(&id) { return Err(RepositoryError::NotFound(format!( - "Notification with ID {} not found", + "Notification with ID '{}' not found", id ))); } diff --git a/src/repositories/notification/notification_redis.rs b/src/repositories/notification/notification_redis.rs index 0fda45930..17f9b9155 100644 --- a/src/repositories/notification/notification_redis.rs +++ b/src/repositories/notification/notification_redis.rs @@ -161,7 +161,7 @@ impl Repository for RedisNotificationRepository { if existing.is_some() { return Err(RepositoryError::ConstraintViolation(format!( - "Notification with ID {} already exists", + "Notification with ID '{}' already exists", entity.id ))); } @@ -207,7 +207,7 @@ impl Repository for RedisNotificationRepository { None => { debug!("Notification {} not found", id); Err(RepositoryError::NotFound(format!( - "Notification with ID {} not found", + "Notification with ID '{}' not found", id ))) } @@ -318,7 +318,7 @@ impl Repository for RedisNotificationRepository { if existing.is_none() { return Err(RepositoryError::NotFound(format!( - "Notification with ID {} not found", + "Notification with ID '{}' not found", id ))); } @@ -356,7 +356,7 @@ impl Repository for RedisNotificationRepository { if existing.is_none() { return Err(RepositoryError::NotFound(format!( - "Notification with ID {} not found", + "Notification with ID '{}' not found", id ))); } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 30b6d659f..c8c242b8f 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -32,5 +32,8 @@ pub use redis::*; mod service_info_log; pub use service_info_log::*; +mod uuid; +pub use uuid::*; + #[cfg(test)] pub mod mocks; diff --git a/src/utils/uuid.rs b/src/utils/uuid.rs new file mode 100644 index 000000000..ab1d2e4fc --- /dev/null +++ b/src/utils/uuid.rs @@ -0,0 +1,20 @@ +//! UUID utilities. +//! +//! This module provides utilities for generating UUIDs. +use uuid::Uuid; + +/// Generate a new UUID. +pub fn generate_uuid() -> String { + Uuid::new_v4().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_uuid() { + let uuid = generate_uuid(); + assert_eq!(uuid.len(), 36); + } +} From 7ce87ce7b9bcfc6c89f1970fc90a60d35fbc2e66 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 22 Jul 2025 11:58:18 +0200 Subject: [PATCH 28/59] chore: improvements --- src/api/controllers/relayer.rs | 39 +- src/api/routes/docs/relayer_docs.rs | 22 +- src/api/routes/relayer.rs | 6 +- src/domain/relayer/mod.rs | 6 - src/models/relayer/mod.rs | 11 +- src/models/relayer/repository.rs | 86 ++++ src/models/relayer/request.rs | 368 +++++++++++++++++- src/openapi.rs | 2 +- src/repositories/relayer/mod.rs | 13 +- src/repositories/relayer/relayer_in_memory.rs | 12 +- src/repositories/relayer/relayer_redis.rs | 9 +- 11 files changed, 515 insertions(+), 59 deletions(-) diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 4748fa4d7..f3040df64 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -13,13 +13,14 @@ use crate::{ domain::{ get_network_relayer, get_network_relayer_by_model, get_relayer_by_id, get_relayer_transaction_by_model, get_transaction_by_id as get_tx_by_id, Relayer, - RelayerFactory, RelayerFactoryTrait, RelayerUpdateRequest, SignDataRequest, - SignDataResponse, SignTypedDataRequest, Transaction, + RelayerFactory, RelayerFactoryTrait, SignDataRequest, SignDataResponse, + SignTypedDataRequest, Transaction, }, models::{ convert_to_internal_rpc_request, ApiError, ApiResponse, CreateRelayerRequest, DefaultAppState, NetworkTransactionRequest, NetworkType, PaginationMeta, PaginationQuery, - RelayerRepoModel, RelayerResponse, TransactionResponse, + Relayer as RelayerDomainModel, RelayerRepoModel, RelayerRepoUpdater, RelayerResponse, TransactionResponse, + UpdateRelayerRequest, }, repositories::{NetworkRepository, RelayerRepository, Repository, TransactionRepository}, services::{Signer, SignerFactory}, @@ -181,26 +182,36 @@ pub async fn create_relayer( /// The updated relayer information. pub async fn update_relayer( relayer_id: String, - update_req: RelayerUpdateRequest, + request: UpdateRelayerRequest, state: web::ThinData, ) -> Result { let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; - if relayer.system_disabled || (relayer.paused && update_req.paused != Some(false)) { - let error_message = if relayer.system_disabled { - "Relayer is disabled" - } else { - "Relayer is paused" - }; - return Err(ApiError::BadRequest(error_message.to_string())); + if relayer.system_disabled { + return Err(ApiError::BadRequest("Relayer is disabled".to_string())); } - let updated_relayer = state + // check if notification exists (if provided) + if let Some(notification_id) = &request.notification_id { + let _notification = state + .notification_repository + .get_by_id(notification_id.clone()) + .await?; + } + + // Apply update (with validation) + let updated: RelayerDomainModel = RelayerDomainModel::from(relayer.clone()).apply_update(&request)?; + + // Use safe updater to preserve runtime fields automatically + let updated_repo_model = RelayerRepoUpdater::from_existing(relayer) + .apply_domain_update(updated); + + let saved_relayer = state .relayer_repository - .partial_update(relayer_id.clone(), update_req) + .update(relayer_id.clone(), updated_repo_model) .await?; - let relayer_response: RelayerResponse = updated_relayer.into(); + let relayer_response: RelayerResponse = saved_relayer.into(); Ok(HttpResponse::Ok().json(ApiResponse::success(relayer_response))) } diff --git a/src/api/routes/docs/relayer_docs.rs b/src/api/routes/docs/relayer_docs.rs index 409d3c4f7..35f118511 100644 --- a/src/api/routes/docs/relayer_docs.rs +++ b/src/api/routes/docs/relayer_docs.rs @@ -1,14 +1,24 @@ +//! # Relayer Documentation +//! +//! This module contains the OpenAPI documentation for the relayer API endpoints. +//! +//! ## Endpoints +//! +//! - `GET /api/v1/relayers`: List all relayers +//! - `GET /api/v1/relayers/{id}`: Get a relayer by ID +//! - `POST /api/v1/relayers`: Create a new relayer +//! - `PATCH /api/v1/relayers/{id}`: Update a relayer +//! - `DELETE /api/v1/relayers/{id}`: Delete a relayer + use crate::{ - domain::{ - BalanceResponse, RelayerUpdateRequest, SignDataRequest, SignDataResponse, - SignTypedDataRequest, - }, + domain::{BalanceResponse, SignDataRequest, SignDataResponse, SignTypedDataRequest}, models::{ ApiResponse, CreateRelayerRequest, DeletePendingTransactionsResponse, JsonRpcRequest, JsonRpcResponse, NetworkRpcRequest, NetworkRpcResult, NetworkTransactionRequest, - RelayerResponse, RelayerStatus, TransactionResponse, + RelayerResponse, RelayerStatus, TransactionResponse, UpdateRelayerRequest, }, }; + /// Relayer routes implementation /// /// Note: OpenAPI documentation for these endpoints can be found in the `openapi.rs` file @@ -223,7 +233,7 @@ fn doc_create_relayer() {} params( ("relayer_id" = String, Path, description = "The unique identifier of the relayer") ), - request_body = RelayerUpdateRequest, + request_body = UpdateRelayerRequest, responses( (status = 200, description = "Relayer updated successfully", body = ApiResponse), ( diff --git a/src/api/routes/relayer.rs b/src/api/routes/relayer.rs index 08ad0fe1b..1dc0cfc39 100644 --- a/src/api/routes/relayer.rs +++ b/src/api/routes/relayer.rs @@ -3,8 +3,8 @@ //! The routes are integrated with the Actix-web framework and interact with the relayer controller. use crate::{ api::controllers::relayer, - domain::{RelayerUpdateRequest, SignDataRequest, SignTypedDataRequest}, - models::{CreateRelayerRequest, DefaultAppState, PaginationQuery}, + domain::{SignDataRequest, SignTypedDataRequest}, + models::{CreateRelayerRequest, DefaultAppState, PaginationQuery, UpdateRelayerRequest}, }; use actix_web::{delete, get, patch, post, put, web, Responder}; use serde::Deserialize; @@ -41,7 +41,7 @@ async fn create_relayer( #[patch("/relayers/{relayer_id}")] async fn update_relayer( relayer_id: web::Path, - update_req: web::Json, + update_req: web::Json, data: web::ThinData, ) -> impl Responder { relayer::update_relayer(relayer_id.into_inner(), update_req.into_inner(), data).await diff --git a/src/domain/relayer/mod.rs b/src/domain/relayer/mod.rs index b3383b309..f41924312 100644 --- a/src/domain/relayer/mod.rs +++ b/src/domain/relayer/mod.rs @@ -513,9 +513,3 @@ pub struct BalanceResponse { #[schema(example = "wei")] pub unit: String, } - -#[derive(Serialize, Deserialize, ToSchema)] -pub struct RelayerUpdateRequest { - #[schema(nullable = false)] - pub paused: Option, -} diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index cc93bc38c..4ffac26f0 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -689,12 +689,11 @@ impl Relayer { updated.paused = paused; } - if let Some(network_type) = &request.network_type { - updated.network_type = network_type.clone(); - } - - if let Some(policies) = &request.policies { - updated.policies = Some(policies.clone()); + if let Some(policies) = request + .to_domain_policies(self.network_type) + .map_err(|e| RelayerValidationError::InvalidPolicy(e.to_string()))? + { + updated.policies = Some(policies); } if let Some(notification_id) = &request.notification_id { diff --git a/src/models/relayer/repository.rs b/src/models/relayer/repository.rs index c23a9c22b..d8ca3739c 100644 --- a/src/models/relayer/repository.rs +++ b/src/models/relayer/repository.rs @@ -8,6 +8,32 @@ use super::{RelayerNetworkPolicy, RelayerNetworkType, RpcConfig}; // Use the domain model RelayerNetworkType directly pub type NetworkType = RelayerNetworkType; +/// Helper for safely updating relayer repository models from domain models +/// while preserving runtime fields like address and system_disabled +pub struct RelayerRepoUpdater { + original: RelayerRepoModel, +} + +impl RelayerRepoUpdater { + /// Create an updater from an existing repository model + pub fn from_existing(existing: RelayerRepoModel) -> Self { + Self { original: existing } + } + + /// Apply updates from a domain model while preserving runtime fields + /// + /// This method ensures that runtime fields (address, system_disabled) from the + /// original repository model are preserved when converting from domain model, + /// preventing data loss during updates. + pub fn apply_domain_update(self, domain: Relayer) -> RelayerRepoModel { + let mut updated = RelayerRepoModel::from(domain); + // Preserve runtime fields from original + updated.address = self.original.address; + updated.system_disabled = self.original.system_disabled; + updated + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayerRepoModel { pub id: String, @@ -55,6 +81,22 @@ impl Default for RelayerRepoModel { } } +impl From for Relayer { + fn from(repo_model: RelayerRepoModel) -> Self { + Self { + id: repo_model.id, + name: repo_model.name, + network: repo_model.network, + paused: repo_model.paused, + network_type: repo_model.network_type, + policies: Some(repo_model.policies), + signer_id: repo_model.signer_id, + notification_id: repo_model.notification_id, + custom_rpc_urls: repo_model.custom_rpc_urls, + } + } +} + impl From for RelayerRepoModel { fn from(relayer: Relayer) -> Self { Self { @@ -129,4 +171,48 @@ mod tests { assert!(result.is_err()); assert!(matches!(result.unwrap_err(), RelayerError::RelayerDisabled)); } + + #[test] + fn test_relayer_repo_updater_preserves_runtime_fields() { + // Create an original relayer with runtime fields set + let original = RelayerRepoModel { + id: "test_relayer".to_string(), + name: "Original Name".to_string(), + address: "0x742d35Cc6634C0532925a3b8D8C2e48a73F6ba2E".to_string(), // Runtime field + system_disabled: true, // Runtime field + paused: false, + network: "mainnet".to_string(), + network_type: NetworkType::Evm, + signer_id: "test_signer".to_string(), + policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()), + notification_id: None, + custom_rpc_urls: None, + }; + + // Create a domain model with different business fields + let domain_update = Relayer { + id: "test_relayer".to_string(), + name: "Updated Name".to_string(), // Changed + paused: true, // Changed + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Evm, + signer_id: "test_signer".to_string(), + policies: Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default())), + notification_id: Some("new_notification".to_string()), // Changed + custom_rpc_urls: None, + }; + + // Use updater to preserve runtime fields + let updated = RelayerRepoUpdater::from_existing(original.clone()) + .apply_domain_update(domain_update); + + // Verify business fields were updated + assert_eq!(updated.name, "Updated Name"); + assert_eq!(updated.paused, true); + assert_eq!(updated.notification_id, Some("new_notification".to_string())); + + // Verify runtime fields were preserved + assert_eq!(updated.address, "0x742d35Cc6634C0532925a3b8D8C2e48a73F6ba2E"); + assert_eq!(updated.system_disabled, true); + } } diff --git a/src/models/relayer/request.rs b/src/models/relayer/request.rs index 2c91a0222..b45af28f8 100644 --- a/src/models/relayer/request.rs +++ b/src/models/relayer/request.rs @@ -11,9 +11,13 @@ //! These models handle API-specific concerns like optional fields for updates //! while delegating business logic validation to the domain model. -use super::{Relayer, RelayerNetworkPolicy, RelayerNetworkType, RpcConfig}; -use crate::{models::ApiError, utils::generate_uuid}; -use serde::{Deserialize, Serialize}; +use super::{ + Relayer, RelayerEvmPolicy, RelayerNetworkPolicy, RelayerNetworkType, RelayerSolanaPolicy, + RelayerStellarPolicy, RpcConfig, +}; +use crate::{models::error::ApiError, utils::generate_uuid}; +use serde::{de, Deserialize, Deserializer, Serialize}; +use serde_json::Value; use utoipa::ToSchema; /// Request model for creating a new relayer @@ -25,31 +29,159 @@ pub struct CreateRelayerRequest { pub network: String, pub paused: bool, pub network_type: RelayerNetworkType, - pub policies: Option, + /// Policies without network_type tag - will be validated against the network_type field + #[serde(skip_serializing_if = "Option::is_none")] + pub policies: Option, pub signer_id: String, pub notification_id: Option, pub custom_rpc_urls: Option>, } +impl CreateRelayerRequest { + /// Converts the policies field to domain RelayerNetworkPolicy using the network_type field + pub fn to_domain_policies(&self) -> Result, ApiError> { + if let Some(policy_request) = &self.policies { + Ok(Some(policy_request.to_domain_policy(self.network_type)?)) + } else { + Ok(None) + } + } +} + +/// Policy types for update requests - these don't require network_type tags +/// since they will be inferred from the existing relayer +#[derive(Debug, Clone, Serialize, PartialEq, ToSchema)] +#[serde(deny_unknown_fields)] +pub enum UpdateRelayerPolicyRequest { + Evm(RelayerEvmPolicy), + Solana(RelayerSolanaPolicy), + Stellar(RelayerStellarPolicy), +} + +impl<'de> Deserialize<'de> for UpdateRelayerPolicyRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value: Value = Value::deserialize(deserializer)?; + + if let Value::Object(obj) = &value { + // Check for Solana-specific fields first (most distinctive) + if obj.contains_key("fee_payment_strategy") + || obj.contains_key("allowed_tokens") + || obj.contains_key("allowed_programs") + || obj.contains_key("max_signatures") + || obj.contains_key("max_tx_data_size") + || obj.contains_key("allowed_accounts") + || obj.contains_key("disallowed_accounts") + || obj.contains_key("max_allowed_fee_lamports") + || obj.contains_key("swap_config") + || obj.contains_key("fee_margin_percentage") + { + let policy: RelayerSolanaPolicy = + serde_json::from_value(value).map_err(de::Error::custom)?; + return Ok(UpdateRelayerPolicyRequest::Solana(policy)); + } + + // Check for EVM-specific fields + if obj.contains_key("gas_price_cap") + || obj.contains_key("gas_limit_estimation") + || obj.contains_key("whitelist_receivers") + || obj.contains_key("eip1559_pricing") + || obj.contains_key("private_transactions") + { + let policy: RelayerEvmPolicy = + serde_json::from_value(value).map_err(de::Error::custom)?; + return Ok(UpdateRelayerPolicyRequest::Evm(policy)); + } + + // Check for Stellar-specific fields + if obj.contains_key("max_fee") || obj.contains_key("timeout_seconds") { + let policy: RelayerStellarPolicy = + serde_json::from_value(value).map_err(de::Error::custom)?; + return Ok(UpdateRelayerPolicyRequest::Stellar(policy)); + } + + // If only min_balance is present, we can't determine the type automatically + // Try each type and see which one deserializes successfully + if let Ok(policy) = serde_json::from_value::(value.clone()) { + return Ok(UpdateRelayerPolicyRequest::Evm(policy)); + } + if let Ok(policy) = serde_json::from_value::(value.clone()) { + return Ok(UpdateRelayerPolicyRequest::Solana(policy)); + } + if let Ok(policy) = serde_json::from_value::(value) { + return Ok(UpdateRelayerPolicyRequest::Stellar(policy)); + } + } + + Err(de::Error::custom( + "Unable to determine policy type from provided fields", + )) + } +} + +impl UpdateRelayerPolicyRequest { + /// Converts to domain RelayerNetworkPolicy using the provided network type + pub fn to_domain_policy( + &self, + network_type: RelayerNetworkType, + ) -> Result { + match (self, network_type) { + (UpdateRelayerPolicyRequest::Evm(policy), RelayerNetworkType::Evm) => { + Ok(RelayerNetworkPolicy::Evm(policy.clone())) + } + (UpdateRelayerPolicyRequest::Solana(policy), RelayerNetworkType::Solana) => { + Ok(RelayerNetworkPolicy::Solana(policy.clone())) + } + (UpdateRelayerPolicyRequest::Stellar(policy), RelayerNetworkType::Stellar) => { + Ok(RelayerNetworkPolicy::Stellar(policy.clone())) + } + _ => Err(ApiError::BadRequest( + "Policy type does not match relayer network type".to_string(), + )), + } + } +} + /// Request model for updating an existing relayer /// All fields are optional to allow partial updates /// Note: network and signer_id are not updateable after creation -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)] #[serde(deny_unknown_fields)] pub struct UpdateRelayerRequest { pub name: Option, pub paused: Option, - pub network_type: Option, - pub policies: Option, + /// Policies without network_type tag - will be validated against existing relayer's network type + #[serde(skip_serializing_if = "Option::is_none")] + pub policies: Option, pub notification_id: Option, pub custom_rpc_urls: Option>, } +impl UpdateRelayerRequest { + /// Converts the policies field to domain RelayerNetworkPolicy using the provided network type + pub fn to_domain_policies( + &self, + network_type: RelayerNetworkType, + ) -> Result, ApiError> { + if let Some(policy_request) = &self.policies { + Ok(Some(policy_request.to_domain_policy(network_type)?)) + } else { + Ok(None) + } + } +} + impl TryFrom for Relayer { type Error = ApiError; fn try_from(request: CreateRelayerRequest) -> Result { - let id = request.id.unwrap_or_else(|| generate_uuid()); + let id = request.id.clone().unwrap_or_else(|| generate_uuid()); + + // Convert policies using the network_type from the request + let policies = request.to_domain_policies()?; + // Create domain relayer let relayer = Relayer::new( id, @@ -57,7 +189,7 @@ impl TryFrom for Relayer { request.network, request.paused, request.network_type, - request.policies, + policies, request.signer_id, request.notification_id, request.custom_rpc_urls, @@ -83,7 +215,7 @@ mod tests { network: "mainnet".to_string(), paused: false, network_type: RelayerNetworkType::Evm, - policies: Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + policies: Some(UpdateRelayerPolicyRequest::Evm(RelayerEvmPolicy { gas_price_cap: Some(100), whitelist_receivers: None, eip1559_pricing: Some(true), @@ -120,12 +252,113 @@ mod tests { assert!(domain_relayer.is_err()); } + #[test] + fn test_create_request_policy_conversion() { + // Test that policies are correctly converted from request type to domain type + let request = CreateRelayerRequest { + id: Some("test-relayer".to_string()), + name: "Test Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Solana, + policies: Some(UpdateRelayerPolicyRequest::Solana(RelayerSolanaPolicy { + fee_payment_strategy: Some( + crate::models::relayer::RelayerSolanaFeePaymentStrategy::Relayer, + ), + min_balance: Some(1000000), + allowed_tokens: None, + allowed_programs: None, + allowed_accounts: None, + disallowed_accounts: None, + max_signatures: None, + max_tx_data_size: None, + max_allowed_fee_lamports: None, + swap_config: None, + fee_margin_percentage: None, + })), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + // Test policy conversion + let policies = request.to_domain_policies().unwrap(); + assert!(policies.is_some()); + + if let Some(RelayerNetworkPolicy::Solana(solana_policy)) = policies { + assert_eq!(solana_policy.min_balance, Some(1000000)); + } else { + panic!("Expected Solana policy"); + } + + // Test full conversion to domain relayer + let domain_relayer = Relayer::try_from(request); + assert!(domain_relayer.is_ok()); + } + + #[test] + fn test_create_request_wrong_policy_type() { + // Test that providing wrong policy type for network type fails + let request = CreateRelayerRequest { + id: Some("test-relayer".to_string()), + name: "Test Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Evm, // EVM network type + policies: Some(UpdateRelayerPolicyRequest::Solana( + RelayerSolanaPolicy::default(), + )), // But Solana policy + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + // Should fail during policy conversion + let result = request.to_domain_policies(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Policy type does not match relayer network type")); + } + + #[test] + fn test_create_request_json_deserialization() { + // Test that JSON without network_type in policies deserializes correctly + let json_input = r#"{ + "name": "Test Relayer", + "network": "mainnet", + "paused": false, + "network_type": "evm", + "signer_id": "test-signer", + "policies": { + "gas_price_cap": 100000000000, + "eip1559_pricing": true, + "min_balance": 1000000000000000000 + } + }"#; + + let request: CreateRelayerRequest = serde_json::from_str(json_input).unwrap(); + assert_eq!(request.network_type, RelayerNetworkType::Evm); + assert!(request.policies.is_some()); + + // Test that it converts to domain model correctly + let domain_relayer = Relayer::try_from(request).unwrap(); + assert_eq!(domain_relayer.network_type, RelayerNetworkType::Evm); + + if let Some(RelayerNetworkPolicy::Evm(evm_policy)) = domain_relayer.policies { + assert_eq!(evm_policy.gas_price_cap, Some(100000000000)); + assert_eq!(evm_policy.eip1559_pricing, Some(true)); + } else { + panic!("Expected EVM policy"); + } + } + #[test] fn test_valid_update_request() { let request = UpdateRelayerRequest { name: Some("Updated Name".to_string()), paused: Some(true), - network_type: None, policies: None, notification_id: Some("new-notification".to_string()), custom_rpc_urls: None, @@ -141,7 +374,6 @@ mod tests { let request = UpdateRelayerRequest { name: None, paused: None, - network_type: None, policies: None, notification_id: None, custom_rpc_urls: None, @@ -151,4 +383,116 @@ mod tests { let serialized = serde_json::to_string(&request).unwrap(); let _deserialized: UpdateRelayerRequest = serde_json::from_str(&serialized).unwrap(); } + + #[test] + fn test_update_request_policy_deserialization() { + // Test EVM policy deserialization without network_type in user input + let json_input = r#"{ + "name": "Updated Relayer", + "policies": { + "gas_price_cap": 100000000000, + "eip1559_pricing": true + } + }"#; + + let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); + assert!(request.policies.is_some()); + + // Test policy conversion to domain type with EVM network type + let policies = request.to_domain_policies(RelayerNetworkType::Evm).unwrap(); + assert!(policies.is_some()); + + if let Some(RelayerNetworkPolicy::Evm(evm_policy)) = policies { + assert_eq!(evm_policy.gas_price_cap, Some(100000000000)); + assert_eq!(evm_policy.eip1559_pricing, Some(true)); + } else { + panic!("Expected EVM policy"); + } + } + + #[test] + fn test_update_request_policy_deserialization_solana() { + // Test Solana policy deserialization without network_type in user input + let json_input = r#"{ + "policies": { + "fee_payment_strategy": "relayer", + "min_balance": 1000000 + } + }"#; + + let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); + + // Test policy conversion to domain type with Solana network type + let policies = request + .to_domain_policies(RelayerNetworkType::Solana) + .unwrap(); + assert!(policies.is_some()); + + if let Some(RelayerNetworkPolicy::Solana(solana_policy)) = policies { + assert_eq!(solana_policy.min_balance, Some(1000000)); + } else { + panic!("Expected Solana policy"); + } + } + + #[test] + fn test_update_request_invalid_policy_format() { + // Test that invalid policy format fails during JSON deserialization + let json_input = r#"{ + "policies": "invalid_not_an_object" + }"#; + + // Should fail during JSON deserialization since policies expects an object + let result = serde_json::from_str::(json_input); + assert!(result.is_err()); + } + + #[test] + fn test_update_request_wrong_network_type() { + // Test that providing EVM policy for Solana relayer fails + let json_input = r#"{ + "policies": { + "gas_price_cap": 100000000000, + "eip1559_pricing": true + } + }"#; + + let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); + + // Should fail when converting EVM policy for Solana network type + let result = request.to_domain_policies(RelayerNetworkType::Solana); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Policy type does not match relayer network type")); + } + + #[test] + fn test_update_request_stellar_policy() { + // Test Stellar policy deserialization + let json_input = r#"{ + "policies": { + "max_fee": 10000, + "timeout_seconds": 300, + "min_balance": 5000000 + } + }"#; + + let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); + + // Should correctly identify as Stellar policy + let policies = request + .to_domain_policies(RelayerNetworkType::Stellar) + .unwrap(); + assert!(policies.is_some()); + + if let Some(RelayerNetworkPolicy::Stellar(stellar_policy)) = policies { + assert_eq!(stellar_policy.max_fee, Some(10000)); + assert_eq!(stellar_policy.timeout_seconds, Some(300)); + assert_eq!(stellar_policy.min_balance, Some(5000000)); + } else { + panic!("Expected Stellar policy"); + } + } } diff --git a/src/openapi.rs b/src/openapi.rs index 65ee88152..4c7411242 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -79,7 +79,7 @@ impl Modify for SecurityAddon { models::EvmPolicyResponse, models::SolanaPolicyResponse, models::StellarPolicyResponse, - domain::RelayerUpdateRequest, + models::UpdateRelayerRequest, domain::SignDataRequest, domain::SignTypedDataRequest, models::PluginCallRequest, diff --git a/src/repositories/relayer/mod.rs b/src/repositories/relayer/mod.rs index 5d47f7f60..4123496a8 100644 --- a/src/repositories/relayer/mod.rs +++ b/src/repositories/relayer/mod.rs @@ -26,7 +26,7 @@ pub use relayer_in_memory::*; pub use relayer_redis::*; use crate::{ - domain::RelayerUpdateRequest, + models::UpdateRelayerRequest, models::{PaginationQuery, RelayerNetworkPolicy, RelayerRepoModel, RepositoryError}, repositories::{PaginatedResult, Repository}, }; @@ -48,7 +48,7 @@ pub trait RelayerRepository: Repository + Send + Sync async fn partial_update( &self, id: String, - update: RelayerUpdateRequest, + update: UpdateRelayerRequest, ) -> Result; async fn enable_relayer(&self, relayer_id: String) -> Result; @@ -201,7 +201,7 @@ impl RelayerRepository for RelayerRepositoryStorage { async fn partial_update( &self, id: String, - update: RelayerUpdateRequest, + update: UpdateRelayerRequest, ) -> Result { match self { RelayerRepositoryStorage::InMemory(repo) => repo.partial_update(id, update).await, @@ -321,7 +321,10 @@ mod tests { assert!(!active_relayers.is_empty()); // Test partial_update - let update = RelayerUpdateRequest { paused: Some(true) }; + let update = UpdateRelayerRequest { + paused: Some(true), + ..Default::default() + }; let updated = impl_repo .partial_update(relayer.id.clone(), update) .await @@ -460,7 +463,7 @@ mockall::mock! { async fn list_active(&self) -> Result, RepositoryError>; async fn list_by_signer_id(&self, signer_id: &str) -> Result, RepositoryError>; async fn list_by_notification_id(&self, notification_id: &str) -> Result, RepositoryError>; - async fn partial_update(&self, id: String, update: RelayerUpdateRequest) -> Result; + async fn partial_update(&self, id: String, update: UpdateRelayerRequest) -> Result; async fn enable_relayer(&self, relayer_id: String) -> Result; async fn disable_relayer(&self, relayer_id: String) -> Result; async fn update_policy(&self, id: String, policy: RelayerNetworkPolicy) -> Result; diff --git a/src/repositories/relayer/relayer_in_memory.rs b/src/repositories/relayer/relayer_in_memory.rs index 2783cadff..a2b33b884 100644 --- a/src/repositories/relayer/relayer_in_memory.rs +++ b/src/repositories/relayer/relayer_in_memory.rs @@ -9,7 +9,7 @@ //! implementation is useful for testing and development purposes. use crate::models::PaginationQuery; use crate::{ - domain::RelayerUpdateRequest, + models::UpdateRelayerRequest, models::{RelayerNetworkPolicy, RelayerRepoModel, RepositoryError}, }; use async_trait::async_trait; @@ -102,7 +102,7 @@ impl RelayerRepository for InMemoryRelayerRepository { async fn partial_update( &self, id: String, - update: RelayerUpdateRequest, + update: UpdateRelayerRequest, ) -> Result { let mut store = Self::acquire_lock(&self.store).await?; if let Some(relayer) = store.get_mut(&id) { @@ -386,7 +386,13 @@ mod tests { repo.create(initial_relayer.clone()).await.unwrap(); // Perform a partial update on the relayer - let update_req = RelayerUpdateRequest { paused: Some(true) }; + let update_req = UpdateRelayerRequest { + name: None, + paused: Some(true), + policies: None, + notification_id: None, + custom_rpc_urls: None, + }; let updated_relayer = repo .partial_update(relayer_id.clone(), update_req) diff --git a/src/repositories/relayer/relayer_redis.rs b/src/repositories/relayer/relayer_redis.rs index 2fde5e471..868bbff3b 100644 --- a/src/repositories/relayer/relayer_redis.rs +++ b/src/repositories/relayer/relayer_redis.rs @@ -1,6 +1,6 @@ //! Redis-backed implementation of the RelayerRepository. -use crate::domain::RelayerUpdateRequest; +use crate::models::UpdateRelayerRequest; use crate::models::{PaginationQuery, RelayerNetworkPolicy, RelayerRepoModel, RepositoryError}; use crate::repositories::redis_base::RedisRepository; use crate::repositories::{BatchRetrievalResult, PaginatedResult, RelayerRepository, Repository}; @@ -482,7 +482,7 @@ impl RelayerRepository for RedisRelayerRepository { async fn partial_update( &self, id: String, - update: RelayerUpdateRequest, + update: UpdateRelayerRequest, ) -> Result { // First get the current relayer let mut relayer = self.get_by_id(id.clone()).await?; @@ -809,7 +809,10 @@ mod tests { repo.create(relayer.clone()).await.unwrap(); - let update = RelayerUpdateRequest { paused: Some(true) }; + let update = UpdateRelayerRequest { + paused: Some(true), + ..Default::default() + }; let result = repo.partial_update(relayer.id.clone(), update).await; assert!(result.is_ok()); From 6f11c177ce3e1dd925c5f9dde95427cfd301b91a Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 22 Jul 2025 22:35:11 +0200 Subject: [PATCH 29/59] chore: impr --- Cargo.lock | 23 ++ Cargo.toml | 1 + src/api/controllers/relayer.rs | 11 +- src/domain/relayer/evm/validations.rs | 7 +- src/domain/relayer/solana/dex/mod.rs | 2 +- .../relayer/solana/rpc/methods/validations.rs | 8 +- src/domain/relayer/solana/solana_relayer.rs | 5 +- src/domain/transaction/evm/evm_transaction.rs | 4 - src/domain/transaction/evm/replacement.rs | 4 +- src/jobs/handlers/notification_handler.rs | 4 +- src/models/relayer/config.rs | 2 +- src/models/relayer/mod.rs | 97 ++------- src/models/relayer/repository.rs | 22 +- src/models/relayer/response.rs | 197 +++++++++++++++++- 14 files changed, 272 insertions(+), 115 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f122f1733..874b7e9ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4489,6 +4489,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "159294d661a039f7644cea7e4d844e6b25aaf71c1ffe9d73a96d768c24b0faf4" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "jsonrpc-core" version = "18.0.0" @@ -5309,6 +5331,7 @@ dependencies = [ "hmac 0.12.1", "http 1.3.1", "itertools 0.14.0", + "json-patch", "k256", "lazy_static", "libsodium-sys", diff --git a/Cargo.toml b/Cargo.toml index f3c99d412..17dc9d9ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ async-trait = "0.1" actix-rt = "2.0.0" alloy = { version = "0.9", features = ["full"] } serde_json = "1" +json-patch = "4.0" strum = { version = "0.27", default-features = false, features = ["derive"] } strum_macros = "0.27" serde = { version = "1.0", features = ["derive", "alloc"] } diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index f3040df64..98f2d1138 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -19,8 +19,8 @@ use crate::{ models::{ convert_to_internal_rpc_request, ApiError, ApiResponse, CreateRelayerRequest, DefaultAppState, NetworkTransactionRequest, NetworkType, PaginationMeta, PaginationQuery, - Relayer as RelayerDomainModel, RelayerRepoModel, RelayerRepoUpdater, RelayerResponse, TransactionResponse, - UpdateRelayerRequest, + Relayer as RelayerDomainModel, RelayerRepoModel, RelayerRepoUpdater, RelayerResponse, + TransactionResponse, UpdateRelayerRequest, }, repositories::{NetworkRepository, RelayerRepository, Repository, TransactionRepository}, services::{Signer, SignerFactory}, @@ -200,11 +200,12 @@ pub async fn update_relayer( } // Apply update (with validation) - let updated: RelayerDomainModel = RelayerDomainModel::from(relayer.clone()).apply_update(&request)?; + let updated: RelayerDomainModel = + RelayerDomainModel::from(relayer.clone()).apply_update(&request)?; // Use safe updater to preserve runtime fields automatically - let updated_repo_model = RelayerRepoUpdater::from_existing(relayer) - .apply_domain_update(updated); + let updated_repo_model = + RelayerRepoUpdater::from_existing(relayer).apply_domain_update(updated); let saved_relayer = state .relayer_repository diff --git a/src/domain/relayer/evm/validations.rs b/src/domain/relayer/evm/validations.rs index f10b768ea..5aa432832 100644 --- a/src/domain/relayer/evm/validations.rs +++ b/src/domain/relayer/evm/validations.rs @@ -1,6 +1,7 @@ use thiserror::Error; use crate::{ + constants::DEFAULT_EVM_MIN_BALANCE, models::{types::U256, RelayerEvmPolicy}, services::EvmProviderTrait, }; @@ -28,7 +29,7 @@ impl EvmTransactionValidator { .await .map_err(|e| EvmTransactionValidationError::ProviderError(e.to_string()))?; - let min_balance = U256::from(policy.min_balance.unwrap_or_default()); + let min_balance = U256::from(policy.min_balance.unwrap_or(DEFAULT_EVM_MIN_BALANCE)); if balance < min_balance { return Err(EvmTransactionValidationError::InsufficientBalance(format!( @@ -51,7 +52,7 @@ impl EvmTransactionValidator { .await .map_err(|e| EvmTransactionValidationError::ProviderError(e.to_string()))?; - let min_balance = U256::from(policy.min_balance.unwrap_or_default()); + let min_balance = U256::from(policy.min_balance.unwrap_or(DEFAULT_EVM_MIN_BALANCE)); let remaining_balance = balance.saturating_sub(balance_to_use); @@ -65,7 +66,7 @@ impl EvmTransactionValidator { // Check if remaining balance would fall below minimum requirement if !min_balance.is_zero() && remaining_balance < min_balance { return Err(EvmTransactionValidationError::InsufficientBalance( - format!("Relayer balance {balance} is insufficient to cover {balance_to_use}, with an enforced minimum balance of {}", policy.min_balance.unwrap_or_default()) + format!("Relayer balance {balance} is insufficient to cover {balance_to_use}, with an enforced minimum balance of {}", policy.min_balance.unwrap_or(DEFAULT_EVM_MIN_BALANCE)) )); } diff --git a/src/domain/relayer/solana/dex/mod.rs b/src/domain/relayer/solana/dex/mod.rs index 86018accd..0a637e803 100644 --- a/src/domain/relayer/solana/dex/mod.rs +++ b/src/domain/relayer/solana/dex/mod.rs @@ -97,7 +97,7 @@ fn resolve_strategy(relayer: &RelayerRepoModel) -> SolanaSwapStrategy { .get_solana_policy() .get_swap_config() .and_then(|cfg| cfg.strategy) - .unwrap_or_default() // Provide a default strategy + .unwrap_or(SolanaSwapStrategy::Noop) // Provide a default strategy } pub struct NoopDex; diff --git a/src/domain/relayer/solana/rpc/methods/validations.rs b/src/domain/relayer/solana/rpc/methods/validations.rs index 021586e58..34f664a64 100644 --- a/src/domain/relayer/solana/rpc/methods/validations.rs +++ b/src/domain/relayer/solana/rpc/methods/validations.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; /// * Have correct fee payer configuration /// * Comply with relayer policies use crate::{ + constants::{DEFAULT_SOLANA_MAX_TX_DATA_SIZE, DEFAULT_SOLANA_MIN_BALANCE}, domain::{SolanaTokenProgram, TokenInstruction as SolanaTokenInstruction}, models::RelayerSolanaPolicy, services::SolanaProviderTrait, @@ -247,7 +248,10 @@ impl SolanaTransactionValidator { tx: &Transaction, config: &RelayerSolanaPolicy, ) -> Result<(), SolanaTransactionValidationError> { - let max_size: usize = config.max_tx_data_size.unwrap_or_default().into(); + let max_size: usize = config + .max_tx_data_size + .unwrap_or(DEFAULT_SOLANA_MAX_TX_DATA_SIZE) + .into(); let tx_bytes = bincode::serialize(tx) .map_err(|e| SolanaTransactionValidationError::DeserializeError(e.to_string()))?; @@ -329,7 +333,7 @@ impl SolanaTransactionValidator { .map_err(|e| SolanaTransactionValidationError::ValidationError(e.to_string()))?; // Ensure minimum balance policy is maintained - let min_balance = policy.min_balance.unwrap_or_default(); + let min_balance = policy.min_balance.unwrap_or(DEFAULT_SOLANA_MIN_BALANCE); let required_balance = fee + min_balance; if balance < required_balance { diff --git a/src/domain/relayer/solana/solana_relayer.rs b/src/domain/relayer/solana/solana_relayer.rs index 696d89e70..ca2df9929 100644 --- a/src/domain/relayer/solana/solana_relayer.rs +++ b/src/domain/relayer/solana/solana_relayer.rs @@ -11,7 +11,8 @@ use std::{str::FromStr, sync::Arc}; use crate::{ constants::{ - DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE, SOLANA_SMALLEST_UNIT_NAME, WRAPPED_SOL_MINT, + DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE, DEFAULT_SOLANA_MIN_BALANCE, + SOLANA_SMALLEST_UNIT_NAME, WRAPPED_SOL_MINT, }, domain::{ relayer::RelayerError, BalanceResponse, DexStrategy, SolanaRelayerDexTrait, @@ -661,7 +662,7 @@ where let policy = self.relayer.policies.get_solana_policy(); - if balance < policy.min_balance.unwrap_or_default() { + if balance < policy.min_balance.unwrap_or(DEFAULT_SOLANA_MIN_BALANCE) { return Err(RelayerError::InsufficientBalanceError( "Insufficient balance".to_string(), )); diff --git a/src/domain/transaction/evm/evm_transaction.rs b/src/domain/transaction/evm/evm_transaction.rs index 32c8c1046..1f0ec0ad4 100644 --- a/src/domain/transaction/evm/evm_transaction.rs +++ b/src/domain/transaction/evm/evm_transaction.rs @@ -268,10 +268,6 @@ where evm_data: &EvmTransactionData, relayer_policy: &RelayerEvmPolicy, ) -> Result { - println!( - "relayer_policy: {:?}", - relayer_policy.gas_limit_estimation.unwrap_or_default() - ); if !relayer_policy .gas_limit_estimation .unwrap_or(DEFAULT_EVM_GAS_LIMIT_ESTIMATION) diff --git a/src/domain/transaction/evm/replacement.rs b/src/domain/transaction/evm/replacement.rs index 28b695fcf..b151e1836 100644 --- a/src/domain/transaction/evm/replacement.rs +++ b/src/domain/transaction/evm/replacement.rs @@ -3,7 +3,7 @@ //! and handling transaction compatibility checks. use crate::{ - constants::DEFAULT_GAS_LIMIT, + constants::{DEFAULT_EVM_GAS_PRICE_CAP, DEFAULT_GAS_LIMIT}, domain::transaction::evm::price_calculator::{calculate_min_bump, PriceCalculatorTrait}, models::{ EvmTransactionData, EvmTransactionDataTrait, RelayerRepoModel, TransactionError, U256, @@ -138,7 +138,7 @@ pub fn validate_explicit_price_bump( .policies .get_evm_policy() .gas_price_cap - .unwrap_or_default(); + .unwrap_or(DEFAULT_EVM_GAS_PRICE_CAP); // Check if gas prices exceed gas price cap if let Some(gas_price) = new_evm_data.gas_price { diff --git a/src/jobs/handlers/notification_handler.rs b/src/jobs/handlers/notification_handler.rs index 33997cb0c..f7a4d6ffb 100644 --- a/src/jobs/handlers/notification_handler.rs +++ b/src/jobs/handlers/notification_handler.rs @@ -66,7 +66,7 @@ mod tests { use super::*; use crate::models::{ EvmTransactionResponse, NetworkType, RelayerDisabledPayload, RelayerEvmPolicy, - RelayerNetworkPolicy, RelayerResponse, TransactionResponse, TransactionStatus, + RelayerNetworkPolicyResponse, RelayerResponse, TransactionResponse, TransactionStatus, WebhookNotification, WebhookPayload, U256, }; @@ -150,7 +150,7 @@ mod tests { network: "ethereum".to_string(), network_type: NetworkType::Evm, paused: false, - policies: Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + policies: Some(RelayerNetworkPolicyResponse::Evm(RelayerEvmPolicy { gas_price_cap: None, whitelist_receivers: None, eip1559_pricing: None, diff --git a/src/models/relayer/config.rs b/src/models/relayer/config.rs index e59162b33..92187557c 100644 --- a/src/models/relayer/config.rs +++ b/src/models/relayer/config.rs @@ -333,7 +333,7 @@ fn convert_config_policies_to_domain( match config_policies { ConfigFileRelayerNetworkPolicy::Evm(evm_policy) => { Ok(RelayerNetworkPolicy::Evm(super::RelayerEvmPolicy { - min_balance: Some(evm_policy.min_balance.unwrap_or_default()), + min_balance: evm_policy.min_balance, gas_limit_estimation: evm_policy.gas_limit_estimation, gas_price_cap: evm_policy.gas_price_cap, whitelist_receivers: evm_policy.whitelist_receivers, diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index 4ffac26f0..1113a24b5 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -26,15 +26,7 @@ pub use repository::*; mod rpc_config; pub use rpc_config::*; -use crate::{ - config::ConfigFileNetworkType, - constants::{ - DEFAULT_EVM_EIP1559_ENABLED, DEFAULT_EVM_GAS_LIMIT_ESTIMATION, DEFAULT_EVM_MIN_BALANCE, - DEFAULT_EVM_PRIVATE_TRANSACTIONS, DEFAULT_SOLANA_MAX_TX_DATA_SIZE, - DEFAULT_SOLANA_MIN_BALANCE, DEFAULT_STELLAR_MIN_BALANCE, ID_REGEX, - }, - utils::deserialize_optional_u128, -}; +use crate::{config::ConfigFileNetworkType, constants::ID_REGEX, utils::deserialize_optional_u128}; use apalis_cron::Schedule; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -82,7 +74,8 @@ impl From for ConfigFileNetworkType { } /// EVM-specific relayer policy configuration -#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq, Default)] +#[serde(deny_unknown_fields)] pub struct RelayerEvmPolicy { #[serde(skip_serializing_if = "Option::is_none")] #[serde(deserialize_with = "deserialize_optional_u128", default)] @@ -100,21 +93,9 @@ pub struct RelayerEvmPolicy { pub private_transactions: Option, } -impl Default for RelayerEvmPolicy { - fn default() -> Self { - Self { - min_balance: Some(DEFAULT_EVM_MIN_BALANCE), - gas_limit_estimation: Some(DEFAULT_EVM_GAS_LIMIT_ESTIMATION), - gas_price_cap: Some(u128::MAX), - whitelist_receivers: None, - eip1559_pricing: Some(DEFAULT_EVM_EIP1559_ENABLED), - private_transactions: Some(DEFAULT_EVM_PRIVATE_TRANSACTIONS), - } - } -} - /// Solana token swap configuration #[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq, Default)] +#[serde(deny_unknown_fields)] pub struct AllowedTokenSwapConfig { /// Conversion slippage percentage for token. Optional. pub slippage_percentage: Option, @@ -128,6 +109,7 @@ pub struct AllowedTokenSwapConfig { /// Configuration for allowed token handling on Solana #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +#[serde(deny_unknown_fields)] pub struct AllowedToken { pub mint: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -167,36 +149,27 @@ impl AllowedToken { } /// Solana fee payment strategy -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Default)] #[serde(rename_all = "lowercase")] pub enum RelayerSolanaFeePaymentStrategy { + #[default] User, Relayer, } -impl Default for RelayerSolanaFeePaymentStrategy { - fn default() -> Self { - Self::User - } -} - /// Solana swap strategy -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Default)] #[serde(rename_all = "kebab-case")] pub enum RelayerSolanaSwapStrategy { JupiterSwap, JupiterUltra, + #[default] Noop, } -impl Default for RelayerSolanaSwapStrategy { - fn default() -> Self { - Self::Noop - } -} - /// Jupiter swap options -#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq, Default)] +#[serde(deny_unknown_fields)] pub struct JupiterSwapOptions { /// Maximum priority fee (in lamports) for a transaction. Optional. pub priority_fee_max_lamports: Option, @@ -206,7 +179,8 @@ pub struct JupiterSwapOptions { } /// Solana swap policy configuration -#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq, Default)] +#[serde(deny_unknown_fields)] pub struct RelayerSolanaSwapPolicy { /// DEX strategy to use for token swaps. pub strategy: Option, @@ -218,19 +192,9 @@ pub struct RelayerSolanaSwapPolicy { pub jupiter_swap_options: Option, } -impl Default for RelayerSolanaSwapPolicy { - fn default() -> Self { - Self { - strategy: Some(RelayerSolanaSwapStrategy::default()), - cron_schedule: None, - min_balance_threshold: None, - jupiter_swap_options: None, - } - } -} - /// Solana-specific relayer policy configuration -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema, Default)] +#[serde(deny_unknown_fields)] pub struct RelayerSolanaPolicy { #[serde(skip_serializing_if = "Option::is_none")] pub allowed_programs: Option>, @@ -256,24 +220,6 @@ pub struct RelayerSolanaPolicy { pub swap_config: Option, } -impl Default for RelayerSolanaPolicy { - fn default() -> Self { - Self { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), - fee_margin_percentage: None, - min_balance: Some(DEFAULT_SOLANA_MIN_BALANCE), - allowed_tokens: None, - allowed_programs: None, - allowed_accounts: None, - disallowed_accounts: None, - max_signatures: None, - max_tx_data_size: Some(DEFAULT_SOLANA_MAX_TX_DATA_SIZE), - max_allowed_fee_lamports: None, - swap_config: None, - } - } -} - impl RelayerSolanaPolicy { /// Get allowed tokens for this policy pub fn get_allowed_tokens(&self) -> Vec { @@ -301,7 +247,8 @@ impl RelayerSolanaPolicy { } } /// Stellar-specific relayer policy configuration -#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq, Default)] +#[serde(deny_unknown_fields)] pub struct RelayerStellarPolicy { #[serde(skip_serializing_if = "Option::is_none")] pub min_balance: Option, @@ -311,16 +258,6 @@ pub struct RelayerStellarPolicy { pub timeout_seconds: Option, } -impl Default for RelayerStellarPolicy { - fn default() -> Self { - Self { - max_fee: None, - timeout_seconds: None, - min_balance: Some(DEFAULT_STELLAR_MIN_BALANCE), - } - } -} - /// Network-specific policy for relayers #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] #[serde(tag = "network_type")] diff --git a/src/models/relayer/repository.rs b/src/models/relayer/repository.rs index d8ca3739c..94c1f1ee7 100644 --- a/src/models/relayer/repository.rs +++ b/src/models/relayer/repository.rs @@ -19,9 +19,9 @@ impl RelayerRepoUpdater { pub fn from_existing(existing: RelayerRepoModel) -> Self { Self { original: existing } } - + /// Apply updates from a domain model while preserving runtime fields - /// + /// /// This method ensures that runtime fields (address, system_disabled) from the /// original repository model are preserved when converting from domain model, /// preventing data loss during updates. @@ -179,7 +179,7 @@ mod tests { id: "test_relayer".to_string(), name: "Original Name".to_string(), address: "0x742d35Cc6634C0532925a3b8D8C2e48a73F6ba2E".to_string(), // Runtime field - system_disabled: true, // Runtime field + system_disabled: true, // Runtime field paused: false, network: "mainnet".to_string(), network_type: NetworkType::Evm, @@ -193,7 +193,7 @@ mod tests { let domain_update = Relayer { id: "test_relayer".to_string(), name: "Updated Name".to_string(), // Changed - paused: true, // Changed + paused: true, // Changed network: "mainnet".to_string(), network_type: RelayerNetworkType::Evm, signer_id: "test_signer".to_string(), @@ -203,16 +203,22 @@ mod tests { }; // Use updater to preserve runtime fields - let updated = RelayerRepoUpdater::from_existing(original.clone()) - .apply_domain_update(domain_update); + let updated = + RelayerRepoUpdater::from_existing(original.clone()).apply_domain_update(domain_update); // Verify business fields were updated assert_eq!(updated.name, "Updated Name"); assert_eq!(updated.paused, true); - assert_eq!(updated.notification_id, Some("new_notification".to_string())); + assert_eq!( + updated.notification_id, + Some("new_notification".to_string()) + ); // Verify runtime fields were preserved - assert_eq!(updated.address, "0x742d35Cc6634C0532925a3b8D8C2e48a73F6ba2E"); + assert_eq!( + updated.address, + "0x742d35Cc6634C0532925a3b8D8C2e48a73F6ba2E" + ); assert_eq!(updated.system_disabled, true); } } diff --git a/src/models/relayer/response.rs b/src/models/relayer/response.rs index 0f46fce0f..13549aac8 100644 --- a/src/models/relayer/response.rs +++ b/src/models/relayer/response.rs @@ -27,6 +27,30 @@ pub struct DeletePendingTransactionsResponse { pub total_processed: u32, } +/// Policy types for responses - these don't include network_type tags +/// since the network_type is already available at the top level of RelayerResponse +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +#[serde(untagged)] +pub enum RelayerNetworkPolicyResponse { + Evm(RelayerEvmPolicy), + Solana(RelayerSolanaPolicy), + Stellar(RelayerStellarPolicy), +} + +impl From for RelayerNetworkPolicyResponse { + fn from(policy: RelayerNetworkPolicy) -> Self { + match policy { + RelayerNetworkPolicy::Evm(evm_policy) => RelayerNetworkPolicyResponse::Evm(evm_policy), + RelayerNetworkPolicy::Solana(solana_policy) => { + RelayerNetworkPolicyResponse::Solana(solana_policy) + } + RelayerNetworkPolicy::Stellar(stellar_policy) => { + RelayerNetworkPolicyResponse::Stellar(stellar_policy) + } + } + } +} + /// Relayer response model for API endpoints #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] pub struct RelayerResponse { @@ -35,7 +59,10 @@ pub struct RelayerResponse { pub network: String, pub network_type: RelayerNetworkType, pub paused: bool, - pub policies: Option, + /// Policies without redundant network_type tag - network type is available at top level + /// Only included if user explicitly provided policies (not shown for empty/default policies) + #[serde(skip_serializing_if = "Option::is_none")] + pub policies: Option, pub signer_id: String, pub notification_id: Option, pub custom_rpc_urls: Option>, @@ -84,7 +111,7 @@ impl From for RelayerResponse { network: relayer.network, network_type: relayer.network_type, paused: relayer.paused, - policies: relayer.policies, + policies: relayer.policies.map(RelayerNetworkPolicyResponse::from), signer_id: relayer.signer_id, notification_id: relayer.notification_id, custom_rpc_urls: relayer.custom_rpc_urls, @@ -96,13 +123,20 @@ impl From for RelayerResponse { impl From for RelayerResponse { fn from(model: RelayerRepoModel) -> Self { + // Only include policies in response if they have actual user-provided values + let policies = if is_empty_policy(&model.policies) { + None // Don't return empty/default policies in API response + } else { + Some(RelayerNetworkPolicyResponse::from(model.policies)) + }; + Self { id: model.id, name: model.name, network: model.network, network_type: model.network_type.into(), paused: model.paused, - policies: Some(model.policies.into()), + policies, signer_id: model.signer_id, notification_id: model.notification_id, custom_rpc_urls: model.custom_rpc_urls, @@ -112,6 +146,38 @@ impl From for RelayerResponse { } } +/// Check if a policy is "empty" (all fields are None) indicating it's a default +fn is_empty_policy(policy: &RelayerNetworkPolicy) -> bool { + match policy { + RelayerNetworkPolicy::Evm(evm_policy) => { + evm_policy.min_balance.is_none() + && evm_policy.gas_limit_estimation.is_none() + && evm_policy.gas_price_cap.is_none() + && evm_policy.whitelist_receivers.is_none() + && evm_policy.eip1559_pricing.is_none() + && evm_policy.private_transactions.is_none() + } + RelayerNetworkPolicy::Solana(solana_policy) => { + solana_policy.allowed_programs.is_none() + && solana_policy.max_signatures.is_none() + && solana_policy.max_tx_data_size.is_none() + && solana_policy.min_balance.is_none() + && solana_policy.allowed_tokens.is_none() + && solana_policy.fee_payment_strategy.is_none() + && solana_policy.fee_margin_percentage.is_none() + && solana_policy.allowed_accounts.is_none() + && solana_policy.disallowed_accounts.is_none() + && solana_policy.max_allowed_fee_lamports.is_none() + && solana_policy.swap_config.is_none() + } + RelayerNetworkPolicy::Stellar(stellar_policy) => { + stellar_policy.min_balance.is_none() + && stellar_policy.max_fee.is_none() + && stellar_policy.timeout_seconds.is_none() + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -145,7 +211,17 @@ mod tests { assert_eq!(response.network, relayer.network); assert_eq!(response.network_type, relayer.network_type); assert_eq!(response.paused, relayer.paused); - assert_eq!(response.policies, relayer.policies); + assert_eq!( + response.policies, + Some(RelayerNetworkPolicyResponse::Evm(RelayerEvmPolicy { + gas_price_cap: Some(100_000_000_000), + whitelist_receivers: None, + eip1559_pricing: Some(true), + private_transactions: None, + min_balance: None, + gas_limit_estimation: None, + })) + ); assert_eq!(response.signer_id, relayer.signer_id); assert_eq!(response.notification_id, relayer.notification_id); assert_eq!(response.custom_rpc_urls, relayer.custom_rpc_urls); @@ -161,7 +237,7 @@ mod tests { network: "mainnet".to_string(), network_type: RelayerNetworkType::Evm, paused: false, - policies: Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + policies: Some(RelayerNetworkPolicyResponse::Evm(RelayerEvmPolicy { gas_price_cap: Some(100_000_000_000), whitelist_receivers: None, eip1559_pricing: Some(true), @@ -185,6 +261,117 @@ mod tests { assert_eq!(response.id, deserialized.id); assert_eq!(response.name, deserialized.name); } + + #[test] + fn test_response_without_redundant_network_type() { + let response = RelayerResponse { + id: "test-relayer".to_string(), + name: "Test Relayer".to_string(), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Evm, + paused: false, + policies: Some(RelayerNetworkPolicyResponse::Evm(RelayerEvmPolicy { + gas_price_cap: Some(100_000_000_000), + whitelist_receivers: None, + eip1559_pricing: Some(true), + private_transactions: None, + min_balance: None, + gas_limit_estimation: None, + })), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: Some("0x123...".to_string()), + system_disabled: Some(false), + }; + + let serialized = serde_json::to_string_pretty(&response).unwrap(); + + assert!(serialized.contains(r#""network_type": "evm""#)); + + // Count occurrences - should only be 1 (at top level) + let network_type_count = serialized.matches(r#""network_type""#).count(); + assert_eq!( + network_type_count, 1, + "Should only have one network_type field at top level, not in policies" + ); + + assert!(serialized.contains(r#""gas_price_cap": 100000000000"#)); + assert!(serialized.contains(r#""eip1559_pricing": true"#)); + } + + #[test] + fn test_empty_policies_not_returned_in_response() { + // Create a repository model with empty policies (all None - user didn't set any) + let repo_model = RelayerRepoModel { + id: "test-relayer".to_string(), + name: "Test Relayer".to_string(), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Evm, + paused: false, + policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()), // All None values + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: "0x123...".to_string(), + system_disabled: false, + }; + + // Convert to response + let response = RelayerResponse::from(repo_model); + + // Empty policies should not be included in response + assert_eq!(response.policies, None); + + // Verify serialization doesn't include policies field + let serialized = serde_json::to_string(&response).unwrap(); + assert!( + !serialized.contains("policies"), + "Empty policies should not appear in JSON response" + ); + } + + #[test] + fn test_user_provided_policies_returned_in_response() { + // Create a repository model with user-provided policies + let repo_model = RelayerRepoModel { + id: "test-relayer".to_string(), + name: "Test Relayer".to_string(), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Evm, + paused: false, + policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + gas_price_cap: Some(100_000_000_000), + eip1559_pricing: Some(true), + min_balance: None, // Some fields can still be None + gas_limit_estimation: None, + whitelist_receivers: None, + private_transactions: None, + }), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: "0x123...".to_string(), + system_disabled: false, + }; + + // Convert to response + let response = RelayerResponse::from(repo_model); + + // User-provided policies should be included in response + assert!(response.policies.is_some()); + + // Verify serialization includes policies field + let serialized = serde_json::to_string(&response).unwrap(); + assert!( + serialized.contains("policies"), + "User-provided policies should appear in JSON response" + ); + assert!( + serialized.contains("gas_price_cap"), + "User-provided policy values should appear in JSON response" + ); + } } /// Network policy response models for OpenAPI documentation From b9c98b60092f87d059f19483114cfd94108d35f7 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 23 Jul 2025 00:34:27 +0200 Subject: [PATCH 30/59] chore: implement merge patch logic for relayer --- src/api/controllers/relayer.rs | 24 +++-- src/api/routes/relayer.rs | 8 +- src/models/relayer/config.rs | 2 + src/models/relayer/mod.rs | 191 ++++++++++++++++++++++++++------- src/models/relayer/request.rs | 164 ++++++++++++++++++++-------- 5 files changed, 288 insertions(+), 101 deletions(-) diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 98f2d1138..7a29ea017 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -182,30 +182,35 @@ pub async fn create_relayer( /// The updated relayer information. pub async fn update_relayer( relayer_id: String, - request: UpdateRelayerRequest, + patch: serde_json::Value, state: web::ThinData, ) -> Result { let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; + // convert patch to UpdateRelayerRequest to validate + let update_request: UpdateRelayerRequest = serde_json::from_value(patch.clone()) + .map_err(|e| ApiError::BadRequest(format!("Invalid update request: {}", e)))?; + if relayer.system_disabled { return Err(ApiError::BadRequest("Relayer is disabled".to_string())); } - // check if notification exists (if provided) - if let Some(notification_id) = &request.notification_id { + // Check if notification exists (if setting one) by extracting from JSON patch + if let Some(notification_id) = update_request.notification_id { let _notification = state .notification_repository - .get_by_id(notification_id.clone()) + .get_by_id(notification_id.to_string()) .await?; } - // Apply update (with validation) - let updated: RelayerDomainModel = - RelayerDomainModel::from(relayer.clone()).apply_update(&request)?; + // Apply JSON merge patch directly to domain object + let updated_domain = RelayerDomainModel::from(relayer.clone()) + .apply_json_patch(&patch) + .map_err(ApiError::from)?; - // Use safe updater to preserve runtime fields automatically + // 3. Repository concern - Use existing RelayerRepoUpdater to preserve runtime fields let updated_repo_model = - RelayerRepoUpdater::from_existing(relayer).apply_domain_update(updated); + RelayerRepoUpdater::from_existing(relayer).apply_domain_update(updated_domain); let saved_relayer = state .relayer_repository @@ -213,7 +218,6 @@ pub async fn update_relayer( .await?; let relayer_response: RelayerResponse = saved_relayer.into(); - Ok(HttpResponse::Ok().json(ApiResponse::success(relayer_response))) } diff --git a/src/api/routes/relayer.rs b/src/api/routes/relayer.rs index 1dc0cfc39..622dbca6e 100644 --- a/src/api/routes/relayer.rs +++ b/src/api/routes/relayer.rs @@ -4,7 +4,7 @@ use crate::{ api::controllers::relayer, domain::{SignDataRequest, SignTypedDataRequest}, - models::{CreateRelayerRequest, DefaultAppState, PaginationQuery, UpdateRelayerRequest}, + models::{CreateRelayerRequest, DefaultAppState, PaginationQuery}, }; use actix_web::{delete, get, patch, post, put, web, Responder}; use serde::Deserialize; @@ -37,14 +37,14 @@ async fn create_relayer( relayer::create_relayer(request.into_inner(), data).await } -/// Updates a relayer's information based on the provided update request. +/// Updates a relayer's information using JSON Merge Patch (RFC 7396). #[patch("/relayers/{relayer_id}")] async fn update_relayer( relayer_id: web::Path, - update_req: web::Json, + patch: web::Json, data: web::ThinData, ) -> impl Responder { - relayer::update_relayer(relayer_id.into_inner(), update_req.into_inner(), data).await + relayer::update_relayer(relayer_id.into_inner(), patch.into_inner(), data).await } /// Deletes a relayer by ID. diff --git a/src/models/relayer/config.rs b/src/models/relayer/config.rs index 92187557c..b787dda89 100644 --- a/src/models/relayer/config.rs +++ b/src/models/relayer/config.rs @@ -321,6 +321,7 @@ impl TryFrom for Relayer { RelayerValidationError::InvalidRpcWeight => { ConfigFileError::InvalidFormat("RPC URL weight must be in range 0-100".to_string()) } + RelayerValidationError::InvalidField(msg) => ConfigFileError::InvalidFormat(msg), })?; Ok(relayer) @@ -472,6 +473,7 @@ impl RelayersFileConfig { RelayerValidationError::InvalidRpcWeight => ConfigFileError::InvalidFormat( "RPC URL weight must be in range 0-100".to_string(), ), + RelayerValidationError::InvalidField(msg) => ConfigFileError::InvalidFormat(msg), })?; if !ids.insert(relayer_config.id.clone()) { diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index 1113a24b5..531e721b0 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -14,7 +14,7 @@ mod config; pub use config::*; -mod request; +pub mod request; pub use request::*; mod response; @@ -599,53 +599,34 @@ impl Relayer { Ok(()) } - /// Applies an update request to create a new validated relayer + /// Apply JSON Merge Patch (RFC 7396) directly to the domain object /// - /// This method provides a domain-first approach where the core model handles - /// its own business rules and validation rather than having update logic - /// scattered across request models. + /// This method: + /// 1. Validates the patch by converting to typed UpdateRelayerRequest + /// 2. Converts domain object to JSON + /// 3. Applies JSON merge patch + /// 4. Converts back to domain object + /// 5. Validates the final result /// - /// # Arguments - /// * `request` - The update request containing partial data to apply - /// - /// # Returns - /// * `Ok(Relayer)` - A new validated relayer with updates applied - /// * `Err(RelayerValidationError)` - If the resulting relayer would be invalid - pub fn apply_update( + /// This approach provides true JSON Merge Patch semantics while maintaining validation. + pub fn apply_json_patch( &self, - request: &UpdateRelayerRequest, + patch: &serde_json::Value, ) -> Result { - let mut updated = self.clone(); - - // Apply updates from request - if let Some(name) = &request.name { - updated.name = name.clone(); - } - - if let Some(paused) = request.paused { - updated.paused = paused; - } + // 3. Convert current domain object to JSON + let mut domain_json = serde_json::to_value(self).map_err(|e| { + RelayerValidationError::InvalidField(format!("Serialization error: {}", e)) + })?; - if let Some(policies) = request - .to_domain_policies(self.network_type) - .map_err(|e| RelayerValidationError::InvalidPolicy(e.to_string()))? - { - updated.policies = Some(policies); - } + // 4. Apply JSON Merge Patch + json_patch::merge(&mut domain_json, patch); - if let Some(notification_id) = &request.notification_id { - updated.notification_id = if notification_id.is_empty() { - None - } else { - Some(notification_id.clone()) - }; - } - - if let Some(custom_rpc_urls) = &request.custom_rpc_urls { - updated.custom_rpc_urls = Some(custom_rpc_urls.clone()); - } + // 5. Convert back to domain object + let updated: Relayer = serde_json::from_value(domain_json).map_err(|e| { + RelayerValidationError::InvalidField(format!("Invalid result after patch: {}", e)) + })?; - // Validate the complete updated model + // 6. Validate the final result updated.validate()?; Ok(updated) @@ -671,6 +652,8 @@ pub enum RelayerValidationError { InvalidRpcUrl(String), #[error("RPC URL weight must be in range 0-100")] InvalidRpcWeight, + #[error("Invalid field: {0}")] + InvalidField(String), } /// Centralized conversion from RelayerValidationError to ApiError @@ -697,6 +680,132 @@ impl From for crate::models::ApiError { RelayerValidationError::InvalidRpcWeight => { "RPC URL weight must be in range 0-100".to_string() } + RelayerValidationError::InvalidField(msg) => msg.clone(), }) } } + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json::json; + + #[test] + fn test_apply_json_patch_comprehensive() { + // Create a sample relayer + let relayer = Relayer { + id: "test-relayer".to_string(), + name: "Original Name".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Evm, + policies: Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + min_balance: Some(1000000000000000000), + gas_limit_estimation: Some(true), + gas_price_cap: Some(50000000000), + whitelist_receivers: None, + eip1559_pricing: Some(false), + private_transactions: None, + })), + signer_id: "test-signer".to_string(), + notification_id: Some("old-notification".to_string()), + custom_rpc_urls: None, + }; + + // Create a JSON patch + let patch = json!({ + "name": "Updated Name via JSON Patch", + "paused": true, + "policies": { + "min_balance": "2000000000000000000", + "gas_price_cap": null, // Remove this field + "eip1559_pricing": true, // Update this field + "whitelist_receivers": ["0x123", "0x456"] // Add this field + // gas_limit_estimation not mentioned - should remain unchanged + }, + "notification_id": null, // Remove notification + "custom_rpc_urls": [{"url": "https://example.com", "weight": 100}] + }); + + // Apply the JSON patch - all logic now handled uniformly! + let updated_relayer = relayer.apply_json_patch(&patch).unwrap(); + + // Verify all updates were applied correctly + assert_eq!(updated_relayer.name, "Updated Name via JSON Patch"); + assert_eq!(updated_relayer.paused, true); + assert_eq!(updated_relayer.notification_id, None); // Removed + assert!(updated_relayer.custom_rpc_urls.is_some()); + + // Verify policy merge patch worked correctly + if let Some(RelayerNetworkPolicy::Evm(evm_policy)) = updated_relayer.policies { + assert_eq!(evm_policy.min_balance, Some(2000000000000000000)); // Updated + assert_eq!(evm_policy.gas_price_cap, None); // Removed (was null) + assert_eq!(evm_policy.eip1559_pricing, Some(true)); // Updated + assert_eq!(evm_policy.gas_limit_estimation, Some(true)); // Unchanged + assert_eq!( + evm_policy.whitelist_receivers, + Some(vec!["0x123".to_string(), "0x456".to_string()]) + ); // Added + assert_eq!(evm_policy.private_transactions, None); // Unchanged + } else { + panic!("Expected EVM policy"); + } + } + + #[test] + fn test_apply_json_patch_validation_failure() { + let relayer = Relayer { + id: "test-relayer".to_string(), + name: "Original Name".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Evm, + policies: None, + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + // Invalid patch - field that would make the result invalid + let invalid_patch = json!({ + "name": "" // Empty name should fail validation + }); + + // Should fail validation during final validation step + let result = relayer.apply_json_patch(&invalid_patch); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Relayer name cannot be empty")); + } + + #[test] + fn test_apply_json_patch_invalid_result() { + let relayer = Relayer { + id: "test-relayer".to_string(), + name: "Original Name".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Evm, + policies: None, + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + // Patch that would create an invalid structure + let invalid_patch = json!({ + "network_type": "invalid_type" // Invalid enum value + }); + + // Should fail when converting back to domain object + let result = relayer.apply_json_patch(&invalid_patch); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid result after patch")); + } +} diff --git a/src/models/relayer/request.rs b/src/models/relayer/request.rs index b45af28f8..67a73476e 100644 --- a/src/models/relayer/request.rs +++ b/src/models/relayer/request.rs @@ -147,32 +147,50 @@ impl UpdateRelayerPolicyRequest { /// Request model for updating an existing relayer /// All fields are optional to allow partial updates /// Note: network and signer_id are not updateable after creation +/// +/// ## Merge Patch Semantics for Policies +/// The policies field uses JSON Merge Patch (RFC 7396) semantics: +/// - Field not provided: no change to existing value +/// - Field with null value: remove/clear the field +/// - Field with value: update the field +/// - Empty object {}: no changes to any policy fields +/// +/// ## Merge Patch Semantics for notification_id +/// The notification_id field also uses JSON Merge Patch semantics: +/// - Field not provided: no change to existing value +/// - Field with null value: remove notification (set to None) +/// - Field with string value: set to that notification ID +/// +/// ## Example Usage +/// +/// ```json +/// // Update request examples: +/// { +/// "notification_id": null, // Remove notification +/// "policies": { "min_balance": null } // Remove min_balance policy +/// } +/// +/// { +/// "notification_id": "notif-123", // Set notification +/// "policies": { "min_balance": "2000000000000000000" } // Update min_balance +/// } +/// +/// { +/// "name": "Updated Name" // Only update name, leave others unchanged +/// } +/// ``` #[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)] #[serde(deny_unknown_fields)] pub struct UpdateRelayerRequest { pub name: Option, pub paused: Option, - /// Policies without network_type tag - will be validated against existing relayer's network type #[serde(skip_serializing_if = "Option::is_none")] pub policies: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub notification_id: Option, pub custom_rpc_urls: Option>, } -impl UpdateRelayerRequest { - /// Converts the policies field to domain RelayerNetworkPolicy using the provided network type - pub fn to_domain_policies( - &self, - network_type: RelayerNetworkType, - ) -> Result, ApiError> { - if let Some(policy_request) = &self.policies { - Ok(Some(policy_request.to_domain_policy(network_type)?)) - } else { - Ok(None) - } - } -} - impl TryFrom for Relayer { type Error = ApiError; @@ -205,7 +223,7 @@ impl TryFrom for Relayer { #[cfg(test)] mod tests { use super::*; - use crate::models::relayer::{Relayer, RelayerEvmPolicy, RelayerNetworkType}; + use crate::models::relayer::{RelayerEvmPolicy, RelayerSolanaPolicy}; #[test] fn test_valid_create_request() { @@ -398,11 +416,8 @@ mod tests { let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); assert!(request.policies.is_some()); - // Test policy conversion to domain type with EVM network type - let policies = request.to_domain_policies(RelayerNetworkType::Evm).unwrap(); - assert!(policies.is_some()); - - if let Some(RelayerNetworkPolicy::Evm(evm_policy)) = policies { + // Validation now happens automatically during deserialization + if let Some(UpdateRelayerPolicyRequest::Evm(evm_policy)) = request.policies { assert_eq!(evm_policy.gas_price_cap, Some(100000000000)); assert_eq!(evm_policy.eip1559_pricing, Some(true)); } else { @@ -422,13 +437,8 @@ mod tests { let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); - // Test policy conversion to domain type with Solana network type - let policies = request - .to_domain_policies(RelayerNetworkType::Solana) - .unwrap(); - assert!(policies.is_some()); - - if let Some(RelayerNetworkPolicy::Solana(solana_policy)) = policies { + // Validation now happens automatically during deserialization + if let Some(UpdateRelayerPolicyRequest::Solana(solana_policy)) = request.policies { assert_eq!(solana_policy.min_balance, Some(1000000)); } else { panic!("Expected Solana policy"); @@ -438,18 +448,19 @@ mod tests { #[test] fn test_update_request_invalid_policy_format() { // Test that invalid policy format fails during JSON deserialization - let json_input = r#"{ + let invalid_json = r#"{ + "name": "Test", "policies": "invalid_not_an_object" }"#; - // Should fail during JSON deserialization since policies expects an object - let result = serde_json::from_str::(json_input); + // Should fail during deserialization since policies should be objects with valid fields + let result = serde_json::from_str::(invalid_json); assert!(result.is_err()); } #[test] fn test_update_request_wrong_network_type() { - // Test that providing EVM policy for Solana relayer fails + // Test that EVM policy deserializes correctly as EVM type let json_input = r#"{ "policies": { "gas_price_cap": 100000000000, @@ -459,13 +470,13 @@ mod tests { let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); - // Should fail when converting EVM policy for Solana network type - let result = request.to_domain_policies(RelayerNetworkType::Solana); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Policy type does not match relayer network type")); + // Should correctly deserialize as EVM policy based on field detection + if let Some(UpdateRelayerPolicyRequest::Evm(evm_policy)) = request.policies { + assert_eq!(evm_policy.gas_price_cap, Some(100000000000)); + assert_eq!(evm_policy.eip1559_pricing, Some(true)); + } else { + panic!("Expected EVM policy to be auto-detected"); + } } #[test] @@ -481,13 +492,8 @@ mod tests { let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); - // Should correctly identify as Stellar policy - let policies = request - .to_domain_policies(RelayerNetworkType::Stellar) - .unwrap(); - assert!(policies.is_some()); - - if let Some(RelayerNetworkPolicy::Stellar(stellar_policy)) = policies { + // Should correctly deserialize as Stellar policy + if let Some(UpdateRelayerPolicyRequest::Stellar(stellar_policy)) = request.policies { assert_eq!(stellar_policy.max_fee, Some(10000)); assert_eq!(stellar_policy.timeout_seconds, Some(300)); assert_eq!(stellar_policy.min_balance, Some(5000000)); @@ -495,4 +501,70 @@ mod tests { panic!("Expected Stellar policy"); } } + + #[test] + fn test_notification_id_deserialization() { + // Test valid notification_id deserialization + let json_with_notification = r#"{ + "name": "Test Relayer", + "notification_id": "notif-123" + }"#; + + let request: UpdateRelayerRequest = serde_json::from_str(json_with_notification).unwrap(); + assert_eq!(request.notification_id, Some("notif-123".to_string())); + + // Test without notification_id + let json_without_notification = r#"{ + "name": "Test Relayer" + }"#; + + let request: UpdateRelayerRequest = + serde_json::from_str(json_without_notification).unwrap(); + assert_eq!(request.notification_id, None); + + // Test invalid notification_id type should fail deserialization + let invalid_json = r#"{ + "name": "Test Relayer", + "notification_id": 123 + }"#; + + let result = serde_json::from_str::(invalid_json); + assert!(result.is_err()); + } + + #[test] + fn test_comprehensive_update_request() { + // Test a comprehensive update request with multiple fields + let json_input = r#"{ + "name": "Updated Relayer", + "paused": true, + "notification_id": "new-notification-id", + "policies": { + "min_balance": "5000000000000000000", + "gas_limit_estimation": false + }, + "custom_rpc_urls": [ + {"url": "https://example.com", "weight": 100} + ] + }"#; + + let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); + + // Verify all fields are correctly deserialized + assert_eq!(request.name, Some("Updated Relayer".to_string())); + assert_eq!(request.paused, Some(true)); + assert_eq!( + request.notification_id, + Some("new-notification-id".to_string()) + ); + assert!(request.policies.is_some()); + assert!(request.custom_rpc_urls.is_some()); + + if let Some(UpdateRelayerPolicyRequest::Evm(evm_policy)) = request.policies { + assert_eq!(evm_policy.min_balance, Some(5000000000000000000)); + assert_eq!(evm_policy.gas_limit_estimation, Some(false)); + } else { + panic!("Expected EVM policy"); + } + } } From 615dda6e6169b175c04b2da05f90e53d73bb20dc Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 23 Jul 2025 01:22:34 +0200 Subject: [PATCH 31/59] chore: improvements --- src/api/controllers/relayer.rs | 17 +- src/models/relayer/mod.rs | 27 +-- src/models/relayer/request.rs | 411 +++++++++++++++++++++------------ 3 files changed, 288 insertions(+), 167 deletions(-) diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 7a29ea017..12827ceb9 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -17,10 +17,10 @@ use crate::{ SignTypedDataRequest, Transaction, }, models::{ - convert_to_internal_rpc_request, ApiError, ApiResponse, CreateRelayerRequest, - DefaultAppState, NetworkTransactionRequest, NetworkType, PaginationMeta, PaginationQuery, - Relayer as RelayerDomainModel, RelayerRepoModel, RelayerRepoUpdater, RelayerResponse, - TransactionResponse, UpdateRelayerRequest, + convert_to_internal_rpc_request, deserialize_policy_for_network_type, ApiError, + ApiResponse, CreateRelayerRequest, DefaultAppState, NetworkTransactionRequest, NetworkType, + PaginationMeta, PaginationQuery, Relayer as RelayerDomainModel, RelayerRepoModel, + RelayerRepoUpdater, RelayerResponse, TransactionResponse, UpdateRelayerRequestRaw, }, repositories::{NetworkRepository, RelayerRepository, Repository, TransactionRepository}, services::{Signer, SignerFactory}, @@ -188,16 +188,21 @@ pub async fn update_relayer( let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; // convert patch to UpdateRelayerRequest to validate - let update_request: UpdateRelayerRequest = serde_json::from_value(patch.clone()) + let update_request: UpdateRelayerRequestRaw = serde_json::from_value(patch.clone()) .map_err(|e| ApiError::BadRequest(format!("Invalid update request: {}", e)))?; + if let Some(policies) = update_request.policies { + deserialize_policy_for_network_type(&policies, relayer.network_type) + .map_err(|e| ApiError::BadRequest(format!("Invalid policy: {}", e)))?; + } + if relayer.system_disabled { return Err(ApiError::BadRequest("Relayer is disabled".to_string())); } // Check if notification exists (if setting one) by extracting from JSON patch if let Some(notification_id) = update_request.notification_id { - let _notification = state + state .notification_repository .get_by_id(notification_id.to_string()) .await?; diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index 531e721b0..b14587c6e 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -602,31 +602,30 @@ impl Relayer { /// Apply JSON Merge Patch (RFC 7396) directly to the domain object /// /// This method: - /// 1. Validates the patch by converting to typed UpdateRelayerRequest - /// 2. Converts domain object to JSON - /// 3. Applies JSON merge patch - /// 4. Converts back to domain object - /// 5. Validates the final result + /// 1. Converts domain object to JSON + /// 2. Applies JSON merge patch + /// 3. Converts back to domain object + /// 4. Validates the final result /// /// This approach provides true JSON Merge Patch semantics while maintaining validation. pub fn apply_json_patch( &self, patch: &serde_json::Value, ) -> Result { - // 3. Convert current domain object to JSON + // 1. Convert current domain object to JSON let mut domain_json = serde_json::to_value(self).map_err(|e| { RelayerValidationError::InvalidField(format!("Serialization error: {}", e)) })?; - // 4. Apply JSON Merge Patch + // 2. Apply JSON Merge Patch json_patch::merge(&mut domain_json, patch); - // 5. Convert back to domain object + // 3. Convert back to domain object let updated: Relayer = serde_json::from_value(domain_json).map_err(|e| { RelayerValidationError::InvalidField(format!("Invalid result after patch: {}", e)) })?; - // 6. Validate the final result + // 4. Validate the final result updated.validate()?; Ok(updated) @@ -803,9 +802,11 @@ mod tests { // Should fail when converting back to domain object let result = relayer.apply_json_patch(&invalid_patch); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Invalid result after patch")); + // The error now occurs during the initial validation step + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("Invalid patch format") + || error_msg.contains("Invalid result after patch") + ); } } diff --git a/src/models/relayer/request.rs b/src/models/relayer/request.rs index 67a73476e..8e7e379f9 100644 --- a/src/models/relayer/request.rs +++ b/src/models/relayer/request.rs @@ -16,12 +16,11 @@ use super::{ RelayerStellarPolicy, RpcConfig, }; use crate::{models::error::ApiError, utils::generate_uuid}; -use serde::{de, Deserialize, Deserializer, Serialize}; -use serde_json::Value; +use serde::{Deserialize, Serialize}; use utoipa::ToSchema; /// Request model for creating a new relayer -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, ToSchema)] #[serde(deny_unknown_fields)] pub struct CreateRelayerRequest { pub id: Option, @@ -29,112 +28,97 @@ pub struct CreateRelayerRequest { pub network: String, pub paused: bool, pub network_type: RelayerNetworkType, - /// Policies without network_type tag - will be validated against the network_type field + /// Policies - will be deserialized based on the network_type field #[serde(skip_serializing_if = "Option::is_none")] - pub policies: Option, + pub policies: Option, pub signer_id: String, pub notification_id: Option, pub custom_rpc_urls: Option>, } -impl CreateRelayerRequest { - /// Converts the policies field to domain RelayerNetworkPolicy using the network_type field - pub fn to_domain_policies(&self) -> Result, ApiError> { - if let Some(policy_request) = &self.policies { - Ok(Some(policy_request.to_domain_policy(self.network_type)?)) - } else { - Ok(None) - } - } -} - -/// Policy types for update requests - these don't require network_type tags -/// since they will be inferred from the existing relayer -#[derive(Debug, Clone, Serialize, PartialEq, ToSchema)] +/// Helper struct for deserializing CreateRelayerRequest with raw policies JSON +#[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] -pub enum UpdateRelayerPolicyRequest { - Evm(RelayerEvmPolicy), - Solana(RelayerSolanaPolicy), - Stellar(RelayerStellarPolicy), +struct CreateRelayerRequestRaw { + pub id: Option, + pub name: String, + pub network: String, + pub paused: bool, + pub network_type: RelayerNetworkType, + #[serde(skip_serializing_if = "Option::is_none")] + pub policies: Option, + pub signer_id: String, + pub notification_id: Option, + pub custom_rpc_urls: Option>, } -impl<'de> Deserialize<'de> for UpdateRelayerPolicyRequest { +impl<'de> serde::Deserialize<'de> for CreateRelayerRequest { fn deserialize(deserializer: D) -> Result where - D: Deserializer<'de>, + D: serde::Deserializer<'de>, { - let value: Value = Value::deserialize(deserializer)?; - - if let Value::Object(obj) = &value { - // Check for Solana-specific fields first (most distinctive) - if obj.contains_key("fee_payment_strategy") - || obj.contains_key("allowed_tokens") - || obj.contains_key("allowed_programs") - || obj.contains_key("max_signatures") - || obj.contains_key("max_tx_data_size") - || obj.contains_key("allowed_accounts") - || obj.contains_key("disallowed_accounts") - || obj.contains_key("max_allowed_fee_lamports") - || obj.contains_key("swap_config") - || obj.contains_key("fee_margin_percentage") - { - let policy: RelayerSolanaPolicy = - serde_json::from_value(value).map_err(de::Error::custom)?; - return Ok(UpdateRelayerPolicyRequest::Solana(policy)); - } - - // Check for EVM-specific fields - if obj.contains_key("gas_price_cap") - || obj.contains_key("gas_limit_estimation") - || obj.contains_key("whitelist_receivers") - || obj.contains_key("eip1559_pricing") - || obj.contains_key("private_transactions") - { - let policy: RelayerEvmPolicy = - serde_json::from_value(value).map_err(de::Error::custom)?; - return Ok(UpdateRelayerPolicyRequest::Evm(policy)); - } - - // Check for Stellar-specific fields - if obj.contains_key("max_fee") || obj.contains_key("timeout_seconds") { - let policy: RelayerStellarPolicy = - serde_json::from_value(value).map_err(de::Error::custom)?; - return Ok(UpdateRelayerPolicyRequest::Stellar(policy)); - } - - // If only min_balance is present, we can't determine the type automatically - // Try each type and see which one deserializes successfully - if let Ok(policy) = serde_json::from_value::(value.clone()) { - return Ok(UpdateRelayerPolicyRequest::Evm(policy)); - } - if let Ok(policy) = serde_json::from_value::(value.clone()) { - return Ok(UpdateRelayerPolicyRequest::Solana(policy)); - } - if let Ok(policy) = serde_json::from_value::(value) { - return Ok(UpdateRelayerPolicyRequest::Stellar(policy)); - } - } + let raw = CreateRelayerRequestRaw::deserialize(deserializer)?; + + // Convert policies based on network_type using the existing utility function + let policies = if let Some(policies_value) = raw.policies { + let domain_policy = + deserialize_policy_for_network_type(&policies_value, raw.network_type) + .map_err(serde::de::Error::custom)?; + + // Convert from RelayerNetworkPolicy to CreateRelayerPolicyRequest + let policy = match domain_policy { + RelayerNetworkPolicy::Evm(evm_policy) => { + CreateRelayerPolicyRequest::Evm(evm_policy) + } + RelayerNetworkPolicy::Solana(solana_policy) => { + CreateRelayerPolicyRequest::Solana(solana_policy) + } + RelayerNetworkPolicy::Stellar(stellar_policy) => { + CreateRelayerPolicyRequest::Stellar(stellar_policy) + } + }; + Some(policy) + } else { + None + }; - Err(de::Error::custom( - "Unable to determine policy type from provided fields", - )) + Ok(CreateRelayerRequest { + id: raw.id, + name: raw.name, + network: raw.network, + paused: raw.paused, + network_type: raw.network_type, + policies, + signer_id: raw.signer_id, + notification_id: raw.notification_id, + custom_rpc_urls: raw.custom_rpc_urls, + }) } } -impl UpdateRelayerPolicyRequest { +/// Policy types for create requests - deserialized based on network_type from parent request +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] +#[serde(deny_unknown_fields)] +pub enum CreateRelayerPolicyRequest { + Evm(RelayerEvmPolicy), + Solana(RelayerSolanaPolicy), + Stellar(RelayerStellarPolicy), +} + +impl CreateRelayerPolicyRequest { /// Converts to domain RelayerNetworkPolicy using the provided network type pub fn to_domain_policy( &self, network_type: RelayerNetworkType, ) -> Result { match (self, network_type) { - (UpdateRelayerPolicyRequest::Evm(policy), RelayerNetworkType::Evm) => { + (CreateRelayerPolicyRequest::Evm(policy), RelayerNetworkType::Evm) => { Ok(RelayerNetworkPolicy::Evm(policy.clone())) } - (UpdateRelayerPolicyRequest::Solana(policy), RelayerNetworkType::Solana) => { + (CreateRelayerPolicyRequest::Solana(policy), RelayerNetworkType::Solana) => { Ok(RelayerNetworkPolicy::Solana(policy.clone())) } - (UpdateRelayerPolicyRequest::Stellar(policy), RelayerNetworkType::Stellar) => { + (CreateRelayerPolicyRequest::Stellar(policy), RelayerNetworkType::Stellar) => { Ok(RelayerNetworkPolicy::Stellar(policy.clone())) } _ => Err(ApiError::BadRequest( @@ -144,6 +128,45 @@ impl UpdateRelayerPolicyRequest { } } +/// Utility function to deserialize policy JSON for a specific network type +/// Used for update requests where we know the network type ahead of time +pub fn deserialize_policy_for_network_type( + policies_value: &serde_json::Value, + network_type: RelayerNetworkType, +) -> Result { + match network_type { + RelayerNetworkType::Evm => { + let evm_policy: RelayerEvmPolicy = serde_json::from_value(policies_value.clone()) + .map_err(|e| ApiError::BadRequest(format!("Invalid EVM policy: {}", e)))?; + Ok(RelayerNetworkPolicy::Evm(evm_policy)) + } + RelayerNetworkType::Solana => { + let solana_policy: RelayerSolanaPolicy = serde_json::from_value(policies_value.clone()) + .map_err(|e| ApiError::BadRequest(format!("Invalid Solana policy: {}", e)))?; + Ok(RelayerNetworkPolicy::Solana(solana_policy)) + } + RelayerNetworkType::Stellar => { + let stellar_policy: RelayerStellarPolicy = + serde_json::from_value(policies_value.clone()) + .map_err(|e| ApiError::BadRequest(format!("Invalid Stellar policy: {}", e)))?; + Ok(RelayerNetworkPolicy::Stellar(stellar_policy)) + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct UpdateRelayerRequest { + pub name: Option, + pub paused: Option, + /// Raw policy JSON - will be validated against relayer's network type during application + #[serde(skip_serializing_if = "Option::is_none")] + pub policies: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notification_id: Option, + pub custom_rpc_urls: Option>, +} + /// Request model for updating an existing relayer /// All fields are optional to allow partial updates /// Note: network and signer_id are not updateable after creation @@ -181,11 +204,12 @@ impl UpdateRelayerPolicyRequest { /// ``` #[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)] #[serde(deny_unknown_fields)] -pub struct UpdateRelayerRequest { +pub struct UpdateRelayerRequestRaw { pub name: Option, pub paused: Option, + /// Raw policy JSON - will be validated against relayer's network type during application #[serde(skip_serializing_if = "Option::is_none")] - pub policies: Option, + pub policies: Option, #[serde(skip_serializing_if = "Option::is_none")] pub notification_id: Option, pub custom_rpc_urls: Option>, @@ -197,8 +221,12 @@ impl TryFrom for Relayer { fn try_from(request: CreateRelayerRequest) -> Result { let id = request.id.clone().unwrap_or_else(|| generate_uuid()); - // Convert policies using the network_type from the request - let policies = request.to_domain_policies()?; + // Convert policies directly using the typed policy request + let policies = if let Some(policy_request) = &request.policies { + Some(policy_request.to_domain_policy(request.network_type)?) + } else { + None + }; // Create domain relayer let relayer = Relayer::new( @@ -233,7 +261,7 @@ mod tests { network: "mainnet".to_string(), paused: false, network_type: RelayerNetworkType::Evm, - policies: Some(UpdateRelayerPolicyRequest::Evm(RelayerEvmPolicy { + policies: Some(CreateRelayerPolicyRequest::Evm(RelayerEvmPolicy { gas_price_cap: Some(100), whitelist_receivers: None, eip1559_pricing: Some(true), @@ -279,7 +307,7 @@ mod tests { network: "mainnet".to_string(), paused: false, network_type: RelayerNetworkType::Solana, - policies: Some(UpdateRelayerPolicyRequest::Solana(RelayerSolanaPolicy { + policies: Some(CreateRelayerPolicyRequest::Solana(RelayerSolanaPolicy { fee_payment_strategy: Some( crate::models::relayer::RelayerSolanaFeePaymentStrategy::Relayer, ), @@ -300,13 +328,17 @@ mod tests { }; // Test policy conversion - let policies = request.to_domain_policies().unwrap(); - assert!(policies.is_some()); - - if let Some(RelayerNetworkPolicy::Solana(solana_policy)) = policies { - assert_eq!(solana_policy.min_balance, Some(1000000)); + if let Some(policy_request) = &request.policies { + let policy = policy_request + .to_domain_policy(request.network_type) + .unwrap(); + if let RelayerNetworkPolicy::Solana(solana_policy) = policy { + assert_eq!(solana_policy.min_balance, Some(1000000)); + } else { + panic!("Expected Solana policy"); + } } else { - panic!("Expected Solana policy"); + panic!("Expected policies to be present"); } // Test full conversion to domain relayer @@ -323,7 +355,7 @@ mod tests { network: "mainnet".to_string(), paused: false, network_type: RelayerNetworkType::Evm, // EVM network type - policies: Some(UpdateRelayerPolicyRequest::Solana( + policies: Some(CreateRelayerPolicyRequest::Solana( RelayerSolanaPolicy::default(), )), // But Solana policy signer_id: "test-signer".to_string(), @@ -331,13 +363,18 @@ mod tests { custom_rpc_urls: None, }; - // Should fail during policy conversion - let result = request.to_domain_policies(); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Policy type does not match relayer network type")); + // Should fail during policy conversion - since the policy was auto-detected as Solana + // but the network type is EVM, the conversion should fail + if let Some(policy_request) = &request.policies { + let result = policy_request.to_domain_policy(request.network_type); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Policy type does not match relayer network type")); + } else { + panic!("Expected policies to be present"); + } } #[test] @@ -374,7 +411,7 @@ mod tests { #[test] fn test_valid_update_request() { - let request = UpdateRelayerRequest { + let request = UpdateRelayerRequestRaw { name: Some("Updated Name".to_string()), paused: Some(true), policies: None, @@ -389,7 +426,7 @@ mod tests { #[test] fn test_update_request_all_none() { - let request = UpdateRelayerRequest { + let request = UpdateRelayerRequestRaw { name: None, paused: None, policies: None, @@ -413,15 +450,21 @@ mod tests { } }"#; - let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); + let request: UpdateRelayerRequestRaw = serde_json::from_str(json_input).unwrap(); assert!(request.policies.is_some()); - // Validation now happens automatically during deserialization - if let Some(UpdateRelayerPolicyRequest::Evm(evm_policy)) = request.policies { - assert_eq!(evm_policy.gas_price_cap, Some(100000000000)); - assert_eq!(evm_policy.eip1559_pricing, Some(true)); - } else { - panic!("Expected EVM policy"); + // Validation happens during domain conversion based on network type + // Test with the utility function + if let Some(policies_json) = &request.policies { + let network_policy = + deserialize_policy_for_network_type(policies_json, RelayerNetworkType::Evm) + .unwrap(); + if let RelayerNetworkPolicy::Evm(evm_policy) = network_policy { + assert_eq!(evm_policy.gas_price_cap, Some(100000000000)); + assert_eq!(evm_policy.eip1559_pricing, Some(true)); + } else { + panic!("Expected EVM policy"); + } } } @@ -435,27 +478,38 @@ mod tests { } }"#; - let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); - - // Validation now happens automatically during deserialization - if let Some(UpdateRelayerPolicyRequest::Solana(solana_policy)) = request.policies { - assert_eq!(solana_policy.min_balance, Some(1000000)); - } else { - panic!("Expected Solana policy"); + let request: UpdateRelayerRequestRaw = serde_json::from_str(json_input).unwrap(); + + // Validation happens during domain conversion based on network type + // Test with the utility function for Solana + if let Some(policies_json) = &request.policies { + let network_policy = + deserialize_policy_for_network_type(policies_json, RelayerNetworkType::Solana) + .unwrap(); + if let RelayerNetworkPolicy::Solana(solana_policy) = network_policy { + assert_eq!(solana_policy.min_balance, Some(1000000)); + } else { + panic!("Expected Solana policy"); + } } } #[test] fn test_update_request_invalid_policy_format() { - // Test that invalid policy format fails during JSON deserialization - let invalid_json = r#"{ + // Test that invalid policy format fails during validation with utility function + let valid_json = r#"{ "name": "Test", "policies": "invalid_not_an_object" }"#; - // Should fail during deserialization since policies should be objects with valid fields - let result = serde_json::from_str::(invalid_json); - assert!(result.is_err()); + let request: UpdateRelayerRequestRaw = serde_json::from_str(valid_json).unwrap(); + + // Should fail when trying to validate the policy against a network type + if let Some(policies_json) = &request.policies { + let result = + deserialize_policy_for_network_type(policies_json, RelayerNetworkType::Evm); + assert!(result.is_err()); + } } #[test] @@ -468,15 +522,10 @@ mod tests { } }"#; - let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); + let request: UpdateRelayerRequestRaw = serde_json::from_str(json_input).unwrap(); - // Should correctly deserialize as EVM policy based on field detection - if let Some(UpdateRelayerPolicyRequest::Evm(evm_policy)) = request.policies { - assert_eq!(evm_policy.gas_price_cap, Some(100000000000)); - assert_eq!(evm_policy.eip1559_pricing, Some(true)); - } else { - panic!("Expected EVM policy to be auto-detected"); - } + // Should correctly deserialize as raw JSON - validation happens during domain conversion + assert!(request.policies.is_some()); } #[test] @@ -490,16 +539,10 @@ mod tests { } }"#; - let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); + let request: UpdateRelayerRequestRaw = serde_json::from_str(json_input).unwrap(); - // Should correctly deserialize as Stellar policy - if let Some(UpdateRelayerPolicyRequest::Stellar(stellar_policy)) = request.policies { - assert_eq!(stellar_policy.max_fee, Some(10000)); - assert_eq!(stellar_policy.timeout_seconds, Some(300)); - assert_eq!(stellar_policy.min_balance, Some(5000000)); - } else { - panic!("Expected Stellar policy"); - } + // Should correctly deserialize as raw JSON - validation happens during domain conversion + assert!(request.policies.is_some()); } #[test] @@ -510,7 +553,8 @@ mod tests { "notification_id": "notif-123" }"#; - let request: UpdateRelayerRequest = serde_json::from_str(json_with_notification).unwrap(); + let request: UpdateRelayerRequestRaw = + serde_json::from_str(json_with_notification).unwrap(); assert_eq!(request.notification_id, Some("notif-123".to_string())); // Test without notification_id @@ -518,7 +562,7 @@ mod tests { "name": "Test Relayer" }"#; - let request: UpdateRelayerRequest = + let request: UpdateRelayerRequestRaw = serde_json::from_str(json_without_notification).unwrap(); assert_eq!(request.notification_id, None); @@ -528,7 +572,7 @@ mod tests { "notification_id": 123 }"#; - let result = serde_json::from_str::(invalid_json); + let result = serde_json::from_str::(invalid_json); assert!(result.is_err()); } @@ -548,7 +592,7 @@ mod tests { ] }"#; - let request: UpdateRelayerRequest = serde_json::from_str(json_input).unwrap(); + let request: UpdateRelayerRequestRaw = serde_json::from_str(json_input).unwrap(); // Verify all fields are correctly deserialized assert_eq!(request.name, Some("Updated Relayer".to_string())); @@ -560,11 +604,82 @@ mod tests { assert!(request.policies.is_some()); assert!(request.custom_rpc_urls.is_some()); - if let Some(UpdateRelayerPolicyRequest::Evm(evm_policy)) = request.policies { - assert_eq!(evm_policy.min_balance, Some(5000000000000000000)); - assert_eq!(evm_policy.gas_limit_estimation, Some(false)); + // Policies are now raw JSON - validation happens during domain conversion + if let Some(policies_json) = &request.policies { + // Just verify it's a JSON object with expected fields + assert!(policies_json.get("min_balance").is_some()); + assert!(policies_json.get("gas_limit_estimation").is_some()); + } else { + panic!("Expected policies"); + } + } + + #[test] + fn test_create_request_network_type_based_policy_deserialization() { + // Test that policies are correctly deserialized based on network_type + // EVM network with EVM policy fields + let evm_json = r#"{ + "name": "EVM Relayer", + "network": "mainnet", + "paused": false, + "network_type": "evm", + "signer_id": "test-signer", + "policies": { + "gas_price_cap": 50000000000, + "eip1559_pricing": true, + "min_balance": "1000000000000000000" + } + }"#; + + let evm_request: CreateRelayerRequest = serde_json::from_str(evm_json).unwrap(); + assert_eq!(evm_request.network_type, RelayerNetworkType::Evm); + + if let Some(CreateRelayerPolicyRequest::Evm(evm_policy)) = evm_request.policies { + assert_eq!(evm_policy.gas_price_cap, Some(50000000000)); + assert_eq!(evm_policy.eip1559_pricing, Some(true)); + assert_eq!(evm_policy.min_balance, Some(1000000000000000000)); } else { panic!("Expected EVM policy"); } + + // Solana network with Solana policy fields + let solana_json = r#"{ + "name": "Solana Relayer", + "network": "mainnet", + "paused": false, + "network_type": "solana", + "signer_id": "test-signer", + "policies": { + "fee_payment_strategy": "relayer", + "min_balance": 5000000, + "max_signatures": 10 + } + }"#; + + let solana_request: CreateRelayerRequest = serde_json::from_str(solana_json).unwrap(); + assert_eq!(solana_request.network_type, RelayerNetworkType::Solana); + + if let Some(CreateRelayerPolicyRequest::Solana(solana_policy)) = solana_request.policies { + assert_eq!(solana_policy.min_balance, Some(5000000)); + assert_eq!(solana_policy.max_signatures, Some(10)); + } else { + panic!("Expected Solana policy"); + } + + // Test that wrong policy fields for network type fails + let invalid_json = r#"{ + "name": "Invalid Relayer", + "network": "mainnet", + "paused": false, + "network_type": "evm", + "signer_id": "test-signer", + "policies": { + "fee_payment_strategy": "relayer" + } + }"#; + + let result = serde_json::from_str::(invalid_json); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("unknown field")); } } From eac5c065239a916f7281a423c8267e68fea927cb Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 23 Jul 2025 13:47:53 +0200 Subject: [PATCH 32/59] chore: improvements --- src/api/controllers/signer.rs | 38 +- src/models/relayer/mod.rs | 18 +- src/models/relayer/response.rs | 2 + src/models/signer/repository.rs | 51 +- src/models/signer/request.rs | 638 ++++++++++++++++-------- src/models/transaction/repository.rs | 17 +- src/models/transaction/response.rs | 20 +- src/repositories/redis_base.rs | 291 ++++++----- src/repositories/signer/signer_redis.rs | 4 +- src/utils/serde/mod.rs | 1 + src/utils/serde/u128_deserializer.rs | 81 ++- 11 files changed, 773 insertions(+), 388 deletions(-) diff --git a/src/api/controllers/signer.rs b/src/api/controllers/signer.rs index 0451c01a5..d4fe3546b 100644 --- a/src/api/controllers/signer.rs +++ b/src/api/controllers/signer.rs @@ -261,30 +261,38 @@ mod tests { signer_type: SignerType, ) -> SignerCreateRequest { use crate::models::{ - AwsKmsSignerRequestConfig, PlainSignerRequestConfig, SignerConfigRequest, + AwsKmsSignerRequestConfig, LocalSignerRequestConfig, SignerConfigRequest, + SignerTypeRequest, }; - let config = match signer_type { - SignerType::Local => SignerConfigRequest::Local { - config: PlainSignerRequestConfig { + let (signer_type_req, config) = match signer_type { + SignerType::Local => ( + SignerTypeRequest::Local, + SignerConfigRequest::Local(LocalSignerRequestConfig { key: "1111111111111111111111111111111111111111111111111111111111111111" .to_string(), // Valid 32-byte hex key - }, - }, - SignerType::AwsKms => SignerConfigRequest::AwsKms { - config: AwsKmsSignerRequestConfig { + }), + ), + SignerType::AwsKms => ( + SignerTypeRequest::AwsKms, + SignerConfigRequest::AwsKms(AwsKmsSignerRequestConfig { region: "us-east-1".to_string(), key_id: "test-key-id".to_string(), - }, - }, - _ => SignerConfigRequest::Local { - config: PlainSignerRequestConfig { + }), + ), + _ => ( + SignerTypeRequest::Local, + SignerConfigRequest::Local(LocalSignerRequestConfig { key: "placeholder-key".to_string(), - }, - }, // Use Local for other types in helper + }), + ), // Use Local for other types in helper }; - SignerCreateRequest { id, config } + SignerCreateRequest { + id, + signer_type: signer_type_req, + config, + } } /// Helper function to create a test signer update request diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index b14587c6e..1e331cbd4 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -26,7 +26,11 @@ pub use repository::*; mod rpc_config; pub use rpc_config::*; -use crate::{config::ConfigFileNetworkType, constants::ID_REGEX, utils::deserialize_optional_u128}; +use crate::{ + config::ConfigFileNetworkType, + constants::ID_REGEX, + utils::{deserialize_optional_u128, serialize_optional_u128}, +}; use apalis_cron::Schedule; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -78,12 +82,20 @@ impl From for ConfigFileNetworkType { #[serde(deny_unknown_fields)] pub struct RelayerEvmPolicy { #[serde(skip_serializing_if = "Option::is_none")] - #[serde(deserialize_with = "deserialize_optional_u128", default)] + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128", + default + )] pub min_balance: Option, #[serde(skip_serializing_if = "Option::is_none")] pub gas_limit_estimation: Option, #[serde(skip_serializing_if = "Option::is_none")] - #[serde(deserialize_with = "deserialize_optional_u128", default)] + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128", + default + )] pub gas_price_cap: Option, #[serde(skip_serializing_if = "Option::is_none")] pub whitelist_receivers: Option>, diff --git a/src/models/relayer/response.rs b/src/models/relayer/response.rs index 13549aac8..ed1428b00 100644 --- a/src/models/relayer/response.rs +++ b/src/models/relayer/response.rs @@ -296,6 +296,8 @@ mod tests { "Should only have one network_type field at top level, not in policies" ); + println!("serialized: {:?}", serialized); + assert!(serialized.contains(r#""gas_price_cap": 100000000000"#)); assert!(serialized.contains(r#""eip1559_pricing": true"#)); } diff --git a/src/models/signer/repository.rs b/src/models/signer/repository.rs index a7b840371..864428bc0 100644 --- a/src/models/signer/repository.rs +++ b/src/models/signer/repository.rs @@ -23,7 +23,7 @@ use crate::{ utils::{base64_decode, base64_encode}, }; use secrets::SecretVec; -use serde::{Deserialize, Serialize, Serializer}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Helper function to serialize secrets as base64 for storage fn serialize_secret_base64(secret: &SecretVec, serializer: S) -> Result @@ -34,6 +34,19 @@ where serializer.serialize_str(&base64) } +/// Helper function to deserialize secrets from base64 storage +fn deserialize_secret_base64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let base64_str = String::deserialize(deserializer)?; + let decoded = base64_decode(&base64_str) + .map_err(|e| serde::de::Error::custom(format!("Invalid base64: {}", e)))?; + Ok(SecretVec::new(decoded.len(), |v| { + v.copy_from_slice(&decoded) + })) +} + /// Repository model for signer storage and retrieval #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignerRepoModel { @@ -60,31 +73,15 @@ pub enum SignerConfigStorage { } /// Local signer configuration for storage (with base64 encoding) -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalSignerConfigStorage { - #[serde(serialize_with = "serialize_secret_base64")] + #[serde( + serialize_with = "serialize_secret_base64", + deserialize_with = "deserialize_secret_base64" + )] pub raw_key: SecretVec, } -impl<'de> Deserialize<'de> for LocalSignerConfigStorage { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct LocalSignerConfigHelper { - raw_key: String, - } - - let helper = LocalSignerConfigHelper::deserialize(deserializer)?; - let decoded = base64_decode(&helper.raw_key) - .map_err(|e| serde::de::Error::custom(format!("Invalid base64: {}", e)))?; - let raw_key = SecretVec::new(decoded.len(), |v| v.copy_from_slice(&decoded)); - - Ok(LocalSignerConfigStorage { raw_key }) - } -} - /// Storage representations for other signer types (these are simpler as they don't contain secrets that need encoding) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AwsKmsSignerConfigStorage { @@ -170,6 +167,16 @@ impl From for SignerRepoModelStorage { } } +/// Convert storage model back to repository model +impl From for SignerRepoModel { + fn from(storage_model: SignerRepoModelStorage) -> Self { + Self { + id: storage_model.id, + config: storage_model.config.into(), + } + } +} + /// Convert from repository model to domain model impl From for Signer { fn from(repo_model: SignerRepoModel) -> Self { diff --git a/src/models/signer/request.rs b/src/models/signer/request.rs index bd678961a..115502982 100644 --- a/src/models/signer/request.rs +++ b/src/models/signer/request.rs @@ -19,9 +19,10 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use zeroize::Zeroize; -/// AWS KMS signer configuration for API requests +/// Local signer configuration for API requests #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] -pub struct PlainSignerRequestConfig { +#[serde(deny_unknown_fields)] +pub struct LocalSignerRequestConfig { pub key: String, } @@ -103,32 +104,38 @@ pub struct GoogleCloudKmsSignerRequestConfig { pub key: GoogleCloudKmsSignerKeyRequestConfig, } -/// Signer configuration enum for API requests +/// Signer configuration enum for API requests (without type discriminator) #[derive(Debug, Serialize, Deserialize, ToSchema, Zeroize)] -#[serde(tag = "type", rename_all = "lowercase")] +#[serde(untagged)] pub enum SignerConfigRequest { + Local(LocalSignerRequestConfig), + AwsKms(AwsKmsSignerRequestConfig), + Vault(VaultSignerRequestConfig), + VaultTransit(VaultTransitSignerRequestConfig), + Turnkey(TurnkeySignerRequestConfig), + GoogleCloudKms(GoogleCloudKmsSignerRequestConfig), +} + +/// Signer type enum for API requests +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum SignerTypeRequest { #[serde(rename = "plain")] - Local { - config: PlainSignerRequestConfig, - }, + Local, #[serde(rename = "aws_kms")] - AwsKms { - config: AwsKmsSignerRequestConfig, - }, - Vault { - config: VaultSignerRequestConfig, - }, + AwsKms, + Vault, #[serde(rename = "vault_transit")] - VaultTransit { - config: VaultTransitSignerRequestConfig, - }, - Turnkey { - config: TurnkeySignerRequestConfig, - }, + VaultTransit, + Turnkey, #[serde(rename = "google_cloud_kms")] - GoogleCloudKms { - config: GoogleCloudKmsSignerRequestConfig, - }, + GoogleCloudKms, +} + +impl zeroize::Zeroize for SignerTypeRequest { + fn zeroize(&mut self) { + // No sensitive data to zeroize in this enum + } } /// Request model for creating a new signer @@ -137,8 +144,10 @@ pub enum SignerConfigRequest { pub struct SignerCreateRequest { /// Optional ID - if not provided, a UUID will be generated pub id: Option, - /// The signer configuration including type and config data - #[serde(flatten)] + /// The type of signer + #[serde(rename = "type")] + pub signer_type: SignerTypeRequest, + /// The signer configuration pub config: SignerConfigRequest, } @@ -148,54 +157,6 @@ pub struct SignerCreateRequest { #[serde(deny_unknown_fields)] pub struct SignerUpdateRequest {} -impl From for AwsKmsSignerConfig { - fn from(config: AwsKmsSignerRequestConfig) -> Self { - Self { - region: Some(config.region), - key_id: config.key_id, - } - } -} - -impl From for VaultSignerConfig { - fn from(config: VaultSignerRequestConfig) -> Self { - Self { - address: config.address, - namespace: config.namespace, - role_id: SecretString::new(&config.role_id), - secret_id: SecretString::new(&config.secret_id), - key_name: config.key_name, - mount_point: config.mount_point, - } - } -} - -impl From for VaultTransitSignerConfig { - fn from(config: VaultTransitSignerRequestConfig) -> Self { - Self { - key_name: config.key_name, - address: config.address, - namespace: config.namespace, - role_id: SecretString::new(&config.role_id), - secret_id: SecretString::new(&config.secret_id), - pubkey: config.pubkey, - mount_point: config.mount_point, - } - } -} - -impl From for TurnkeySignerConfig { - fn from(config: TurnkeySignerRequestConfig) -> Self { - Self { - api_public_key: config.api_public_key, - api_private_key: SecretString::new(&config.api_private_key), - organization_id: config.organization_id, - private_key_id: config.private_key_id, - public_key: config.public_key, - } - } -} - impl From for GoogleCloudKmsSignerServiceAccountConfig { @@ -226,23 +187,14 @@ impl From for GoogleCloudKmsSignerKeyConfi } } -impl From for GoogleCloudKmsSignerConfig { - fn from(config: GoogleCloudKmsSignerRequestConfig) -> Self { - Self { - service_account: config.service_account.into(), - key: config.key.into(), - } - } -} - impl TryFrom for SignerConfig { type Error = ApiError; fn try_from(config: SignerConfigRequest) -> Result { let domain_config = match config { - SignerConfigRequest::Local { config } => { + SignerConfigRequest::Local(local_config) => { // Decode hex string to raw bytes for cryptographic key - let key_bytes = hex::decode(&config.key) + let key_bytes = hex::decode(&local_config.key) .map_err(|e| ApiError::BadRequest(format!( "Invalid hex key format: {}. Key must be a 64-character hex string (32 bytes).", e )))?; @@ -253,14 +205,43 @@ impl TryFrom for SignerConfig { SignerConfig::Local(LocalSignerConfig { raw_key }) } - SignerConfigRequest::AwsKms { config } => SignerConfig::AwsKms(config.into()), - SignerConfigRequest::Vault { config } => SignerConfig::Vault(config.into()), - SignerConfigRequest::VaultTransit { config } => { - SignerConfig::VaultTransit(config.into()) + SignerConfigRequest::AwsKms(aws_config) => SignerConfig::AwsKms(AwsKmsSignerConfig { + region: Some(aws_config.region), + key_id: aws_config.key_id, + }), + SignerConfigRequest::Vault(vault_config) => SignerConfig::Vault(VaultSignerConfig { + address: vault_config.address, + namespace: vault_config.namespace, + role_id: SecretString::new(&vault_config.role_id), + secret_id: SecretString::new(&vault_config.secret_id), + key_name: vault_config.key_name, + mount_point: vault_config.mount_point, + }), + SignerConfigRequest::VaultTransit(vault_transit_config) => { + SignerConfig::VaultTransit(VaultTransitSignerConfig { + key_name: vault_transit_config.key_name, + address: vault_transit_config.address, + namespace: vault_transit_config.namespace, + role_id: SecretString::new(&vault_transit_config.role_id), + secret_id: SecretString::new(&vault_transit_config.secret_id), + pubkey: vault_transit_config.pubkey, + mount_point: vault_transit_config.mount_point, + }) } - SignerConfigRequest::Turnkey { config } => SignerConfig::Turnkey(config.into()), - SignerConfigRequest::GoogleCloudKms { config } => { - SignerConfig::GoogleCloudKms(config.into()) + SignerConfigRequest::Turnkey(turnkey_config) => { + SignerConfig::Turnkey(TurnkeySignerConfig { + api_public_key: turnkey_config.api_public_key, + api_private_key: SecretString::new(&turnkey_config.api_private_key), + organization_id: turnkey_config.organization_id, + private_key_id: turnkey_config.private_key_id, + public_key: turnkey_config.public_key, + }) + } + SignerConfigRequest::GoogleCloudKms(gcp_kms_config) => { + SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { + service_account: gcp_kms_config.service_account.into(), + key: gcp_kms_config.key.into(), + }) } }; @@ -280,6 +261,24 @@ impl TryFrom for Signer { .id .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + // Validate that the signer type matches the config variant + let config_matches_type = match (&request.signer_type, &request.config) { + (SignerTypeRequest::Local, SignerConfigRequest::Local(_)) => true, + (SignerTypeRequest::AwsKms, SignerConfigRequest::AwsKms(_)) => true, + (SignerTypeRequest::Vault, SignerConfigRequest::Vault(_)) => true, + (SignerTypeRequest::VaultTransit, SignerConfigRequest::VaultTransit(_)) => true, + (SignerTypeRequest::Turnkey, SignerConfigRequest::Turnkey(_)) => true, + (SignerTypeRequest::GoogleCloudKms, SignerConfigRequest::GoogleCloudKms(_)) => true, + _ => false, + }; + + if !config_matches_type { + return Err(ApiError::BadRequest(format!( + "Signer type '{:?}' does not match the provided configuration", + request.signer_type + ))); + } + // Convert request config to domain config (with validation) let config = SignerConfig::try_from(request.config)?; @@ -298,16 +297,250 @@ mod tests { use super::*; use crate::models::signer::SignerType; + #[test] + fn test_json_deserialization_local_signer() { + let json = r#"{ + "id": "test-local-signer", + "type": "plain", + "config": { + "key": "1111111111111111111111111111111111111111111111111111111111111111" + } + }"#; + + let result: Result = serde_json::from_str(json); + + assert!( + result.is_ok(), + "Failed to deserialize local signer: {:?}", + result.err() + ); + + let request = result.unwrap(); + assert_eq!(request.id, Some("test-local-signer".to_string())); + + match request.config { + SignerConfigRequest::Local(local_config) => { + assert_eq!( + local_config.key, + "1111111111111111111111111111111111111111111111111111111111111111" + ); + } + _ => panic!("Expected Local config variant"), + } + } + + #[test] + fn test_json_deserialization_aws_kms_signer() { + let json = r#"{ + "id": "test-aws-signer", + "type": "aws_kms", + "config": { + "region": "us-east-1", + "key_id": "test-key-id" + } + }"#; + + let result: Result = serde_json::from_str(json); + + assert!( + result.is_ok(), + "Failed to deserialize AWS KMS signer: {:?}", + result.err() + ); + + let request = result.unwrap(); + assert_eq!(request.id, Some("test-aws-signer".to_string())); + + match request.config { + SignerConfigRequest::AwsKms(aws_config) => { + assert_eq!(aws_config.region, "us-east-1"); + assert_eq!(aws_config.key_id, "test-key-id"); + } + _ => panic!("Expected AwsKms config variant"), + } + } + + #[test] + fn test_json_deserialization_vault_signer() { + let json = r#"{ + "id": "test-vault-signer", + "type": "vault", + "config": { + "address": "https://vault.example.com", + "namespace": null, + "role_id": "test-role-id", + "secret_id": "test-secret-id", + "key_name": "test-key", + "mount_point": null + } + }"#; + + let result: Result = serde_json::from_str(json); + + assert!( + result.is_ok(), + "Failed to deserialize Vault signer: {:?}", + result.err() + ); + + let request = result.unwrap(); + assert_eq!(request.id, Some("test-vault-signer".to_string())); + + match request.config { + SignerConfigRequest::Vault(vault_config) => { + assert_eq!(vault_config.address, "https://vault.example.com"); + assert_eq!(vault_config.namespace, None); + assert_eq!(vault_config.role_id, "test-role-id"); + assert_eq!(vault_config.secret_id, "test-secret-id"); + assert_eq!(vault_config.key_name, "test-key"); + assert_eq!(vault_config.mount_point, None); + } + _ => panic!("Expected Vault config variant"), + } + } + + #[test] + fn test_json_deserialization_turnkey_signer() { + let json = r#"{ + "id": "test-turnkey-signer", + "type": "turnkey", + "config": { + "api_public_key": "test-public-key", + "api_private_key": "test-private-key", + "organization_id": "test-org", + "private_key_id": "test-private-key-id", + "public_key": "test-public-key" + } + }"#; + + let result: Result = serde_json::from_str(json); + + assert!( + result.is_ok(), + "Failed to deserialize Turnkey signer: {:?}", + result.err() + ); + + let request = result.unwrap(); + assert_eq!(request.id, Some("test-turnkey-signer".to_string())); + + match request.config { + SignerConfigRequest::Turnkey(turnkey_config) => { + assert_eq!(turnkey_config.api_public_key, "test-public-key"); + assert_eq!(turnkey_config.api_private_key, "test-private-key"); + assert_eq!(turnkey_config.organization_id, "test-org"); + assert_eq!(turnkey_config.private_key_id, "test-private-key-id"); + assert_eq!(turnkey_config.public_key, "test-public-key"); + } + _ => panic!("Expected Turnkey config variant"), + } + } + + #[test] + fn test_json_serialization_local_signer() { + let request = SignerCreateRequest { + id: Some("test-local-signer".to_string()), + signer_type: SignerTypeRequest::Local, + config: SignerConfigRequest::Local(LocalSignerRequestConfig { + key: "1111111111111111111111111111111111111111111111111111111111111111".to_string(), + }), + }; + + let json_result = serde_json::to_string_pretty(&request); + + assert!( + json_result.is_ok(), + "Failed to serialize local signer: {:?}", + json_result.err() + ); + + let json = json_result.unwrap(); + + // Verify it can be deserialized back + let deserialize_result: Result = serde_json::from_str(&json); + assert!( + deserialize_result.is_ok(), + "Failed to deserialize back: {:?}", + deserialize_result.err() + ); + } + + #[test] + fn test_json_serialization_aws_kms_signer() { + let request = SignerCreateRequest { + id: Some("test-aws-signer".to_string()), + signer_type: SignerTypeRequest::AwsKms, + config: SignerConfigRequest::AwsKms(AwsKmsSignerRequestConfig { + region: "us-east-1".to_string(), + key_id: "test-key-id".to_string(), + }), + }; + + let json_result = serde_json::to_string_pretty(&request); + + assert!( + json_result.is_ok(), + "Failed to serialize AWS KMS signer: {:?}", + json_result.err() + ); + + let json = json_result.unwrap(); + + // Verify it can be deserialized back + let deserialize_result: Result = serde_json::from_str(&json); + assert!( + deserialize_result.is_ok(), + "Failed to deserialize back: {:?}", + deserialize_result.err() + ); + } + + #[test] + fn test_type_config_mismatch_validation() { + // Create a request where the type doesn't match the config + let json = r#"{ + "id": "test-mismatch-signer", + "type": "aws_kms", + "config": { + "key": "1111111111111111111111111111111111111111111111111111111111111111" + } + }"#; + + let result: Result = serde_json::from_str(json); + + // This should deserialize successfully due to untagged enum + assert!(result.is_ok(), "JSON deserialization should succeed"); + + let request = result.unwrap(); + + // But the conversion to Signer should fail due to type mismatch validation + let signer_result = Signer::try_from(request); + assert!( + signer_result.is_err(), + "Type mismatch should cause validation error" + ); + + if let Err(ApiError::BadRequest(msg)) = signer_result { + assert!( + msg.contains("does not match"), + "Error should mention type mismatch: {}", + msg + ); + } else { + panic!("Expected BadRequest error for type mismatch"); + } + } + + // Keep existing tests for backward compatibility #[test] fn test_valid_aws_kms_create_request() { let request = SignerCreateRequest { id: Some("test-aws-signer".to_string()), - config: SignerConfigRequest::AwsKms { - config: AwsKmsSignerRequestConfig { - region: "us-east-1".to_string(), - key_id: "test-key-id".to_string(), - }, - }, + signer_type: SignerTypeRequest::AwsKms, + config: SignerConfigRequest::AwsKms(AwsKmsSignerRequestConfig { + region: "us-east-1".to_string(), + key_id: "test-key-id".to_string(), + }), }; let result = Signer::try_from(request); @@ -330,16 +563,15 @@ mod tests { fn test_valid_vault_create_request() { let request = SignerCreateRequest { id: Some("test-vault-signer".to_string()), - config: SignerConfigRequest::Vault { - config: VaultSignerRequestConfig { - address: "https://vault.example.com".to_string(), - namespace: None, - role_id: "test-role-id".to_string(), - secret_id: "test-secret-id".to_string(), - key_name: "test-key".to_string(), - mount_point: None, - }, - }, + signer_type: SignerTypeRequest::Vault, + config: SignerConfigRequest::Vault(VaultSignerRequestConfig { + address: "https://vault.example.com".to_string(), + namespace: None, + role_id: "test-role-id".to_string(), + secret_id: "test-secret-id".to_string(), + key_name: "test-key".to_string(), + mount_point: None, + }), }; let result = Signer::try_from(request); @@ -354,12 +586,11 @@ mod tests { fn test_invalid_aws_kms_empty_key_id() { let request = SignerCreateRequest { id: Some("test-signer".to_string()), - config: SignerConfigRequest::AwsKms { - config: AwsKmsSignerRequestConfig { - region: "us-east-1".to_string(), - key_id: "".to_string(), // Empty key ID should fail validation - }, - }, + signer_type: SignerTypeRequest::AwsKms, + config: SignerConfigRequest::AwsKms(AwsKmsSignerRequestConfig { + region: "us-east-1".to_string(), + key_id: "".to_string(), // Empty key ID should fail validation + }), }; let result = Signer::try_from(request); @@ -376,16 +607,15 @@ mod tests { fn test_invalid_vault_empty_address() { let request = SignerCreateRequest { id: Some("test-signer".to_string()), - config: SignerConfigRequest::Vault { - config: VaultSignerRequestConfig { - address: "".to_string(), // Empty address should fail validation - namespace: None, - role_id: "test-role".to_string(), - secret_id: "test-secret".to_string(), - key_name: "test-key".to_string(), - mount_point: None, - }, - }, + signer_type: SignerTypeRequest::Vault, + config: SignerConfigRequest::Vault(VaultSignerRequestConfig { + address: "".to_string(), // Empty address should fail validation + namespace: None, + role_id: "test-role".to_string(), + secret_id: "test-secret".to_string(), + key_name: "test-key".to_string(), + mount_point: None, + }), }; let result = Signer::try_from(request); @@ -396,16 +626,15 @@ mod tests { fn test_invalid_vault_invalid_url() { let request = SignerCreateRequest { id: Some("test-signer".to_string()), - config: SignerConfigRequest::Vault { - config: VaultSignerRequestConfig { - address: "not-a-url".to_string(), // Invalid URL should fail validation - namespace: None, - role_id: "test-role".to_string(), - secret_id: "test-secret".to_string(), - key_name: "test-key".to_string(), - mount_point: None, - }, - }, + signer_type: SignerTypeRequest::Vault, + config: SignerConfigRequest::Vault(VaultSignerRequestConfig { + address: "not-a-url".to_string(), // Invalid URL should fail validation + namespace: None, + role_id: "test-role".to_string(), + secret_id: "test-secret".to_string(), + key_name: "test-key".to_string(), + mount_point: None, + }), }; let result = Signer::try_from(request); @@ -422,12 +651,10 @@ mod tests { fn test_create_request_generates_uuid_when_no_id() { let request = SignerCreateRequest { id: None, - config: SignerConfigRequest::Local { - config: PlainSignerRequestConfig { - key: "1111111111111111111111111111111111111111111111111111111111111111" - .to_string(), // 32 bytes as hex - }, - }, + signer_type: SignerTypeRequest::Local, + config: SignerConfigRequest::Local(LocalSignerRequestConfig { + key: "1111111111111111111111111111111111111111111111111111111111111111".to_string(), // 32 bytes as hex + }), }; let result = Signer::try_from(request); @@ -445,12 +672,10 @@ mod tests { fn test_invalid_id_format() { let request = SignerCreateRequest { id: Some("invalid@id".to_string()), // Invalid characters - config: SignerConfigRequest::Local { - config: PlainSignerRequestConfig { - key: "2222222222222222222222222222222222222222222222222222222222222222" - .to_string(), // 32 bytes as hex - }, - }, + signer_type: SignerTypeRequest::Local, + config: SignerConfigRequest::Local(LocalSignerRequestConfig { + key: "2222222222222222222222222222222222222222222222222222222222222222".to_string(), // 32 bytes as hex + }), }; let result = Signer::try_from(request); @@ -467,12 +692,10 @@ mod tests { fn test_test_signer_creation() { let request = SignerCreateRequest { id: Some("test-signer".to_string()), - config: SignerConfigRequest::Local { - config: PlainSignerRequestConfig { - key: "3333333333333333333333333333333333333333333333333333333333333333" - .to_string(), // 32 bytes as hex - }, - }, + signer_type: SignerTypeRequest::Local, + config: SignerConfigRequest::Local(LocalSignerRequestConfig { + key: "3333333333333333333333333333333333333333333333333333333333333333".to_string(), // 32 bytes as hex + }), }; let result = Signer::try_from(request); @@ -487,12 +710,10 @@ mod tests { fn test_local_signer_creation() { let request = SignerCreateRequest { id: Some("local-signer".to_string()), - config: SignerConfigRequest::Local { - config: PlainSignerRequestConfig { - key: "4444444444444444444444444444444444444444444444444444444444444444" - .to_string(), // 32 bytes as hex - }, - }, + signer_type: SignerTypeRequest::Local, + config: SignerConfigRequest::Local(LocalSignerRequestConfig { + key: "4444444444444444444444444444444444444444444444444444444444444444".to_string(), // 32 bytes as hex + }), }; let result = Signer::try_from(request); @@ -507,15 +728,14 @@ mod tests { fn test_valid_turnkey_create_request() { let request = SignerCreateRequest { id: Some("test-turnkey-signer".to_string()), - config: SignerConfigRequest::Turnkey { - config: TurnkeySignerRequestConfig { - api_public_key: "test-public-key".to_string(), - api_private_key: "test-private-key".to_string(), - organization_id: "test-org".to_string(), - private_key_id: "test-private-key-id".to_string(), - public_key: "test-public-key".to_string(), - }, - }, + signer_type: SignerTypeRequest::Turnkey, + config: SignerConfigRequest::Turnkey(TurnkeySignerRequestConfig { + api_public_key: "test-public-key".to_string(), + api_private_key: "test-private-key".to_string(), + organization_id: "test-org".to_string(), + private_key_id: "test-private-key-id".to_string(), + public_key: "test-public-key".to_string(), + }), }; let result = Signer::try_from(request); @@ -537,17 +757,16 @@ mod tests { fn test_valid_vault_transit_create_request() { let request = SignerCreateRequest { id: Some("test-vault-transit-signer".to_string()), - config: SignerConfigRequest::VaultTransit { - config: VaultTransitSignerRequestConfig { - key_name: "test-key".to_string(), - address: "https://vault.example.com".to_string(), - namespace: None, - role_id: "test-role".to_string(), - secret_id: "test-secret".to_string(), - pubkey: "test-pubkey".to_string(), - mount_point: None, - }, - }, + signer_type: SignerTypeRequest::VaultTransit, + config: SignerConfigRequest::VaultTransit(VaultTransitSignerRequestConfig { + key_name: "test-key".to_string(), + address: "https://vault.example.com".to_string(), + namespace: None, + role_id: "test-role".to_string(), + secret_id: "test-secret".to_string(), + pubkey: "test-pubkey".to_string(), + mount_point: None, + }), }; let result = Signer::try_from(request); @@ -562,28 +781,27 @@ mod tests { fn test_valid_google_cloud_kms_create_request() { let request = SignerCreateRequest { id: Some("test-gcp-kms-signer".to_string()), - config: SignerConfigRequest::GoogleCloudKms { - config: GoogleCloudKmsSignerRequestConfig { - service_account: GoogleCloudKmsSignerServiceAccountRequestConfig { - private_key: "test-private-key".to_string(), - private_key_id: "test-key-id".to_string(), - project_id: "test-project".to_string(), - client_email: "test@email.com".to_string(), - client_id: "test-client-id".to_string(), - auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), - token_uri: "https://oauth2.googleapis.com/token".to_string(), - auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs".to_string(), - client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test%40test.iam.gserviceaccount.com".to_string(), - universe_domain: "googleapis.com".to_string(), - }, - key: GoogleCloudKmsSignerKeyRequestConfig { - location: "global".to_string(), - key_ring_id: "test-ring".to_string(), - key_id: "test-key".to_string(), - key_version: 1, - }, + signer_type: SignerTypeRequest::GoogleCloudKms, + config: SignerConfigRequest::GoogleCloudKms(GoogleCloudKmsSignerRequestConfig { + service_account: GoogleCloudKmsSignerServiceAccountRequestConfig { + private_key: "test-private-key".to_string(), + private_key_id: "test-key-id".to_string(), + project_id: "test-project".to_string(), + client_email: "test@email.com".to_string(), + client_id: "test-client-id".to_string(), + auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs".to_string(), + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test%40test.iam.gserviceaccount.com".to_string(), + universe_domain: "googleapis.com".to_string(), }, - }, + key: GoogleCloudKmsSignerKeyRequestConfig { + location: "global".to_string(), + key_ring_id: "test-ring".to_string(), + key_id: "test-key".to_string(), + key_version: 1, + }, + }), }; let result = Signer::try_from(request); @@ -598,11 +816,10 @@ mod tests { fn test_invalid_local_hex_key() { let request = SignerCreateRequest { id: Some("test-signer".to_string()), - config: SignerConfigRequest::Local { - config: PlainSignerRequestConfig { - key: "invalid-hex".to_string(), // Invalid hex - }, - }, + signer_type: SignerTypeRequest::Local, + config: SignerConfigRequest::Local(LocalSignerRequestConfig { + key: "invalid-hex".to_string(), // Invalid hex + }), }; let result = Signer::try_from(request); @@ -616,15 +833,14 @@ mod tests { fn test_invalid_turnkey_empty_key() { let request = SignerCreateRequest { id: Some("test-signer".to_string()), - config: SignerConfigRequest::Turnkey { - config: TurnkeySignerRequestConfig { - api_public_key: "".to_string(), // Empty - api_private_key: "test-private-key".to_string(), - organization_id: "test-org".to_string(), - private_key_id: "test-private-key-id".to_string(), - public_key: "test-public-key".to_string(), - }, - }, + signer_type: SignerTypeRequest::Turnkey, + config: SignerConfigRequest::Turnkey(TurnkeySignerRequestConfig { + api_public_key: "".to_string(), // Empty + api_private_key: "test-private-key".to_string(), + organization_id: "test-org".to_string(), + private_key_id: "test-private-key-id".to_string(), + public_key: "test-public-key".to_string(), + }), }; let result = Signer::try_from(request); diff --git a/src/models/transaction/repository.rs b/src/models/transaction/repository.rs index c1b33573b..a77b823e6 100644 --- a/src/models/transaction/repository.rs +++ b/src/models/transaction/repository.rs @@ -16,6 +16,7 @@ use crate::{ RelayerError, RelayerRepoModel, SignerError, StellarNetwork, StellarValidationError, TransactionError, U256, }, + utils::{deserialize_optional_u128, serialize_optional_u128}, }; use alloy::{ consensus::{TxEip1559, TxLegacy}, @@ -144,8 +145,12 @@ pub struct EvmTransactionDataSignature { } #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct EvmTransactionData { + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128", + default + )] pub gas_price: Option, pub gas_limit: Option, pub nonce: Option, @@ -157,7 +162,17 @@ pub struct EvmTransactionData { pub hash: Option, pub signature: Option, pub speed: Option, + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128", + default + )] pub max_fee_per_gas: Option, + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128", + default + )] pub max_priority_fee_per_gas: Option, pub raw: Option>, } diff --git a/src/models/transaction/response.rs b/src/models/transaction/response.rs index 3bdf20eb6..3309ab991 100644 --- a/src/models/transaction/response.rs +++ b/src/models/transaction/response.rs @@ -3,7 +3,7 @@ use crate::{ evm::Speed, EvmTransactionDataSignature, NetworkTransactionData, TransactionRepoModel, TransactionStatus, U256, }, - utils::{deserialize_optional_u128, deserialize_optional_u64}, + utils::{deserialize_optional_u128, deserialize_optional_u64, serialize_optional_u128}, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -28,7 +28,11 @@ pub struct EvmTransactionResponse { pub sent_at: Option, #[schema(nullable = false)] pub confirmed_at: Option, - #[serde(deserialize_with = "deserialize_optional_u128", default)] + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128", + default + )] #[schema(nullable = false)] pub gas_price: Option, #[serde(deserialize_with = "deserialize_optional_u64", default)] @@ -44,10 +48,18 @@ pub struct EvmTransactionResponse { pub relayer_id: String, #[schema(nullable = false)] pub data: Option, - #[serde(deserialize_with = "deserialize_optional_u128", default)] + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128", + default + )] #[schema(nullable = false)] pub max_fee_per_gas: Option, - #[serde(deserialize_with = "deserialize_optional_u128", default)] + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128", + default + )] #[schema(nullable = false)] pub max_priority_fee_per_gas: Option, pub signature: Option, diff --git a/src/repositories/redis_base.rs b/src/repositories/redis_base.rs index 72e7b9995..b903e66ed 100644 --- a/src/repositories/redis_base.rs +++ b/src/repositories/redis_base.rs @@ -58,35 +58,38 @@ pub trait RedisRepository { /// Convert Redis errors to appropriate RepositoryError types fn map_redis_error(&self, error: RedisError, context: &str) -> RepositoryError { + warn!("Redis operation failed in context '{}': {}", context, error); + match error.kind() { - redis::ErrorKind::IoError => { - error!("Redis IO error in {}: {}", context, error); - RepositoryError::ConnectionError(format!("Redis connection failed: {}", error)) - } + redis::ErrorKind::TypeError => RepositoryError::InvalidData(format!( + "Redis data type error in operation '{}': {}", + context, error + )), redis::ErrorKind::AuthenticationFailed => { - error!("Redis authentication failed in {}: {}", context, error); - RepositoryError::PermissionDenied(format!("Redis authentication failed: {}", error)) - } - redis::ErrorKind::TypeError => { - error!("Redis type error in {}: {}", context, error); - RepositoryError::InvalidData(format!("Redis data type error: {}", error)) - } - redis::ErrorKind::ExecAbortError => { - warn!("Redis transaction aborted in {}: {}", context, error); - RepositoryError::TransactionFailure(format!("Redis transaction aborted: {}", error)) - } - redis::ErrorKind::BusyLoadingError => { - warn!("Redis busy loading in {}: {}", context, error); - RepositoryError::ConnectionError(format!("Redis is loading: {}", error)) - } - redis::ErrorKind::NoScriptError => { - error!("Redis script error in {}: {}", context, error); - RepositoryError::Other(format!("Redis script error: {}", error)) - } - _ => { - error!("Unexpected Redis error in {}: {}", context, error); - RepositoryError::Other(format!("Redis error in {}: {}", context, error)) + RepositoryError::InvalidData("Redis authentication failed".to_string()) } + redis::ErrorKind::NoScriptError => RepositoryError::InvalidData(format!( + "Redis script error in operation '{}': {}", + context, error + )), + redis::ErrorKind::ReadOnly => RepositoryError::InvalidData(format!( + "Redis is read-only in operation '{}': {}", + context, error + )), + redis::ErrorKind::ExecAbortError => RepositoryError::InvalidData(format!( + "Redis transaction aborted in operation '{}': {}", + context, error + )), + redis::ErrorKind::BusyLoadingError => RepositoryError::InvalidData(format!( + "Redis is busy in operation '{}': {}", + context, error + )), + redis::ErrorKind::ExtensionError => RepositoryError::InvalidData(format!( + "Redis extension error in operation '{}': {}", + context, error + )), + // Default to Other for connection errors and other issues + _ => RepositoryError::Other(format!("Redis operation '{}' failed: {}", context, error)), } } } @@ -95,7 +98,6 @@ pub trait RedisRepository { mod tests { use super::*; use serde::{Deserialize, Serialize}; - use std::io; // Test structs for serialization/deserialization #[derive(Debug, Serialize, Deserialize, PartialEq)] @@ -208,9 +210,9 @@ mod tests { } #[test] - fn test_deserialize_entity_wrong_structure() { + fn test_deserialize_entity_invalid_structure() { let repo = TestRedisRepository::new(); - let json = r#"{"wrong":"field"}"#; + let json = r#"{"wrongfield":"test-id"}"#; let result: Result = repo.deserialize_entity(json, "test-id", "TestEntity"); @@ -225,77 +227,79 @@ mod tests { } #[test] - fn test_deserialize_entity_empty_json() { + fn test_map_redis_error_type_error() { let repo = TestRedisRepository::new(); - let json = ""; + let redis_error = RedisError::from((redis::ErrorKind::TypeError, "Type error")); - let result: Result = - repo.deserialize_entity(json, "test-id", "TestEntity"); + let result = repo.map_redis_error(redis_error, "test_operation"); - assert!(result.is_err()); - match result.unwrap_err() { + match result { RepositoryError::InvalidData(msg) => { - assert!(msg.contains("Failed to deserialize TestEntity test-id")); - assert!(msg.contains("JSON length: 0")); + assert!(msg.contains("Redis data type error")); + assert!(msg.contains("test_operation")); } _ => panic!("Expected InvalidData error"), } } #[test] - fn test_deserialize_entity_simple_struct() { + fn test_map_redis_error_authentication_failed() { let repo = TestRedisRepository::new(); - let json = r#"{"id":"simple-id"}"#; + let redis_error = RedisError::from((redis::ErrorKind::AuthenticationFailed, "Auth failed")); - let result: Result = - repo.deserialize_entity(json, "simple-id", "SimpleEntity"); + let result = repo.map_redis_error(redis_error, "auth_operation"); - assert!(result.is_ok()); - let entity = result.unwrap(); - assert_eq!(entity.id, "simple-id"); + match result { + RepositoryError::InvalidData(msg) => { + assert!(msg.contains("Redis authentication failed")); + } + _ => panic!("Expected InvalidData error"), + } } #[test] - fn test_map_redis_error_io_error() { + fn test_map_redis_error_connection_error() { let repo = TestRedisRepository::new(); - let io_error = io::Error::new(io::ErrorKind::ConnectionRefused, "Connection refused"); - let redis_error = RedisError::from(io_error); + let redis_error = RedisError::from((redis::ErrorKind::IoError, "Connection failed")); - let result = repo.map_redis_error(redis_error, "test_context"); + let result = repo.map_redis_error(redis_error, "connection_operation"); match result { - RepositoryError::ConnectionError(msg) => { - assert!(msg.contains("Redis connection failed")); + RepositoryError::Other(msg) => { + assert!(msg.contains("Redis operation")); + assert!(msg.contains("connection_operation")); } - _ => panic!("Expected ConnectionError"), + _ => panic!("Expected Other error"), } } #[test] - fn test_map_redis_error_authentication_failed() { + fn test_map_redis_error_no_script_error() { let repo = TestRedisRepository::new(); - let redis_error = RedisError::from((redis::ErrorKind::AuthenticationFailed, "Auth failed")); + let redis_error = RedisError::from((redis::ErrorKind::NoScriptError, "Script not found")); - let result = repo.map_redis_error(redis_error, "test_context"); + let result = repo.map_redis_error(redis_error, "script_operation"); match result { - RepositoryError::PermissionDenied(msg) => { - assert!(msg.contains("Redis authentication failed")); + RepositoryError::InvalidData(msg) => { + assert!(msg.contains("Redis script error")); + assert!(msg.contains("script_operation")); } - _ => panic!("Expected PermissionDenied error"), + _ => panic!("Expected InvalidData error"), } } #[test] - fn test_map_redis_error_type_error() { + fn test_map_redis_error_read_only() { let repo = TestRedisRepository::new(); - let redis_error = RedisError::from((redis::ErrorKind::TypeError, "Type error")); + let redis_error = RedisError::from((redis::ErrorKind::ReadOnly, "Read only")); - let result = repo.map_redis_error(redis_error, "test_context"); + let result = repo.map_redis_error(redis_error, "write_operation"); match result { RepositoryError::InvalidData(msg) => { - assert!(msg.contains("Redis data type error")); + assert!(msg.contains("Redis is read-only")); + assert!(msg.contains("write_operation")); } _ => panic!("Expected InvalidData error"), } @@ -307,58 +311,46 @@ mod tests { let redis_error = RedisError::from((redis::ErrorKind::ExecAbortError, "Transaction aborted")); - let result = repo.map_redis_error(redis_error, "test_context"); + let result = repo.map_redis_error(redis_error, "transaction_operation"); match result { - RepositoryError::TransactionFailure(msg) => { + RepositoryError::InvalidData(msg) => { assert!(msg.contains("Redis transaction aborted")); + assert!(msg.contains("transaction_operation")); } - _ => panic!("Expected TransactionFailure error"), - } - } - - #[test] - fn test_map_redis_error_busy_loading_error() { - let repo = TestRedisRepository::new(); - let redis_error = RedisError::from((redis::ErrorKind::BusyLoadingError, "Loading")); - - let result = repo.map_redis_error(redis_error, "test_context"); - - match result { - RepositoryError::ConnectionError(msg) => { - assert!(msg.contains("Redis is loading")); - } - _ => panic!("Expected ConnectionError"), + _ => panic!("Expected InvalidData error"), } } #[test] - fn test_map_redis_error_no_script_error() { + fn test_map_redis_error_busy_error() { let repo = TestRedisRepository::new(); - let redis_error = RedisError::from((redis::ErrorKind::NoScriptError, "Script not found")); + let redis_error = RedisError::from((redis::ErrorKind::BusyLoadingError, "Server busy")); - let result = repo.map_redis_error(redis_error, "test_context"); + let result = repo.map_redis_error(redis_error, "busy_operation"); match result { - RepositoryError::Other(msg) => { - assert!(msg.contains("Redis script error")); + RepositoryError::InvalidData(msg) => { + assert!(msg.contains("Redis is busy")); + assert!(msg.contains("busy_operation")); } - _ => panic!("Expected Other error"), + _ => panic!("Expected InvalidData error"), } } #[test] - fn test_map_redis_error_cluster_down() { + fn test_map_redis_error_extension_error() { let repo = TestRedisRepository::new(); - let redis_error = RedisError::from((redis::ErrorKind::ClusterDown, "Cluster down")); + let redis_error = RedisError::from((redis::ErrorKind::ExtensionError, "Extension error")); - let result = repo.map_redis_error(redis_error, "test_context"); + let result = repo.map_redis_error(redis_error, "extension_operation"); match result { - RepositoryError::Other(msg) => { - assert!(msg.contains("Redis error in test_context")); + RepositoryError::InvalidData(msg) => { + assert!(msg.contains("Redis extension error")); + assert!(msg.contains("extension_operation")); } - _ => panic!("Expected Other error"), + _ => panic!("Expected InvalidData error"), } } @@ -461,61 +453,100 @@ mod tests { assert!(json.contains("[1,2,3]")); } + // Test specifically for u128 serialization/deserialization with large values #[test] - fn test_deserialize_entity_with_optional_fields() { - let repo = TestRedisRepository::new(); + fn test_serialize_deserialize_u128_large_values() { + use crate::utils::{deserialize_optional_u128, serialize_optional_u128}; - #[derive(Deserialize, Debug, PartialEq)] - struct OptionalEntity { + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct TestU128Entity { id: String, - optional_field: Option, + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128", + default + )] + gas_price: Option, + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128", + default + )] + max_fee_per_gas: Option, } - // Test with optional field present - let json_with_optional = r#"{"id":"test-id","optional_field":"present"}"#; - let result: Result = - repo.deserialize_entity(json_with_optional, "test-id", "OptionalEntity"); + let repo = TestRedisRepository::new(); - assert!(result.is_ok()); - let entity = result.unwrap(); - assert_eq!(entity.id, "test-id"); - assert_eq!(entity.optional_field, Some("present".to_string())); + // Test with very large u128 values that would overflow JSON numbers + let original = TestU128Entity { + id: "u128-test".to_string(), + gas_price: Some(u128::MAX), // 340282366920938463463374607431768211455 + max_fee_per_gas: Some(999999999999999999999999999999999u128), + }; + + // Serialize + let json = repo + .serialize_entity(&original, |e| &e.id, "TestU128Entity") + .unwrap(); - // Test with optional field missing - let json_without_optional = r#"{"id":"test-id"}"#; - let result: Result = - repo.deserialize_entity(json_without_optional, "test-id", "OptionalEntity"); + // Verify it contains string representations, not numbers + assert!(json.contains("\"340282366920938463463374607431768211455\"")); + assert!(json.contains("\"999999999999999999999999999999999\"")); + // Make sure they're not stored as numbers (which would cause overflow) + assert!(!json.contains("3.4028236692093846e+38")); - assert!(result.is_ok()); - let entity = result.unwrap(); - assert_eq!(entity.id, "test-id"); - assert_eq!(entity.optional_field, None); + // Deserialize + let deserialized: TestU128Entity = repo + .deserialize_entity(&json, "u128-test", "TestU128Entity") + .unwrap(); + + // Should be identical + assert_eq!(original, deserialized); + assert_eq!(deserialized.gas_price, Some(u128::MAX)); + assert_eq!( + deserialized.max_fee_per_gas, + Some(999999999999999999999999999999999u128) + ); } #[test] - fn test_error_propagation_with_different_entity_types() { + fn test_serialize_deserialize_u128_none_values() { + use crate::utils::{deserialize_optional_u128, serialize_optional_u128}; + + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct TestU128Entity { + id: String, + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128", + default + )] + gas_price: Option, + } + let repo = TestRedisRepository::new(); - // Test with different entity types to ensure error messages are correct - let invalid_json = r#"{"invalid": "json"}"#; + // Test with None values + let original = TestU128Entity { + id: "u128-none-test".to_string(), + gas_price: None, + }; - let result1: Result = - repo.deserialize_entity(invalid_json, "id1", "TestEntity"); - let result2: Result = - repo.deserialize_entity(invalid_json, "id2", "SimpleEntity"); + // Serialize + let json = repo + .serialize_entity(&original, |e| &e.id, "TestU128Entity") + .unwrap(); - assert!(result1.is_err()); - assert!(result2.is_err()); + // Should contain null + assert!(json.contains("null")); - let error1 = result1.unwrap_err(); - let error2 = result2.unwrap_err(); + // Deserialize + let deserialized: TestU128Entity = repo + .deserialize_entity(&json, "u128-none-test", "TestU128Entity") + .unwrap(); - match (error1, error2) { - (RepositoryError::InvalidData(msg1), RepositoryError::InvalidData(msg2)) => { - assert!(msg1.contains("TestEntity id1")); - assert!(msg2.contains("SimpleEntity id2")); - } - _ => panic!("Expected InvalidData errors"), - } + // Should be identical + assert_eq!(original, deserialized); + assert_eq!(deserialized.gas_price, None); } } diff --git a/src/repositories/signer/signer_redis.rs b/src/repositories/signer/signer_redis.rs index 1980829bd..b5b646a06 100644 --- a/src/repositories/signer/signer_redis.rs +++ b/src/repositories/signer/signer_redis.rs @@ -221,7 +221,9 @@ impl Repository for RedisSignerRepository { let result: Result, RedisError> = conn.get(&key).await; match result { Ok(Some(data)) => { - let signer = self.deserialize_entity::(&data, &id, "signer")?; + let signer_storage = + self.deserialize_entity::(&data, &id, "signer")?; + let signer = SignerRepoModel::from(signer_storage); debug!("Retrieved signer with ID: {}", id); Ok(signer) } diff --git a/src/utils/serde/mod.rs b/src/utils/serde/mod.rs index 823f955f2..a8e265450 100644 --- a/src/utils/serde/mod.rs +++ b/src/utils/serde/mod.rs @@ -3,4 +3,5 @@ pub use u128_deserializer::*; mod u64_deserializer; pub use u64_deserializer::*; + pub mod field_as_string; diff --git a/src/utils/serde/u128_deserializer.rs b/src/utils/serde/u128_deserializer.rs index de3daa4a9..484d84118 100644 --- a/src/utils/serde/u128_deserializer.rs +++ b/src/utils/serde/u128_deserializer.rs @@ -4,7 +4,7 @@ use std::fmt; -use serde::{de, Deserialize, Deserializer}; +use serde::{de, Deserialize, Deserializer, Serializer}; use super::deserialize_u64; @@ -68,6 +68,25 @@ where Ok(helper.map(|Helper(value)| value)) } +// Serialize u128 as string +pub fn serialize_u128(value: &u128, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +// Serialize optional u128 as string +pub fn serialize_optional_u128(value: &Option, serializer: S) -> Result +where + S: Serializer, +{ + match value { + Some(v) => serializer.serialize_some(&v.to_string()), + None => serializer.serialize_none(), + } +} + // Deserialize optional u64 pub fn deserialize_optional_u64<'de, D>(deserializer: D) -> Result, D::Error> where @@ -86,6 +105,7 @@ mod tests { use serde::de::value::{ Error as ValueError, I64Deserializer, StringDeserializer, U64Deserializer, }; + use serde_json; #[test] fn test_deserialize_u128_from_string() { @@ -178,4 +198,63 @@ mod tests { let result: TestStructOptionalU64 = serde_json::from_str(json).unwrap(); assert_eq!(result.value, None); } + + // Test serialization functions + #[test] + fn test_serialize_u128() { + let value: u128 = 340282366920938463463374607431768211455; // u128::MAX + let serialized = serde_json::to_string_pretty(&serde_json::json!({ + "test": serde_json::to_value(value.to_string()).unwrap() + })) + .unwrap(); + + assert!(serialized.contains("340282366920938463463374607431768211455")); + } + + // Test round-trip serialization/deserialization + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct TestSerializeStruct { + #[serde( + serialize_with = "serialize_optional_u128", + deserialize_with = "deserialize_optional_u128" + )] + value: Option, + } + + #[test] + fn test_serialize_deserialize_roundtrip_large_value() { + let original = TestSerializeStruct { + value: Some(u128::MAX), + }; + + let json = serde_json::to_string(&original).unwrap(); + let deserialized: TestSerializeStruct = serde_json::from_str(&json).unwrap(); + + assert_eq!(original, deserialized); + assert!(json.contains("340282366920938463463374607431768211455")); + } + + #[test] + fn test_serialize_deserialize_roundtrip_none() { + let original = TestSerializeStruct { value: None }; + + let json = serde_json::to_string(&original).unwrap(); + let deserialized: TestSerializeStruct = serde_json::from_str(&json).unwrap(); + + assert_eq!(original, deserialized); + assert!(json.contains("null")); + } + + #[test] + fn test_serialize_deserialize_roundtrip_small_value() { + let original = TestSerializeStruct { value: Some(12345) }; + + let json = serde_json::to_string(&original).unwrap(); + let deserialized: TestSerializeStruct = serde_json::from_str(&json).unwrap(); + + assert_eq!(original, deserialized); + assert!(json.contains("12345")); + } } From eceabe7eccf3069069f46d341ff6f9405bbf2f56 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 23 Jul 2025 14:01:51 +0200 Subject: [PATCH 33/59] chore: impr --- src/models/relayer/response.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/models/relayer/response.rs b/src/models/relayer/response.rs index ed1428b00..a0cc1223b 100644 --- a/src/models/relayer/response.rs +++ b/src/models/relayer/response.rs @@ -16,6 +16,7 @@ use super::{ RelayerRepoModel, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, RelayerSolanaSwapPolicy, RelayerStellarPolicy, RpcConfig, }; +use crate::utils::{deserialize_optional_u128, serialize_optional_u128}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -298,7 +299,7 @@ mod tests { println!("serialized: {:?}", serialized); - assert!(serialized.contains(r#""gas_price_cap": 100000000000"#)); + assert!(serialized.contains(r#""gas_price_cap": "100000000000""#)); assert!(serialized.contains(r#""eip1559_pricing": true"#)); } @@ -386,8 +387,10 @@ pub struct NetworkPolicyResponse { /// EVM policy response model for OpenAPI documentation #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] pub struct EvmPolicyResponse { + #[serde(serialize_with = "serialize_optional_u128", deserialize_with = "deserialize_optional_u128")] pub min_balance: Option, pub gas_limit_estimation: Option, + #[serde(serialize_with = "serialize_optional_u128", deserialize_with = "deserialize_optional_u128")] pub gas_price_cap: Option, pub whitelist_receivers: Option>, pub eip1559_pricing: Option, From 12d5285bf4295d2d643713864cda0c8f5df3c1ae Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 23 Jul 2025 15:16:36 +0200 Subject: [PATCH 34/59] chore: clippy --- src/api/controllers/relayer.rs | 2 +- src/jobs/handlers/notification_handler.rs | 19 ++-- src/models/relayer/mod.rs | 3 +- src/models/relayer/repository.rs | 4 +- src/models/relayer/request.rs | 2 +- src/models/relayer/response.rs | 88 ++++++++++++++----- src/models/signer/request.rs | 24 +++-- src/models/transaction/request/evm.rs | 2 +- src/repositories/relayer/relayer_in_memory.rs | 2 +- src/repositories/relayer/relayer_redis.rs | 2 +- src/services/signer/stellar/mod.rs | 7 +- src/utils/serde/u128_deserializer.rs | 24 +++++ 12 files changed, 129 insertions(+), 50 deletions(-) diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 12827ceb9..1a2cf9811 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -114,7 +114,7 @@ pub async fn create_relayer( // Check if network exists for the given network type let network = state .network_repository - .get_by_name(relayer.network_type.into(), &relayer.network) + .get_by_name(relayer.network_type, &relayer.network) .await?; if network.is_none() { diff --git a/src/jobs/handlers/notification_handler.rs b/src/jobs/handlers/notification_handler.rs index f7a4d6ffb..e8e8d3eaf 100644 --- a/src/jobs/handlers/notification_handler.rs +++ b/src/jobs/handlers/notification_handler.rs @@ -150,14 +150,17 @@ mod tests { network: "ethereum".to_string(), network_type: NetworkType::Evm, paused: false, - policies: Some(RelayerNetworkPolicyResponse::Evm(RelayerEvmPolicy { - gas_price_cap: None, - whitelist_receivers: None, - eip1559_pricing: None, - private_transactions: Some(false), - min_balance: Some(0), - gas_limit_estimation: None, - })), + policies: Some(RelayerNetworkPolicyResponse::Evm( + RelayerEvmPolicy { + gas_price_cap: None, + whitelist_receivers: None, + eip1559_pricing: None, + private_transactions: Some(false), + min_balance: Some(0), + gas_limit_estimation: None, + } + .into(), + )), signer_id: "signer-1".to_string(), notification_id: None, custom_rpc_urls: None, diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index 1e331cbd4..d9da01266 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -339,6 +339,7 @@ pub struct Relayer { impl Relayer { /// Creates a new relayer + #[allow(clippy::too_many_arguments)] pub fn new( id: String, name: String, @@ -744,7 +745,7 @@ mod tests { // Verify all updates were applied correctly assert_eq!(updated_relayer.name, "Updated Name via JSON Patch"); - assert_eq!(updated_relayer.paused, true); + assert!(updated_relayer.paused); assert_eq!(updated_relayer.notification_id, None); // Removed assert!(updated_relayer.custom_rpc_urls.is_some()); diff --git a/src/models/relayer/repository.rs b/src/models/relayer/repository.rs index 94c1f1ee7..ee87608cc 100644 --- a/src/models/relayer/repository.rs +++ b/src/models/relayer/repository.rs @@ -208,7 +208,7 @@ mod tests { // Verify business fields were updated assert_eq!(updated.name, "Updated Name"); - assert_eq!(updated.paused, true); + assert!(updated.paused); assert_eq!( updated.notification_id, Some("new_notification".to_string()) @@ -219,6 +219,6 @@ mod tests { updated.address, "0x742d35Cc6634C0532925a3b8D8C2e48a73F6ba2E" ); - assert_eq!(updated.system_disabled, true); + assert!(updated.system_disabled); } } diff --git a/src/models/relayer/request.rs b/src/models/relayer/request.rs index 8e7e379f9..8ed3dc1de 100644 --- a/src/models/relayer/request.rs +++ b/src/models/relayer/request.rs @@ -219,7 +219,7 @@ impl TryFrom for Relayer { type Error = ApiError; fn try_from(request: CreateRelayerRequest) -> Result { - let id = request.id.clone().unwrap_or_else(|| generate_uuid()); + let id = request.id.clone().unwrap_or_else(generate_uuid); // Convert policies directly using the typed policy request let policies = if let Some(policy_request) = &request.policies { diff --git a/src/models/relayer/response.rs b/src/models/relayer/response.rs index a0cc1223b..93da662ce 100644 --- a/src/models/relayer/response.rs +++ b/src/models/relayer/response.rs @@ -16,7 +16,6 @@ use super::{ RelayerRepoModel, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, RelayerSolanaSwapPolicy, RelayerStellarPolicy, RpcConfig, }; -use crate::utils::{deserialize_optional_u128, serialize_optional_u128}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -33,20 +32,22 @@ pub struct DeletePendingTransactionsResponse { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] #[serde(untagged)] pub enum RelayerNetworkPolicyResponse { - Evm(RelayerEvmPolicy), - Solana(RelayerSolanaPolicy), - Stellar(RelayerStellarPolicy), + Evm(EvmPolicyResponse), + Solana(SolanaPolicyResponse), + Stellar(StellarPolicyResponse), } impl From for RelayerNetworkPolicyResponse { fn from(policy: RelayerNetworkPolicy) -> Self { match policy { - RelayerNetworkPolicy::Evm(evm_policy) => RelayerNetworkPolicyResponse::Evm(evm_policy), + RelayerNetworkPolicy::Evm(evm_policy) => { + RelayerNetworkPolicyResponse::Evm(evm_policy.into()) + } RelayerNetworkPolicy::Solana(solana_policy) => { - RelayerNetworkPolicyResponse::Solana(solana_policy) + RelayerNetworkPolicyResponse::Solana(solana_policy.into()) } RelayerNetworkPolicy::Stellar(stellar_policy) => { - RelayerNetworkPolicyResponse::Stellar(stellar_policy) + RelayerNetworkPolicyResponse::Stellar(stellar_policy.into()) } } } @@ -135,7 +136,7 @@ impl From for RelayerResponse { id: model.id, name: model.name, network: model.network, - network_type: model.network_type.into(), + network_type: model.network_type, paused: model.paused, policies, signer_id: model.signer_id, @@ -214,14 +215,17 @@ mod tests { assert_eq!(response.paused, relayer.paused); assert_eq!( response.policies, - Some(RelayerNetworkPolicyResponse::Evm(RelayerEvmPolicy { - gas_price_cap: Some(100_000_000_000), - whitelist_receivers: None, - eip1559_pricing: Some(true), - private_transactions: None, - min_balance: None, - gas_limit_estimation: None, - })) + Some(RelayerNetworkPolicyResponse::Evm( + RelayerEvmPolicy { + gas_price_cap: Some(100_000_000_000), + whitelist_receivers: None, + eip1559_pricing: Some(true), + private_transactions: None, + min_balance: None, + gas_limit_estimation: None, + } + .into() + )) ); assert_eq!(response.signer_id, relayer.signer_id); assert_eq!(response.notification_id, relayer.notification_id); @@ -238,7 +242,7 @@ mod tests { network: "mainnet".to_string(), network_type: RelayerNetworkType::Evm, paused: false, - policies: Some(RelayerNetworkPolicyResponse::Evm(RelayerEvmPolicy { + policies: Some(RelayerNetworkPolicyResponse::Evm(EvmPolicyResponse { gas_price_cap: Some(100_000_000_000), whitelist_receivers: None, eip1559_pricing: Some(true), @@ -271,7 +275,7 @@ mod tests { network: "mainnet".to_string(), network_type: RelayerNetworkType::Evm, paused: false, - policies: Some(RelayerNetworkPolicyResponse::Evm(RelayerEvmPolicy { + policies: Some(RelayerNetworkPolicyResponse::Evm(EvmPolicyResponse { gas_price_cap: Some(100_000_000_000), whitelist_receivers: None, eip1559_pricing: Some(true), @@ -299,7 +303,7 @@ mod tests { println!("serialized: {:?}", serialized); - assert!(serialized.contains(r#""gas_price_cap": "100000000000""#)); + assert!(serialized.contains(r#""gas_price_cap": 100000000000"#)); assert!(serialized.contains(r#""eip1559_pricing": true"#)); } @@ -387,37 +391,75 @@ pub struct NetworkPolicyResponse { /// EVM policy response model for OpenAPI documentation #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] pub struct EvmPolicyResponse { - #[serde(serialize_with = "serialize_optional_u128", deserialize_with = "deserialize_optional_u128")] + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub min_balance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub gas_limit_estimation: Option, - #[serde(serialize_with = "serialize_optional_u128", deserialize_with = "deserialize_optional_u128")] + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub gas_price_cap: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub whitelist_receivers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub eip1559_pricing: Option, - pub private_transactions: bool, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub private_transactions: Option, } /// Solana policy response model for OpenAPI documentation #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] pub struct SolanaPolicyResponse { + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub allowed_programs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub max_signatures: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub max_tx_data_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub min_balance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub allowed_tokens: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub fee_payment_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub fee_margin_percentage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub allowed_accounts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub disallowed_accounts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub max_allowed_fee_lamports: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub swap_config: Option, } /// Stellar policy response model for OpenAPI documentation #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] pub struct StellarPolicyResponse { + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub max_fee: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub timeout_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub min_balance: Option, } @@ -429,7 +471,7 @@ impl From for EvmPolicyResponse { gas_price_cap: policy.gas_price_cap, whitelist_receivers: policy.whitelist_receivers, eip1559_pricing: policy.eip1559_pricing, - private_transactions: policy.private_transactions.unwrap_or(false), + private_transactions: policy.private_transactions, } } } diff --git a/src/models/signer/request.rs b/src/models/signer/request.rs index 115502982..302c0c680 100644 --- a/src/models/signer/request.rs +++ b/src/models/signer/request.rs @@ -262,15 +262,21 @@ impl TryFrom for Signer { .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); // Validate that the signer type matches the config variant - let config_matches_type = match (&request.signer_type, &request.config) { - (SignerTypeRequest::Local, SignerConfigRequest::Local(_)) => true, - (SignerTypeRequest::AwsKms, SignerConfigRequest::AwsKms(_)) => true, - (SignerTypeRequest::Vault, SignerConfigRequest::Vault(_)) => true, - (SignerTypeRequest::VaultTransit, SignerConfigRequest::VaultTransit(_)) => true, - (SignerTypeRequest::Turnkey, SignerConfigRequest::Turnkey(_)) => true, - (SignerTypeRequest::GoogleCloudKms, SignerConfigRequest::GoogleCloudKms(_)) => true, - _ => false, - }; + let config_matches_type = matches!( + (&request.signer_type, &request.config), + (SignerTypeRequest::Local, SignerConfigRequest::Local(_)) + | (SignerTypeRequest::AwsKms, SignerConfigRequest::AwsKms(_)) + | (SignerTypeRequest::Vault, SignerConfigRequest::Vault(_)) + | ( + SignerTypeRequest::VaultTransit, + SignerConfigRequest::VaultTransit(_) + ) + | (SignerTypeRequest::Turnkey, SignerConfigRequest::Turnkey(_)) + | ( + SignerTypeRequest::GoogleCloudKms, + SignerConfigRequest::GoogleCloudKms(_) + ) + ); if !config_matches_type { return Err(ApiError::BadRequest(format!( diff --git a/src/models/transaction/request/evm.rs b/src/models/transaction/request/evm.rs index f587f30a7..b53343bca 100644 --- a/src/models/transaction/request/evm.rs +++ b/src/models/transaction/request/evm.rs @@ -175,7 +175,7 @@ pub fn validate_price_params( if is_legacy { if let RelayerNetworkPolicy::Evm(evm_policy) = &relayer.policies { if let Some(gas_price_cap) = evm_policy.gas_price_cap { - if request.gas_price.unwrap_or(0) > gas_price_cap as u128 { + if request.gas_price.unwrap_or(0) > gas_price_cap { return Err(ApiError::BadRequest("Gas price is too high".to_string())); } } diff --git a/src/repositories/relayer/relayer_in_memory.rs b/src/repositories/relayer/relayer_in_memory.rs index a2b33b884..943a8d0bc 100644 --- a/src/repositories/relayer/relayer_in_memory.rs +++ b/src/repositories/relayer/relayer_in_memory.rs @@ -127,7 +127,7 @@ impl RelayerRepository for InMemoryRelayerRepository { let relayer = store.get_mut(&id).ok_or_else(|| { RepositoryError::NotFound(format!("Relayer with ID {} not found", id)) })?; - relayer.policies = policy.into(); + relayer.policies = policy; Ok(relayer.clone()) } diff --git a/src/repositories/relayer/relayer_redis.rs b/src/repositories/relayer/relayer_redis.rs index 868bbff3b..dc42b36d4 100644 --- a/src/repositories/relayer/relayer_redis.rs +++ b/src/repositories/relayer/relayer_redis.rs @@ -533,7 +533,7 @@ impl RelayerRepository for RedisRelayerRepository { let mut relayer = self.get_by_id(id.clone()).await?; // Update the policy - relayer.policies = policy.into(); + relayer.policies = policy; // Update the relayer self.update(id, relayer).await diff --git a/src/services/signer/stellar/mod.rs b/src/services/signer/stellar/mod.rs index a1ab1c043..905581c80 100644 --- a/src/services/signer/stellar/mod.rs +++ b/src/services/signer/stellar/mod.rs @@ -20,7 +20,7 @@ use crate::{ use super::DataSignerTrait; pub enum StellarSigner { - Local(LocalSigner), + Local(Box), Vault(VaultSigner), } @@ -49,7 +49,10 @@ pub struct StellarSignerFactory; impl StellarSignerFactory { pub fn create_stellar_signer(m: &SignerRepoModel) -> Result { let signer = match &m.config { - SignerConfig::Local(_) => StellarSigner::Local(LocalSigner::new(m)?), + SignerConfig::Local(_) => { + let local_signer = LocalSigner::new(m)?; + StellarSigner::Local(Box::new(local_signer)) + } SignerConfig::Vault(config) => { let vault_config = VaultConfig::new( config.address.clone(), diff --git a/src/utils/serde/u128_deserializer.rs b/src/utils/serde/u128_deserializer.rs index 484d84118..431146664 100644 --- a/src/utils/serde/u128_deserializer.rs +++ b/src/utils/serde/u128_deserializer.rs @@ -87,6 +87,30 @@ where } } +pub fn serialize_optional_u128_as_number( + value: &Option, + serializer: S, +) -> Result +where + S: Serializer, +{ + match value { + Some(v) => serializer.serialize_some(&v), + None => serializer.serialize_none(), + } +} + +/// Deserialize optional u128 from number +pub fn deserialize_optional_u128_as_number<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value: Option = Option::deserialize(deserializer)?; + Ok(value) +} + // Deserialize optional u64 pub fn deserialize_optional_u64<'de, D>(deserializer: D) -> Result, D::Error> where From 925e60daa8be6c622b69f2042370127861972c93 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 23 Jul 2025 15:31:40 +0200 Subject: [PATCH 35/59] chore: cleanup --- src/constants/relayer.rs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/constants/relayer.rs b/src/constants/relayer.rs index 292d4c680..cb32bc07a 100644 --- a/src/constants/relayer.rs +++ b/src/constants/relayer.rs @@ -13,30 +13,11 @@ pub const DEFAULT_EVM_GAS_PRICE_CAP: u128 = 100_000_000_000; pub const DEFAULT_EVM_EIP1559_ENABLED: bool = true; /// Default gas limit estimation enabled pub const DEFAULT_EVM_GAS_LIMIT_ESTIMATION: bool = true; -/// Default private transactions disabled -pub const DEFAULT_EVM_PRIVATE_TRANSACTIONS: bool = false; // === Solana Policy Defaults === -/// Default fee margin percentage for Solana transactions -pub const DEFAULT_SOLANA_FEE_MARGIN_PERCENTAGE: f32 = 5.0; // 5% /// Default maximum transaction data size for Solana pub const DEFAULT_SOLANA_MAX_TX_DATA_SIZE: u16 = 1232; -/// Default maximum signatures for Solana transactions -pub const DEFAULT_SOLANA_MAX_SIGNATURES: u8 = 8; -/// Default maximum allowed fee for Solana transactions (0.1 SOL) -pub const DEFAULT_SOLANA_MAX_ALLOWED_FEE: u64 = 100_000_000; // lamports -// === Stellar Policy Defaults === -/// Default maximum fee for Stellar transactions (10 stroops) -pub const DEFAULT_STELLAR_MAX_FEE: u32 = 10; -/// Default timeout for Stellar transactions (30 seconds) -pub const DEFAULT_STELLAR_TIMEOUT_SECONDS: u64 = 30; - -// === Token Swap Defaults === -/// Default slippage percentage for token swaps -pub const DEFAULT_TOKEN_SWAP_SLIPPAGE: f32 = 1.0; // 1% - -// === Legacy Constants === pub const MAX_SOLANA_TX_DATA_SIZE: u16 = 1232; pub const EVM_SMALLEST_UNIT_NAME: &str = "wei"; pub const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; From 259b812d23a93214873b93786fa8a09a4c48e9d1 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 23 Jul 2025 16:21:41 +0200 Subject: [PATCH 36/59] chore: add controller tests --- src/api/controllers/relayer.rs | 734 ++++++++++++++++++++++++++++++--- 1 file changed, 688 insertions(+), 46 deletions(-) diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 1a2cf9811..39cc6debc 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -16,13 +16,19 @@ use crate::{ RelayerFactory, RelayerFactoryTrait, SignDataRequest, SignDataResponse, SignTypedDataRequest, Transaction, }, + jobs::JobProducerTrait, models::{ convert_to_internal_rpc_request, deserialize_policy_for_network_type, ApiError, - ApiResponse, CreateRelayerRequest, DefaultAppState, NetworkTransactionRequest, NetworkType, - PaginationMeta, PaginationQuery, Relayer as RelayerDomainModel, RelayerRepoModel, - RelayerRepoUpdater, RelayerResponse, TransactionResponse, UpdateRelayerRequestRaw, + ApiResponse, CreateRelayerRequest, DefaultAppState, NetworkRepoModel, + NetworkTransactionRequest, NetworkType, NotificationRepoModel, PaginationMeta, + PaginationQuery, Relayer as RelayerDomainModel, RelayerRepoModel, RelayerRepoUpdater, + RelayerResponse, SignerRepoModel, ThinDataAppState, TransactionRepoModel, + TransactionResponse, UpdateRelayerRequestRaw, + }, + repositories::{ + NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, + TransactionCounterTrait, TransactionRepository, }, - repositories::{NetworkRepository, RelayerRepository, Repository, TransactionRepository}, services::{Signer, SignerFactory}, }; use actix_web::{web, HttpResponse}; @@ -38,10 +44,20 @@ use eyre::Result; /// # Returns /// /// A paginated list of relayers. -pub async fn list_relayers( +pub async fn list_relayers( query: PaginationQuery, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let relayers = state.relayer_repository.list_paginated(query).await?; let mapped_relayers: Vec = @@ -67,10 +83,20 @@ pub async fn list_relayers( /// # Returns /// /// The details of the specified relayer. -pub async fn get_relayer( +pub async fn get_relayer( relayer_id: String, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let relayer = get_relayer_by_id(relayer_id, &state).await?; let relayer_response: RelayerResponse = relayer.into(); @@ -98,10 +124,20 @@ pub async fn get_relayer( /// - **Network Validation**: Confirms the specified network exists for the given network type /// /// All validations must pass before the relayer is created, ensuring referential integrity and security constraints. -pub async fn create_relayer( +pub async fn create_relayer( request: CreateRelayerRequest, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ // Convert request to domain relayer (validates automatically) let relayer = crate::models::Relayer::try_from(request)?; @@ -180,11 +216,21 @@ pub async fn create_relayer( /// # Returns /// /// The updated relayer information. -pub async fn update_relayer( +pub async fn update_relayer( relayer_id: String, patch: serde_json::Value, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; // convert patch to UpdateRelayerRequest to validate @@ -241,10 +287,20 @@ pub async fn update_relayer( /// /// This endpoint ensures that relayers cannot be deleted if they have any pending /// or active transactions. This prevents data loss and maintains system integrity. -pub async fn delete_relayer( +pub async fn delete_relayer( relayer_id: String, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ // First check if the relayer exists let _relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; @@ -285,10 +341,20 @@ pub async fn delete_relayer( /// # Returns /// /// The status of the specified relayer. -pub async fn get_relayer_status( +pub async fn get_relayer_status( relayer_id: String, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let relayer = get_network_relayer(relayer_id, &state).await?; let status = relayer.get_status().await?; @@ -306,10 +372,20 @@ pub async fn get_relayer_status( /// # Returns /// /// The balance of the specified relayer. -pub async fn get_relayer_balance( +pub async fn get_relayer_balance( relayer_id: String, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let relayer = get_network_relayer(relayer_id, &state).await?; let result = relayer.get_balance().await?; @@ -361,11 +437,21 @@ pub async fn send_transaction( /// # Returns /// /// The details of the specified transaction. -pub async fn get_transaction_by_id( +pub async fn get_transaction_by_id( relayer_id: String, transaction_id: String, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ if relayer_id.is_empty() || transaction_id.is_empty() { return Ok(HttpResponse::Ok().json(ApiResponse::<()>::error( "Invalid relayer or transaction ID".to_string(), @@ -392,11 +478,21 @@ pub async fn get_transaction_by_id( /// # Returns /// /// The details of the specified transaction. -pub async fn get_transaction_by_nonce( +pub async fn get_transaction_by_nonce( relayer_id: String, nonce: u64, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; // get by nonce is only supported for EVM network @@ -428,11 +524,21 @@ pub async fn get_transaction_by_nonce( /// # Returns /// /// A paginated list of transactions -pub async fn list_transactions( +pub async fn list_transactions( relayer_id: String, query: PaginationQuery, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ get_relayer_by_id(relayer_id.clone(), &state).await?; let transactions = state @@ -463,10 +569,20 @@ pub async fn list_transactions( /// # Returns /// /// A success response with details about cancelled and failed transactions. -pub async fn delete_pending_transactions( +pub async fn delete_pending_transactions( relayer_id: String, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let relayer = get_relayer_by_id(relayer_id, &state).await?; relayer.validate_active_state()?; let network_relayer = get_network_relayer_by_model(relayer.clone(), &state).await?; @@ -559,11 +675,21 @@ pub async fn replace_transaction( /// # Returns /// /// The signed data response. -pub async fn sign_data( +pub async fn sign_data( relayer_id: String, request: SignDataRequest, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; relayer.validate_active_state()?; let network_relayer = get_network_relayer_by_model(relayer, &state).await?; @@ -588,11 +714,21 @@ pub async fn sign_data( /// # Returns /// /// The signed typed data response. -pub async fn sign_typed_data( +pub async fn sign_typed_data( relayer_id: String, request: SignTypedDataRequest, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; relayer.validate_active_state()?; let network_relayer = get_network_relayer_by_model(relayer, &state).await?; @@ -613,11 +749,21 @@ pub async fn sign_typed_data( /// # Returns /// /// The result of the JSON-RPC call. -pub async fn relayer_rpc( +pub async fn relayer_rpc( relayer_id: String, request: serde_json::Value, - state: web::ThinData, -) -> Result { + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, +{ let relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; relayer.validate_active_state()?; let network_relayer = get_network_relayer_by_model(relayer.clone(), &state).await?; @@ -627,3 +773,499 @@ pub async fn relayer_rpc( Ok(HttpResponse::Ok().json(result)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + models::{ApiResponse, CreateRelayerRequest, RelayerNetworkType, RelayerResponse}, + utils::mocks::mockutils::{ + create_mock_app_state, create_mock_network, create_mock_notification, + create_mock_relayer, create_mock_signer, create_mock_transaction, + }, + }; + use actix_web::body::to_bytes; + use std::env; + + fn setup_test_env() { + env::set_var("API_KEY", "7EF1CB7C-5003-4696-B384-C72AF8C3E15D"); // noboost + env::set_var("REDIS_URL", "redis://localhost:6379"); + } + + fn cleanup_test_env() { + env::remove_var("API_KEY"); + env::remove_var("REDIS_URL"); + } + + /// Helper function to create a test relayer create request + fn create_test_relayer_create_request( + id: Option, + name: &str, + network: &str, + signer_id: &str, + notification_id: Option, + ) -> CreateRelayerRequest { + CreateRelayerRequest { + id, + name: name.to_string(), + network: network.to_string(), + network_type: RelayerNetworkType::Evm, + paused: false, + policies: None, + signer_id: signer_id.to_string(), + notification_id, + custom_rpc_urls: None, + } + } + + // CREATE RELAYER TESTS + + #[actix_web::test] + async fn test_create_relayer_success() { + setup_test_env(); + let network = create_mock_network(); + let signer = create_mock_signer(); + let app_state = + create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + + let request = create_test_relayer_create_request( + Some("test-relayer".to_string()), + "Test Relayer", + "test", // Using "test" to match the mock network name + "test", // Using "test" to match the mock signer id + None, + ); + + let result = create_relayer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "test-relayer"); + assert_eq!(data.name, "Test Relayer"); // This one keeps custom name from the request + assert_eq!(data.network, "test"); + cleanup_test_env(); + } + + #[actix_web::test] + async fn test_create_relayer_with_notification() { + setup_test_env(); + let network = create_mock_network(); + let signer = create_mock_signer(); + let notification = create_mock_notification("test-notification".to_string()); + let app_state = + create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + + // Add notification manually since create_mock_app_state doesn't handle notifications + app_state + .notification_repository + .create(notification) + .await + .unwrap(); + + let request = create_test_relayer_create_request( + Some("test-relayer".to_string()), + "Test Relayer", + "test", // Using "test" to match the mock network name + "test", // Using "test" to match the mock signer id + Some("test-notification".to_string()), + ); + + let result = create_relayer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + cleanup_test_env(); + } + + #[actix_web::test] + async fn test_create_relayer_nonexistent_signer() { + let network = create_mock_network(); + let app_state = create_mock_app_state(None, None, Some(vec![network]), None, None).await; + + let request = create_test_relayer_create_request( + Some("test-relayer".to_string()), + "Test Relayer", + "test", // Using "test" to match the mock network name + "nonexistent-signer", + None, + ); + + let result = create_relayer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_err()); + if let Err(ApiError::NotFound(msg)) = result { + assert!(msg.contains("Signer with ID nonexistent-signer not found")); + } else { + panic!("Expected NotFound error for nonexistent signer"); + } + } + + #[actix_web::test] + async fn test_create_relayer_nonexistent_network() { + let signer = create_mock_signer(); + let app_state = create_mock_app_state(None, Some(vec![signer]), None, None, None).await; + + let request = create_test_relayer_create_request( + Some("test-relayer".to_string()), + "Test Relayer", + "nonexistent-network", + "test", // Using "test" to match the mock signer id + None, + ); + + let result = create_relayer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Network 'nonexistent-network' not found")); + assert!(msg.contains("network configuration exists")); + } else { + panic!("Expected BadRequest error for nonexistent network"); + } + } + + #[actix_web::test] + async fn test_create_relayer_signer_already_in_use() { + let network = create_mock_network(); + let signer = create_mock_signer(); + let mut existing_relayer = create_mock_relayer("existing-relayer".to_string(), false); + existing_relayer.signer_id = "test".to_string(); // Match the mock signer id + existing_relayer.network = "test".to_string(); // Match the mock network name + let app_state = create_mock_app_state( + Some(vec![existing_relayer]), + Some(vec![signer]), + Some(vec![network]), + None, + None, + ) + .await; + + let request = create_test_relayer_create_request( + Some("test-relayer".to_string()), + "Test Relayer", + "test", // Using "test" to match the mock network name + "test", // Using "test" to match the mock signer id + None, + ); + + let result = create_relayer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("signer 'test' is already in use")); + assert!(msg.contains("relayer 'existing-relayer'")); + assert!(msg.contains("network 'test'")); + assert!(msg.contains("security reasons")); + } else { + panic!("Expected BadRequest error for signer already in use"); + } + } + + #[actix_web::test] + async fn test_create_relayer_nonexistent_notification() { + let network = create_mock_network(); + let signer = create_mock_signer(); + let app_state = + create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + + let request = create_test_relayer_create_request( + Some("test-relayer".to_string()), + "Test Relayer", + "test", // Using "test" to match the mock network name + "test", // Using "test" to match the mock signer id + Some("nonexistent-notification".to_string()), + ); + + let result = create_relayer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_err()); + if let Err(ApiError::NotFound(msg)) = result { + assert!(msg.contains("Notification with ID 'nonexistent-notification' not found")); + } else { + panic!("Expected NotFound error for nonexistent notification"); + } + } + + // LIST RELAYERS TESTS + + #[actix_web::test] + async fn test_list_relayers_success() { + let relayer1 = create_mock_relayer("relayer-1".to_string(), false); + let relayer2 = create_mock_relayer("relayer-2".to_string(), false); + let app_state = + create_mock_app_state(Some(vec![relayer1, relayer2]), None, None, None, None).await; + + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = list_relayers(query, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse> = + serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.len(), 2); + } + + #[actix_web::test] + async fn test_list_relayers_empty() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = list_relayers(query, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse> = + serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.len(), 0); + } + + // GET RELAYER TESTS + + #[actix_web::test] + async fn test_get_relayer_success() { + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let result = get_relayer( + "test-relayer".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "test-relayer"); + assert_eq!(data.name, "Relayer test-relayer"); // Mock utility creates name as "Relayer {id}" + } + + #[actix_web::test] + async fn test_get_relayer_not_found() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let result = get_relayer( + "nonexistent".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + if let Err(ApiError::NotFound(msg)) = result { + assert!(msg.contains("Relayer with ID nonexistent not found")); + } else { + panic!("Expected NotFound error"); + } + } + + // UPDATE RELAYER TESTS + + #[actix_web::test] + async fn test_update_relayer_success() { + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "name": "Updated Relayer Name", + "paused": true + }); + + let result = update_relayer( + "test-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.name, "Updated Relayer Name"); + assert!(data.paused); + } + + #[actix_web::test] + async fn test_update_relayer_system_disabled() { + let mut relayer = create_mock_relayer("disabled-relayer".to_string(), false); + relayer.system_disabled = true; + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "name": "Updated Name" + }); + + let result = update_relayer( + "disabled-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Relayer is disabled")); + } else { + panic!("Expected BadRequest error for disabled relayer"); + } + } + + #[actix_web::test] + async fn test_update_relayer_invalid_patch() { + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "invalid_field": "value" + }); + + let result = update_relayer( + "test-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Invalid update request")); + } else { + panic!("Expected BadRequest error for invalid patch"); + } + } + + #[actix_web::test] + async fn test_update_relayer_nonexistent() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let patch = serde_json::json!({ + "name": "Updated Name" + }); + + let result = update_relayer( + "nonexistent-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + if let Err(ApiError::NotFound(msg)) = result { + assert!(msg.contains("Relayer with ID nonexistent-relayer not found")); + } else { + panic!("Expected NotFound error for nonexistent relayer"); + } + } + + // DELETE RELAYER TESTS + + #[actix_web::test] + async fn test_delete_relayer_success() { + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let result = delete_relayer( + "test-relayer".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert!(data.contains("Relayer deleted successfully")); + } + + #[actix_web::test] + async fn test_delete_relayer_with_transactions() { + let relayer = create_mock_relayer("relayer-with-tx".to_string(), false); + let mut transaction = create_mock_transaction(); + transaction.id = "test-tx".to_string(); + transaction.relayer_id = "relayer-with-tx".to_string(); + let app_state = create_mock_app_state( + Some(vec![relayer]), + None, + None, + None, + Some(vec![transaction]), + ) + .await; + + let result = delete_relayer( + "relayer-with-tx".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Cannot delete relayer 'relayer-with-tx'")); + assert!(msg.contains("has 1 transaction(s)")); + assert!(msg.contains("wait for all transactions to complete")); + } else { + panic!("Expected BadRequest error for relayer with transactions"); + } + } + + #[actix_web::test] + async fn test_delete_relayer_nonexistent() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let result = delete_relayer( + "nonexistent-relayer".to_string(), + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + if let Err(ApiError::NotFound(msg)) = result { + assert!(msg.contains("Relayer with ID nonexistent-relayer not found")); + } else { + panic!("Expected NotFound error for nonexistent relayer"); + } + } +} From 917bf31c657188a6f9c6da6f8048c4a157c96521 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Thu, 24 Jul 2025 00:26:22 +0200 Subject: [PATCH 37/59] chore: more contrller tests --- src/api/controllers/relayer.rs | 696 ++++++++++++++++++++++++++++++++- 1 file changed, 695 insertions(+), 1 deletion(-) diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 39cc6debc..7535f0548 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -778,7 +778,10 @@ where mod tests { use super::*; use crate::{ - models::{ApiResponse, CreateRelayerRequest, RelayerNetworkType, RelayerResponse}, + models::{ + ApiResponse, CreateRelayerRequest, RelayerNetworkPolicyResponse, RelayerNetworkType, + RelayerResponse, + }, utils::mocks::mockutils::{ create_mock_app_state, create_mock_network, create_mock_notification, create_mock_relayer, create_mock_signer, create_mock_transaction, @@ -818,6 +821,53 @@ mod tests { } } + /// Helper function to create a mock Solana network + fn create_mock_solana_network() -> crate::models::NetworkRepoModel { + use crate::config::{NetworkConfigCommon, SolanaNetworkConfig}; + use crate::models::{NetworkConfigData, NetworkRepoModel, NetworkType}; + + NetworkRepoModel { + id: "test".to_string(), + name: "test".to_string(), + network_type: NetworkType::Solana, + config: NetworkConfigData::Solana(SolanaNetworkConfig { + common: NetworkConfigCommon { + network: "test".to_string(), + from: None, + rpc_urls: Some(vec!["http://localhost:8899".to_string()]), + explorer_urls: None, + average_blocktime_ms: Some(400), + is_testnet: Some(true), + tags: None, + }, + }), + } + } + + /// Helper function to create a mock Stellar network + fn create_mock_stellar_network() -> crate::models::NetworkRepoModel { + use crate::config::{NetworkConfigCommon, StellarNetworkConfig}; + use crate::models::{NetworkConfigData, NetworkRepoModel, NetworkType}; + + NetworkRepoModel { + id: "test".to_string(), + name: "test".to_string(), + network_type: NetworkType::Stellar, + config: NetworkConfigData::Stellar(StellarNetworkConfig { + common: NetworkConfigCommon { + network: "test".to_string(), + from: None, + rpc_urls: Some(vec!["https://horizon-testnet.stellar.org".to_string()]), + explorer_urls: None, + average_blocktime_ms: Some(5000), + is_testnet: Some(true), + tags: None, + }, + passphrase: Some("Test Network ; September 2015".to_string()), + }), + } + } + // CREATE RELAYER TESTS #[actix_web::test] @@ -853,6 +903,252 @@ mod tests { cleanup_test_env(); } + #[actix_web::test] + async fn test_create_relayer_with_evm_policies() { + setup_test_env(); + let network = create_mock_network(); + let signer = create_mock_signer(); + let app_state = + create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + + let mut request = create_test_relayer_create_request( + Some("test-relayer-policies".to_string()), + "Test Relayer with Policies", + "test", // Using "test" to match the mock network name + "test", // Using "test" to match the mock signer id + None, + ); + + // Add EVM policies + use crate::models::relayer::{CreateRelayerPolicyRequest, RelayerEvmPolicy}; + request.policies = Some(CreateRelayerPolicyRequest::Evm(RelayerEvmPolicy { + gas_price_cap: Some(50000000000), + min_balance: Some(1000000000000000000), + eip1559_pricing: Some(true), + private_transactions: Some(false), + gas_limit_estimation: Some(true), + whitelist_receivers: Some(vec![ + "0x1234567890123456789012345678901234567890".to_string() + ]), + })); + + let result = create_relayer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "test-relayer-policies"); + assert_eq!(data.name, "Test Relayer with Policies"); + assert_eq!(data.network, "test"); + + // Verify policies are present in response + assert!(data.policies.is_some()); + cleanup_test_env(); + } + + #[actix_web::test] + async fn test_create_relayer_with_partial_evm_policies() { + setup_test_env(); + let network = create_mock_network(); + let signer = create_mock_signer(); + let app_state = + create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + + let mut request = create_test_relayer_create_request( + Some("test-relayer-partial".to_string()), + "Test Relayer with Partial Policies", + "test", + "test", + None, + ); + + // Add partial EVM policies + use crate::models::relayer::{CreateRelayerPolicyRequest, RelayerEvmPolicy}; + request.policies = Some(CreateRelayerPolicyRequest::Evm(RelayerEvmPolicy { + gas_price_cap: Some(30000000000), + eip1559_pricing: Some(false), + min_balance: None, + private_transactions: None, + gas_limit_estimation: None, + whitelist_receivers: None, + })); + + let result = create_relayer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "test-relayer-partial"); + + // Verify partial policies are present in response + assert!(data.policies.is_some()); + cleanup_test_env(); + } + + #[actix_web::test] + async fn test_create_relayer_with_solana_policies() { + setup_test_env(); + let network = create_mock_solana_network(); + let signer = create_mock_signer(); + let app_state = + create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + + let mut request = create_test_relayer_create_request( + Some("test-solana-relayer".to_string()), + "Test Solana Relayer", + "test", + "test", + None, + ); + + // Change network type to Solana and add Solana policies + use crate::models::relayer::{ + CreateRelayerPolicyRequest, RelayerNetworkType, RelayerSolanaFeePaymentStrategy, + RelayerSolanaPolicy, + }; + request.network_type = RelayerNetworkType::Solana; + request.policies = Some(CreateRelayerPolicyRequest::Solana(RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + min_balance: Some(5000000), + max_signatures: Some(10), + max_tx_data_size: Some(1232), + max_allowed_fee_lamports: Some(50000), + allowed_programs: None, // Simplified to avoid validation issues + allowed_tokens: None, + fee_margin_percentage: Some(10.0), + allowed_accounts: None, + disallowed_accounts: None, + swap_config: None, + })); + + let result = create_relayer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "test-solana-relayer"); + assert_eq!(data.network_type, RelayerNetworkType::Solana); + assert_eq!(data.name, "Test Solana Relayer"); + + // Verify Solana policies are present in response + assert!(data.policies.is_some()); + // verify policies are correct + let policies = data.policies.unwrap(); + if let RelayerNetworkPolicyResponse::Solana(solana_policy) = policies { + assert_eq!( + solana_policy.fee_payment_strategy, + Some(RelayerSolanaFeePaymentStrategy::Relayer) + ); + assert_eq!(solana_policy.min_balance, Some(5000000)); + assert_eq!(solana_policy.max_signatures, Some(10)); + assert_eq!(solana_policy.max_tx_data_size, Some(1232)); + assert_eq!(solana_policy.max_allowed_fee_lamports, Some(50000)); + } else { + panic!("Expected Solana policies"); + } + cleanup_test_env(); + } + + #[actix_web::test] + async fn test_create_relayer_with_stellar_policies() { + setup_test_env(); + let network = create_mock_stellar_network(); + let signer = create_mock_signer(); + let app_state = + create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + + let mut request = create_test_relayer_create_request( + Some("test-stellar-relayer".to_string()), + "Test Stellar Relayer", + "test", + "test", + None, + ); + + // Change network type to Stellar and add Stellar policies + use crate::models::relayer::{ + CreateRelayerPolicyRequest, RelayerNetworkType, RelayerStellarPolicy, + }; + request.network_type = RelayerNetworkType::Stellar; + request.policies = Some(CreateRelayerPolicyRequest::Stellar(RelayerStellarPolicy { + min_balance: Some(10000000), + max_fee: Some(100), + timeout_seconds: Some(30), + })); + + let result = create_relayer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "test-stellar-relayer"); + assert_eq!(data.network_type, RelayerNetworkType::Stellar); + + // Verify Stellar policies are present in response + assert!(data.policies.is_some()); + cleanup_test_env(); + } + + #[actix_web::test] + async fn test_create_relayer_with_policy_type_mismatch() { + setup_test_env(); + let network = create_mock_network(); + let signer = create_mock_signer(); + let app_state = + create_mock_app_state(None, Some(vec![signer]), Some(vec![network]), None, None).await; + + let mut request = create_test_relayer_create_request( + Some("test-mismatch-relayer".to_string()), + "Test Mismatch Relayer", + "test", + "test", + None, + ); + + // Set network type to EVM but provide Solana policies (should fail) + use crate::models::relayer::{ + CreateRelayerPolicyRequest, RelayerNetworkType, RelayerSolanaPolicy, + }; + request.network_type = RelayerNetworkType::Evm; + request.policies = Some(CreateRelayerPolicyRequest::Solana( + RelayerSolanaPolicy::default(), + )); + + let result = create_relayer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Policy type does not match relayer network type")); + } else { + panic!("Expected BadRequest error for policy type mismatch"); + } + cleanup_test_env(); + } + #[actix_web::test] async fn test_create_relayer_with_notification() { setup_test_env(); @@ -882,6 +1178,12 @@ mod tests { assert!(result.is_ok()); let response = result.unwrap(); assert_eq!(response.status(), 201); + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.notification_id, Some("test-notification".to_string())); cleanup_test_env(); } @@ -1195,6 +1497,398 @@ mod tests { } } + #[actix_web::test] + async fn test_update_relayer_set_evm_policies() { + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "policies": { + "gas_price_cap": 50000000000u64, + "min_balance": 1000000000000000000u64, + "eip1559_pricing": true, + "private_transactions": false, + "gas_limit_estimation": true, + "whitelist_receivers": ["0x1234567890123456789012345678901234567890"] + } + }); + + let result = update_relayer( + "test-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + + // For now, just verify that the policies field exists + // The policy validation can be added once we understand the correct structure + assert!(data.policies.is_some()); + } + + #[actix_web::test] + async fn test_update_relayer_partial_policy_update() { + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + // First update with some policies + let patch1 = serde_json::json!({ + "policies": { + "gas_price_cap": 30000000000u64, + "min_balance": 500000000000000000u64, + "eip1559_pricing": false + } + }); + + let result1 = update_relayer( + "test-relayer".to_string(), + patch1, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result1.is_ok()); + + // Create fresh app state for second update test + let relayer2 = create_mock_relayer("test-relayer".to_string(), false); + let app_state2 = create_mock_app_state(Some(vec![relayer2]), None, None, None, None).await; + + // Second update with only gas_price_cap change + let patch2 = serde_json::json!({ + "policies": { + "gas_price_cap": 60000000000u64 + } + }); + + let result2 = update_relayer( + "test-relayer".to_string(), + patch2, + actix_web::web::ThinData(app_state2), + ) + .await; + + assert!(result2.is_ok()); + let response = result2.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + + // Just verify policies exist for now + assert!(data.policies.is_some()); + } + + #[actix_web::test] + async fn test_update_relayer_unset_notification() { + let mut relayer = create_mock_relayer("test-relayer".to_string(), false); + relayer.notification_id = Some("test-notification".to_string()); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "notification_id": null + }); + + let result = update_relayer( + "test-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.notification_id, None); + } + + #[actix_web::test] + async fn test_update_relayer_unset_custom_rpc_urls() { + let mut relayer = create_mock_relayer("test-relayer".to_string(), false); + relayer.custom_rpc_urls = Some(vec![crate::models::RpcConfig { + url: "https://custom-rpc.example.com".to_string(), + weight: 50, + }]); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "custom_rpc_urls": null + }); + + let result = update_relayer( + "test-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.custom_rpc_urls, None); + } + + #[actix_web::test] + async fn test_update_relayer_set_custom_rpc_urls() { + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "custom_rpc_urls": [ + { + "url": "https://rpc1.example.com", + "weight": 80 + }, + { + "url": "https://rpc2.example.com", + "weight": 60 + } + ] + }); + + let result = update_relayer( + "test-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + + assert!(data.custom_rpc_urls.is_some()); + let rpc_urls = data.custom_rpc_urls.unwrap(); + assert_eq!(rpc_urls.len(), 2); + assert_eq!(rpc_urls[0].url, "https://rpc1.example.com"); + assert_eq!(rpc_urls[0].weight, 80); + assert_eq!(rpc_urls[1].url, "https://rpc2.example.com"); + assert_eq!(rpc_urls[1].weight, 60); + } + + #[actix_web::test] + async fn test_update_relayer_clear_policies() { + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "policies": null + }); + + let result = update_relayer( + "test-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.policies, None); + } + + #[actix_web::test] + async fn test_update_relayer_invalid_policy_structure() { + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "policies": { + "invalid_field_name": "some_value" + } + }); + + let result = update_relayer( + "test-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Invalid policy")); + } else { + panic!("Expected BadRequest error for invalid policy structure"); + } + } + + #[actix_web::test] + async fn test_update_relayer_invalid_evm_policy_values() { + let relayer = create_mock_relayer("test-relayer".to_string(), false); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "policies": { + "gas_price_cap": "invalid_number", + "min_balance": -1 + } + }); + + let result = update_relayer( + "test-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Invalid policy") || msg.contains("Invalid update request")); + } else { + panic!("Expected BadRequest error for invalid policy values"); + } + } + + #[actix_web::test] + async fn test_update_relayer_multiple_fields_at_once() { + let mut relayer = create_mock_relayer("test-relayer".to_string(), false); + relayer.notification_id = Some("old-notification".to_string()); + let app_state = create_mock_app_state(Some(vec![relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "name": "Multi-Update Relayer", + "paused": true, + "notification_id": null, + "policies": { + "gas_price_cap": 40000000000u64, + "eip1559_pricing": true + }, + "custom_rpc_urls": [ + { + "url": "https://new-rpc.example.com", + "weight": 90 + } + ] + }); + + let result = update_relayer( + "test-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + + // Verify all fields were updated correctly + assert_eq!(data.name, "Multi-Update Relayer"); + assert!(data.paused); + assert_eq!(data.notification_id, None); + + // Verify policies and RPC URLs were set + assert!(data.policies.is_some()); + assert!(data.custom_rpc_urls.is_some()); + let rpc_urls = data.custom_rpc_urls.unwrap(); + assert_eq!(rpc_urls.len(), 1); + assert_eq!(rpc_urls[0].url, "https://new-rpc.example.com"); + assert_eq!(rpc_urls[0].weight, 90); + } + + #[actix_web::test] + async fn test_update_relayer_solana_policies() { + use crate::models::{ + NetworkType, RelayerNetworkPolicy, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, + }; + + // Create a Solana relayer (not the default EVM one) + let mut solana_relayer = create_mock_relayer("test-solana-relayer".to_string(), false); + solana_relayer.network_type = NetworkType::Solana; + solana_relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy::default()); + + let app_state = + create_mock_app_state(Some(vec![solana_relayer]), None, None, None, None).await; + + let patch = serde_json::json!({ + "policies": { + "fee_payment_strategy": "user", + "min_balance": 2000000, + "max_signatures": 5, + "max_tx_data_size": 800, + "max_allowed_fee_lamports": 25000, + "fee_margin_percentage": 15.0 + } + }); + + let result = update_relayer( + "test-solana-relayer".to_string(), + patch, + actix_web::web::ThinData(app_state), + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + + let body = to_bytes(response.into_body()).await.unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + + // Verify Solana policies are present and correctly updated + assert!(data.policies.is_some()); + let policies = data.policies.unwrap(); + if let RelayerNetworkPolicyResponse::Solana(solana_policy) = policies { + assert_eq!( + solana_policy.fee_payment_strategy, + Some(RelayerSolanaFeePaymentStrategy::User) + ); + assert_eq!(solana_policy.min_balance, Some(2000000)); + assert_eq!(solana_policy.max_signatures, Some(5)); + assert_eq!(solana_policy.max_tx_data_size, Some(800)); + assert_eq!(solana_policy.max_allowed_fee_lamports, Some(25000)); + assert_eq!(solana_policy.fee_margin_percentage, Some(15.0)); + } else { + panic!("Expected Solana policies in response"); + } + } + // DELETE RELAYER TESTS #[actix_web::test] From 93d2dcc6c71f496d6f37ec646a4ec45136ab3099 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Thu, 24 Jul 2025 00:48:24 +0200 Subject: [PATCH 38/59] chore: resolve bug --- src/repositories/signer/signer_redis.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/repositories/signer/signer_redis.rs b/src/repositories/signer/signer_redis.rs index b5b646a06..1e2d628be 100644 --- a/src/repositories/signer/signer_redis.rs +++ b/src/repositories/signer/signer_redis.rs @@ -112,11 +112,12 @@ impl RedisSignerRepository { let mut failed_count = 0; let mut failed_ids = Vec::new(); + for (i, value) in values.into_iter().enumerate() { match value { Some(json) => { - match self.deserialize_entity::(&json, &ids[i], "signer") { - Ok(signer) => signers.push(signer), + match self.deserialize_entity::(&json, &ids[i], "signer") { + Ok(signer) => signers.push(SignerRepoModel::from(signer)), Err(e) => { failed_count += 1; error!("Failed to deserialize signer {}: {}", ids[i], e); From 0abe630b841ffd7d1a61f4713020b5b5afde3ec4 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Thu, 24 Jul 2025 07:50:20 +0200 Subject: [PATCH 39/59] chore: tests --- src/api/controllers/signer.rs | 321 +++++++++++++++++++++++- src/repositories/signer/signer_redis.rs | 5 +- 2 files changed, 323 insertions(+), 3 deletions(-) diff --git a/src/api/controllers/signer.rs b/src/api/controllers/signer.rs index d4fe3546b..3e5b9d44e 100644 --- a/src/api/controllers/signer.rs +++ b/src/api/controllers/signer.rs @@ -229,7 +229,12 @@ where mod tests { use super::*; use crate::{ - models::{LocalSignerConfig, SignerConfig, SignerType}, + models::{ + AwsKmsSignerRequestConfig, GoogleCloudKmsSignerKeyRequestConfig, + GoogleCloudKmsSignerRequestConfig, GoogleCloudKmsSignerServiceAccountRequestConfig, + LocalSignerConfig, LocalSignerRequestConfig, SignerConfig, SignerConfigRequest, + SignerType, SignerTypeRequest, TurnkeySignerRequestConfig, VaultSignerRequestConfig, + }, utils::mocks::mockutils::create_mock_app_state, }; use secrets::SecretVec; @@ -455,6 +460,320 @@ mod tests { ); } + #[actix_web::test] + async fn test_create_signer_local_with_valid_key() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = SignerCreateRequest { + id: Some("local-signer-test".to_string()), + signer_type: SignerTypeRequest::Local, + config: SignerConfigRequest::Local(LocalSignerRequestConfig { + key: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(), // 32 bytes as hex + }), + }; + + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "local-signer-test"); + assert_eq!(data.r#type, SignerType::Local); + } + + #[actix_web::test] + async fn test_create_signer_aws_kms_comprehensive() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = SignerCreateRequest { + id: Some("aws-kms-signer".to_string()), + signer_type: SignerTypeRequest::AwsKms, + config: SignerConfigRequest::AwsKms(AwsKmsSignerRequestConfig { + region: "us-west-2".to_string(), + key_id: + "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012" + .to_string(), + }), + }; + + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "aws-kms-signer"); + assert_eq!(data.r#type, SignerType::AwsKms); + } + + #[actix_web::test] + async fn test_create_signer_vault() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = SignerCreateRequest { + id: Some("vault-signer".to_string()), + signer_type: SignerTypeRequest::Vault, + config: SignerConfigRequest::Vault(VaultSignerRequestConfig { + address: "https://vault.example.com:8200".to_string(), + namespace: Some("development".to_string()), + role_id: "test-role-id-12345".to_string(), + secret_id: "test-secret-id-67890".to_string(), + key_name: "ethereum-key".to_string(), + mount_point: Some("secret".to_string()), + }), + }; + + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "vault-signer"); + assert_eq!(data.r#type, SignerType::Vault); + } + + #[actix_web::test] + async fn test_create_signer_vault_transit() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + use crate::models::{ + SignerConfigRequest, SignerTypeRequest, VaultTransitSignerRequestConfig, + }; + let request = SignerCreateRequest { + id: Some("vault-transit-signer".to_string()), + signer_type: SignerTypeRequest::VaultTransit, + config: SignerConfigRequest::VaultTransit(VaultTransitSignerRequestConfig { + key_name: "ethereum-transit-key".to_string(), + address: "https://vault.example.com:8200".to_string(), + namespace: None, + role_id: "transit-role-id-12345".to_string(), + secret_id: "transit-secret-id-67890".to_string(), + pubkey: "0x04a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd5b8dec5235a0fa8722476c7709c02559e3aa73aa03918ba2d492eea75abea235".to_string(), + mount_point: Some("transit".to_string()), + }), + }; + + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "vault-transit-signer"); + assert_eq!(data.r#type, SignerType::VaultTransit); + } + + #[actix_web::test] + async fn test_create_signer_turnkey() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = SignerCreateRequest { + id: Some("turnkey-signer".to_string()), + signer_type: SignerTypeRequest::Turnkey, + config: SignerConfigRequest::Turnkey(TurnkeySignerRequestConfig { + api_public_key: "turnkey-api-public-key-example".to_string(), + api_private_key: "turnkey-api-private-key-example".to_string(), + organization_id: "turnkey-org-12345".to_string(), + private_key_id: "turnkey-private-key-67890".to_string(), + public_key: "0x04a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd5b8dec5235a0fa8722476c7709c02559e3aa73aa03918ba2d492eea75abea235".to_string(), + }), + }; + + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "turnkey-signer"); + assert_eq!(data.r#type, SignerType::Turnkey); + } + + #[actix_web::test] + async fn test_create_signer_google_cloud_kms() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = SignerCreateRequest { + id: Some("gcp-kms-signer".to_string()), + signer_type: SignerTypeRequest::GoogleCloudKms, + config: SignerConfigRequest::GoogleCloudKms(GoogleCloudKmsSignerRequestConfig { + service_account: GoogleCloudKmsSignerServiceAccountRequestConfig { + private_key: "-----BEGIN PRIVATE KEY-----\nSDFGSDFGSDGSDFGSDFGSDFGSDFGSDFGSAFAS...\n-----END PRIVATE KEY-----\n".to_string(), // noboost + private_key_id: "gcp-private-key-id-12345".to_string(), + project_id: "my-gcp-project".to_string(), + client_email: "service-account@my-gcp-project.iam.gserviceaccount.com".to_string(), + client_id: "123456789012345678901".to_string(), + auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs".to_string(), + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/service-account%40my-gcp-project.iam.gserviceaccount.com".to_string(), + universe_domain: "googleapis.com".to_string(), + }, + key: GoogleCloudKmsSignerKeyRequestConfig { + location: "global".to_string(), + key_ring_id: "ethereum-keyring".to_string(), + key_id: "ethereum-signing-key".to_string(), + key_version: 1, + }, + }), + }; + + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert_eq!(data.id, "gcp-kms-signer"); + assert_eq!(data.r#type, SignerType::GoogleCloudKms); + } + + #[actix_web::test] + async fn test_create_signer_auto_generated_id() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = SignerCreateRequest { + id: None, // Let the system generate an ID + signer_type: SignerTypeRequest::Local, + config: SignerConfigRequest::Local(LocalSignerRequestConfig { + key: "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321".to_string(), + }), + }; + + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 201); + + let body = actix_web::body::to_bytes(response.into_body()) + .await + .unwrap(); + let api_response: ApiResponse = serde_json::from_slice(&body).unwrap(); + + assert!(api_response.success); + let data = api_response.data.unwrap(); + assert!(!data.id.is_empty()); + assert!(uuid::Uuid::parse_str(&data.id).is_ok()); // Should be a valid UUID + assert_eq!(data.r#type, SignerType::Local); + } + + #[actix_web::test] + async fn test_create_signer_invalid_local_key() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = SignerCreateRequest { + id: Some("invalid-key-signer".to_string()), + signer_type: SignerTypeRequest::Local, + config: SignerConfigRequest::Local(LocalSignerRequestConfig { + key: "invalid-hex-key".to_string(), // Invalid hex + }), + }; + + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Invalid hex key format")); + } else { + panic!("Expected BadRequest error for invalid hex key"); + } + } + + #[actix_web::test] + async fn test_create_signer_invalid_vault_address() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = SignerCreateRequest { + id: Some("invalid-vault-signer".to_string()), + signer_type: SignerTypeRequest::Vault, + config: SignerConfigRequest::Vault(VaultSignerRequestConfig { + address: "not-a-valid-url".to_string(), // Invalid URL + namespace: None, + role_id: "test-role".to_string(), + secret_id: "test-secret".to_string(), + key_name: "test-key".to_string(), + mount_point: None, + }), + }; + + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Address must be a valid URL")); + } else { + panic!("Expected BadRequest error for invalid Vault address"); + } + } + + #[actix_web::test] + async fn test_create_signer_empty_aws_kms_key_id() { + let app_state = create_mock_app_state(None, None, None, None, None).await; + + let request = SignerCreateRequest { + id: Some("empty-key-id-signer".to_string()), + signer_type: SignerTypeRequest::AwsKms, + config: SignerConfigRequest::AwsKms(AwsKmsSignerRequestConfig { + region: "us-east-1".to_string(), + key_id: "".to_string(), // Empty key ID + }), + }; + + let result = create_signer(request, actix_web::web::ThinData(app_state)).await; + + assert!(result.is_err()); + if let Err(ApiError::BadRequest(msg)) = result { + assert!(msg.contains("Key ID cannot be empty")); + } else { + panic!("Expected BadRequest error for empty AWS KMS key ID"); + } + } + #[actix_web::test] async fn test_update_signer_not_allowed() { let app_state = create_mock_app_state(None, None, None, None, None).await; diff --git a/src/repositories/signer/signer_redis.rs b/src/repositories/signer/signer_redis.rs index 1e2d628be..f5e894a05 100644 --- a/src/repositories/signer/signer_redis.rs +++ b/src/repositories/signer/signer_redis.rs @@ -112,11 +112,12 @@ impl RedisSignerRepository { let mut failed_count = 0; let mut failed_ids = Vec::new(); - for (i, value) in values.into_iter().enumerate() { match value { Some(json) => { - match self.deserialize_entity::(&json, &ids[i], "signer") { + match self + .deserialize_entity::(&json, &ids[i], "signer") + { Ok(signer) => signers.push(SignerRepoModel::from(signer)), Err(e) => { failed_count += 1; From 995664e035d3c96b58acca08bd2e303a05dea2b4 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Thu, 24 Jul 2025 10:13:50 +0200 Subject: [PATCH 40/59] chore: more tests --- src/models/relayer/config.rs | 777 ++++++++++++++++++++- src/models/relayer/mod.rs | 1123 +++++++++++++++++++++++++++++- src/models/relayer/repository.rs | 709 ++++++++++++++++++- src/models/relayer/request.rs | 473 ++++++++++++- src/models/relayer/response.rs | 764 +++++++++++++++++--- 5 files changed, 3719 insertions(+), 127 deletions(-) diff --git a/src/models/relayer/config.rs b/src/models/relayer/config.rs index b787dda89..7f32e5d53 100644 --- a/src/models/relayer/config.rs +++ b/src/models/relayer/config.rs @@ -15,7 +15,7 @@ use crate::config::{ConfigFileError, ConfigFileNetworkType, NetworksFileConfig}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "lowercase")] pub enum ConfigFileRelayerNetworkPolicy { Evm(ConfigFileRelayerEvmPolicy), @@ -23,7 +23,7 @@ pub enum ConfigFileRelayerNetworkPolicy { Stellar(ConfigFileRelayerStellarPolicy), } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigFileRelayerEvmPolicy { pub gas_price_cap: Option, @@ -34,7 +34,7 @@ pub struct ConfigFileRelayerEvmPolicy { pub gas_limit_estimation: Option, } -#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] pub struct AllowedTokenSwapConfig { /// Conversion slippage percentage for token. Optional. pub slippage_percentage: Option, @@ -46,7 +46,7 @@ pub struct AllowedTokenSwapConfig { pub retain_min_amount: Option, } -#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] pub struct AllowedToken { pub mint: String, /// Decimals for the token. Optional. @@ -73,7 +73,7 @@ pub enum ConfigFileRelayerSolanaSwapStrategy { JupiterUltra, } -#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] pub struct JupiterSwapOptions { /// Maximum priority fee (in lamports) for a transaction. Optional. pub priority_fee_max_lamports: Option, @@ -83,7 +83,7 @@ pub struct JupiterSwapOptions { pub dynamic_compute_unit_limit: Option, } -#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigFileRelayerSolanaSwapPolicy { /// DEX strategy to use for token swaps. @@ -99,7 +99,7 @@ pub struct ConfigFileRelayerSolanaSwapPolicy { pub jupiter_swap_options: Option, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigFileRelayerSolanaPolicy { /// Determines if the relayer pays the transaction fee or the user. Optional. @@ -139,7 +139,7 @@ pub struct ConfigFileRelayerSolanaPolicy { pub swap_config: Option, } -#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigFileRelayerStellarPolicy { pub max_fee: Option, @@ -483,3 +483,764 @@ impl RelayersFileConfig { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ConfigFileNetworkType; + use crate::models::relayer::{RelayerSolanaFeePaymentStrategy, RelayerSolanaSwapStrategy}; + use serde_json; + + fn create_test_networks_config() -> NetworksFileConfig { + // Create a mock networks config for validation tests + NetworksFileConfig::new(vec![]).unwrap() + } + + #[test] + fn test_relayer_file_config_deserialization_evm() { + let json_input = r#"{ + "id": "test-evm-relayer", + "name": "Test EVM Relayer", + "network": "mainnet", + "paused": false, + "network_type": "evm", + "signer_id": "test-signer", + "policies": { + "gas_price_cap": 100000000000, + "eip1559_pricing": true, + "min_balance": 1000000000000000000, + "gas_limit_estimation": false, + "private_transactions": null + }, + "notification_id": "test-notification", + "custom_rpc_urls": [ + "https://mainnet.infura.io/v3/test", + {"url": "https://eth.llamarpc.com", "weight": 80} + ] + }"#; + + let config: RelayerFileConfig = serde_json::from_str(json_input).unwrap(); + + assert_eq!(config.id, "test-evm-relayer"); + assert_eq!(config.name, "Test EVM Relayer"); + assert_eq!(config.network, "mainnet"); + assert!(!config.paused); + assert_eq!(config.network_type, ConfigFileNetworkType::Evm); + assert_eq!(config.signer_id, "test-signer"); + assert_eq!( + config.notification_id, + Some("test-notification".to_string()) + ); + + // Test policies + assert!(config.policies.is_some()); + if let Some(ConfigFileRelayerNetworkPolicy::Evm(evm_policy)) = config.policies { + assert_eq!(evm_policy.gas_price_cap, Some(100000000000)); + assert_eq!(evm_policy.eip1559_pricing, Some(true)); + assert_eq!(evm_policy.min_balance, Some(1000000000000000000)); + assert_eq!(evm_policy.gas_limit_estimation, Some(false)); + assert_eq!(evm_policy.private_transactions, None); + } else { + panic!("Expected EVM policy"); + } + + // Test custom RPC URLs (both string and object formats) + assert!(config.custom_rpc_urls.is_some()); + let rpc_urls = config.custom_rpc_urls.unwrap(); + assert_eq!(rpc_urls.len(), 2); + assert_eq!(rpc_urls[0].url, "https://mainnet.infura.io/v3/test"); + assert_eq!(rpc_urls[0].weight, 100); // Default weight + assert_eq!(rpc_urls[1].url, "https://eth.llamarpc.com"); + assert_eq!(rpc_urls[1].weight, 80); + } + + #[test] + fn test_relayer_file_config_deserialization_solana() { + let json_input = r#"{ + "id": "test-solana-relayer", + "name": "Test Solana Relayer", + "network": "mainnet", + "paused": true, + "network_type": "solana", + "signer_id": "test-signer", + "policies": { + "fee_payment_strategy": "relayer", + "min_balance": 5000000, + "max_signatures": 8, + "max_tx_data_size": 1024, + "fee_margin_percentage": 2.5, + "allowed_tokens": [ + { + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "decimals": 6, + "symbol": "USDC", + "max_allowed_fee": 100000, + "swap_config": { + "slippage_percentage": 0.5, + "min_amount": 1000, + "max_amount": 10000000 + } + } + ], + "allowed_programs": ["11111111111111111111111111111111"], + "swap_config": { + "strategy": "jupiter-swap", + "cron_schedule": "0 0 * * *", + "min_balance_threshold": 1000000, + "jupiter_swap_options": { + "priority_fee_max_lamports": 10000, + "priority_level": "high", + "dynamic_compute_unit_limit": true + } + } + } + }"#; + + let config: RelayerFileConfig = serde_json::from_str(json_input).unwrap(); + + assert_eq!(config.id, "test-solana-relayer"); + assert_eq!(config.network_type, ConfigFileNetworkType::Solana); + assert!(config.paused); + + // Test Solana policies + assert!(config.policies.is_some()); + if let Some(ConfigFileRelayerNetworkPolicy::Solana(solana_policy)) = config.policies { + assert_eq!( + solana_policy.fee_payment_strategy, + Some(ConfigFileRelayerSolanaFeePaymentStrategy::Relayer) + ); + assert_eq!(solana_policy.min_balance, Some(5000000)); + assert_eq!(solana_policy.max_signatures, Some(8)); + assert_eq!(solana_policy.max_tx_data_size, Some(1024)); + assert_eq!(solana_policy.fee_margin_percentage, Some(2.5)); + + // Test allowed tokens + assert!(solana_policy.allowed_tokens.is_some()); + let tokens = solana_policy.allowed_tokens.as_ref().unwrap(); + assert_eq!(tokens.len(), 1); + assert_eq!( + tokens[0].mint, + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + ); + assert_eq!(tokens[0].decimals, Some(6)); + assert_eq!(tokens[0].symbol, Some("USDC".to_string())); + assert_eq!(tokens[0].max_allowed_fee, Some(100000)); + + // Test swap config in token + assert!(tokens[0].swap_config.is_some()); + let token_swap = tokens[0].swap_config.as_ref().unwrap(); + assert_eq!(token_swap.slippage_percentage, Some(0.5)); + assert_eq!(token_swap.min_amount, Some(1000)); + assert_eq!(token_swap.max_amount, Some(10000000)); + + // Test main swap config + assert!(solana_policy.swap_config.is_some()); + let swap_config = solana_policy.swap_config.as_ref().unwrap(); + assert_eq!( + swap_config.strategy, + Some(ConfigFileRelayerSolanaSwapStrategy::JupiterSwap) + ); + assert_eq!(swap_config.cron_schedule, Some("0 0 * * *".to_string())); + assert_eq!(swap_config.min_balance_threshold, Some(1000000)); + + // Test Jupiter options + assert!(swap_config.jupiter_swap_options.is_some()); + let jupiter_opts = swap_config.jupiter_swap_options.as_ref().unwrap(); + assert_eq!(jupiter_opts.priority_fee_max_lamports, Some(10000)); + assert_eq!(jupiter_opts.priority_level, Some("high".to_string())); + assert_eq!(jupiter_opts.dynamic_compute_unit_limit, Some(true)); + } else { + panic!("Expected Solana policy"); + } + } + + #[test] + fn test_relayer_file_config_deserialization_stellar() { + let json_input = r#"{ + "id": "test-stellar-relayer", + "name": "Test Stellar Relayer", + "network": "mainnet", + "paused": false, + "network_type": "stellar", + "signer_id": "test-signer", + "policies": { + "min_balance": 20000000, + "max_fee": 100000, + "timeout_seconds": 30 + }, + "custom_rpc_urls": [ + {"url": "https://stellar-node.example.com", "weight": 100} + ] + }"#; + + let config: RelayerFileConfig = serde_json::from_str(json_input).unwrap(); + + assert_eq!(config.id, "test-stellar-relayer"); + assert_eq!(config.network_type, ConfigFileNetworkType::Stellar); + assert!(!config.paused); + + // Test Stellar policies + assert!(config.policies.is_some()); + if let Some(ConfigFileRelayerNetworkPolicy::Stellar(stellar_policy)) = config.policies { + assert_eq!(stellar_policy.min_balance, Some(20000000)); + assert_eq!(stellar_policy.max_fee, Some(100000)); + assert_eq!(stellar_policy.timeout_seconds, Some(30)); + } else { + panic!("Expected Stellar policy"); + } + } + + #[test] + fn test_relayer_file_config_deserialization_minimal() { + // Test minimal config without optional fields + let json_input = r#"{ + "id": "minimal-relayer", + "name": "Minimal Relayer", + "network": "testnet", + "paused": false, + "network_type": "evm", + "signer_id": "minimal-signer" + }"#; + + let config: RelayerFileConfig = serde_json::from_str(json_input).unwrap(); + + assert_eq!(config.id, "minimal-relayer"); + assert_eq!(config.name, "Minimal Relayer"); + assert_eq!(config.network, "testnet"); + assert!(!config.paused); + assert_eq!(config.network_type, ConfigFileNetworkType::Evm); + assert_eq!(config.signer_id, "minimal-signer"); + assert_eq!(config.notification_id, None); + assert_eq!(config.policies, None); + assert_eq!(config.custom_rpc_urls, None); + } + + #[test] + fn test_relayer_file_config_deserialization_missing_required_field() { + // Test missing required field should fail + let json_input = r#"{ + "name": "Test Relayer", + "network": "mainnet", + "paused": false, + "network_type": "evm", + "signer_id": "test-signer" + }"#; + + let result = serde_json::from_str::(json_input); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("missing field `id`")); + } + + #[test] + fn test_relayer_file_config_deserialization_invalid_network_type() { + let json_input = r#"{ + "id": "test-relayer", + "name": "Test Relayer", + "network": "mainnet", + "paused": false, + "network_type": "invalid", + "signer_id": "test-signer" + }"#; + + let result = serde_json::from_str::(json_input); + assert!(result.is_err()); + } + + #[test] + fn test_relayer_file_config_deserialization_wrong_policy_for_network_type() { + // Test EVM network type with Solana policy should fail + let json_input = r#"{ + "id": "test-relayer", + "name": "Test Relayer", + "network": "mainnet", + "paused": false, + "network_type": "evm", + "signer_id": "test-signer", + "policies": { + "fee_payment_strategy": "relayer" + } + }"#; + + let result = serde_json::from_str::(json_input); + assert!(result.is_err()); + } + + #[test] + fn test_convert_config_policies_to_domain_evm() { + let config_policy = ConfigFileRelayerNetworkPolicy::Evm(ConfigFileRelayerEvmPolicy { + gas_price_cap: Some(50000000000), + whitelist_receivers: Some(vec!["0x123".to_string(), "0x456".to_string()]), + eip1559_pricing: Some(true), + private_transactions: Some(false), + min_balance: Some(2000000000000000000), + gas_limit_estimation: Some(true), + }); + + let domain_policy = convert_config_policies_to_domain(config_policy).unwrap(); + + if let RelayerNetworkPolicy::Evm(evm_policy) = domain_policy { + assert_eq!(evm_policy.gas_price_cap, Some(50000000000)); + assert_eq!( + evm_policy.whitelist_receivers, + Some(vec!["0x123".to_string(), "0x456".to_string()]) + ); + assert_eq!(evm_policy.eip1559_pricing, Some(true)); + assert_eq!(evm_policy.private_transactions, Some(false)); + assert_eq!(evm_policy.min_balance, Some(2000000000000000000)); + assert_eq!(evm_policy.gas_limit_estimation, Some(true)); + } else { + panic!("Expected EVM domain policy"); + } + } + + #[test] + fn test_convert_config_policies_to_domain_solana() { + let config_policy = ConfigFileRelayerNetworkPolicy::Solana(ConfigFileRelayerSolanaPolicy { + fee_payment_strategy: Some(ConfigFileRelayerSolanaFeePaymentStrategy::User), + fee_margin_percentage: Some(1.5), + min_balance: Some(3000000), + allowed_tokens: Some(vec![AllowedToken { + mint: "TokenMint123".to_string(), + decimals: Some(9), + symbol: Some("TOKEN".to_string()), + max_allowed_fee: Some(50000), + swap_config: Some(AllowedTokenSwapConfig { + slippage_percentage: Some(1.0), + min_amount: Some(100), + max_amount: Some(1000000), + retain_min_amount: Some(500), + }), + }]), + allowed_programs: Some(vec!["Program123".to_string()]), + allowed_accounts: Some(vec!["Account123".to_string()]), + disallowed_accounts: None, + max_tx_data_size: Some(2048), + max_signatures: Some(10), + max_allowed_fee_lamports: Some(100000), + swap_config: Some(ConfigFileRelayerSolanaSwapPolicy { + strategy: Some(ConfigFileRelayerSolanaSwapStrategy::JupiterUltra), + cron_schedule: Some("0 */6 * * *".to_string()), + min_balance_threshold: Some(2000000), + jupiter_swap_options: Some(JupiterSwapOptions { + priority_fee_max_lamports: Some(5000), + priority_level: Some("medium".to_string()), + dynamic_compute_unit_limit: Some(false), + }), + }), + }); + + let domain_policy = convert_config_policies_to_domain(config_policy).unwrap(); + + if let RelayerNetworkPolicy::Solana(solana_policy) = domain_policy { + assert_eq!( + solana_policy.fee_payment_strategy, + Some(RelayerSolanaFeePaymentStrategy::User) + ); + assert_eq!(solana_policy.fee_margin_percentage, Some(1.5)); + assert_eq!(solana_policy.min_balance, Some(3000000)); + assert_eq!(solana_policy.max_tx_data_size, Some(2048)); + assert_eq!(solana_policy.max_signatures, Some(10)); + + // Test allowed tokens conversion + assert!(solana_policy.allowed_tokens.is_some()); + let tokens = solana_policy.allowed_tokens.unwrap(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].mint, "TokenMint123"); + assert_eq!(tokens[0].decimals, Some(9)); + assert_eq!(tokens[0].symbol, Some("TOKEN".to_string())); + assert_eq!(tokens[0].max_allowed_fee, Some(50000)); + + // Test swap config conversion + assert!(solana_policy.swap_config.is_some()); + let swap_config = solana_policy.swap_config.unwrap(); + assert_eq!( + swap_config.strategy, + Some(RelayerSolanaSwapStrategy::JupiterUltra) + ); + assert_eq!(swap_config.cron_schedule, Some("0 */6 * * *".to_string())); + assert_eq!(swap_config.min_balance_threshold, Some(2000000)); + } else { + panic!("Expected Solana domain policy"); + } + } + + #[test] + fn test_convert_config_policies_to_domain_stellar() { + let config_policy = + ConfigFileRelayerNetworkPolicy::Stellar(ConfigFileRelayerStellarPolicy { + min_balance: Some(25000000), + max_fee: Some(150000), + timeout_seconds: Some(60), + }); + + let domain_policy = convert_config_policies_to_domain(config_policy).unwrap(); + + if let RelayerNetworkPolicy::Stellar(stellar_policy) = domain_policy { + assert_eq!(stellar_policy.min_balance, Some(25000000)); + assert_eq!(stellar_policy.max_fee, Some(150000)); + assert_eq!(stellar_policy.timeout_seconds, Some(60)); + } else { + panic!("Expected Stellar domain policy"); + } + } + + #[test] + fn test_try_from_relayer_file_config_to_domain_evm() { + let config = RelayerFileConfig { + id: "test-evm".to_string(), + name: "Test EVM Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: ConfigFileNetworkType::Evm, + policies: Some(ConfigFileRelayerNetworkPolicy::Evm( + ConfigFileRelayerEvmPolicy { + gas_price_cap: Some(75000000000), + whitelist_receivers: None, + eip1559_pricing: Some(true), + private_transactions: None, + min_balance: None, + gas_limit_estimation: None, + }, + )), + signer_id: "test-signer".to_string(), + notification_id: Some("test-notification".to_string()), + custom_rpc_urls: None, + }; + + let domain_relayer = Relayer::try_from(config).unwrap(); + + assert_eq!(domain_relayer.id, "test-evm"); + assert_eq!(domain_relayer.name, "Test EVM Relayer"); + assert_eq!(domain_relayer.network, "mainnet"); + assert!(!domain_relayer.paused); + assert_eq!( + domain_relayer.network_type, + crate::models::relayer::RelayerNetworkType::Evm + ); + assert_eq!(domain_relayer.signer_id, "test-signer"); + assert_eq!( + domain_relayer.notification_id, + Some("test-notification".to_string()) + ); + + // Test policy conversion + assert!(domain_relayer.policies.is_some()); + if let Some(RelayerNetworkPolicy::Evm(evm_policy)) = domain_relayer.policies { + assert_eq!(evm_policy.gas_price_cap, Some(75000000000)); + assert_eq!(evm_policy.eip1559_pricing, Some(true)); + } else { + panic!("Expected EVM domain policy"); + } + } + + #[test] + fn test_try_from_relayer_file_config_to_domain_solana() { + let config = RelayerFileConfig { + id: "test-solana".to_string(), + name: "Test Solana Relayer".to_string(), + network: "mainnet".to_string(), + paused: true, + network_type: ConfigFileNetworkType::Solana, + policies: Some(ConfigFileRelayerNetworkPolicy::Solana( + ConfigFileRelayerSolanaPolicy { + fee_payment_strategy: Some(ConfigFileRelayerSolanaFeePaymentStrategy::Relayer), + fee_margin_percentage: None, + min_balance: Some(4000000), + allowed_tokens: None, + allowed_programs: None, + allowed_accounts: None, + disallowed_accounts: None, + max_tx_data_size: None, + max_signatures: Some(7), + max_allowed_fee_lamports: None, + swap_config: None, + }, + )), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let domain_relayer = Relayer::try_from(config).unwrap(); + + assert_eq!( + domain_relayer.network_type, + crate::models::relayer::RelayerNetworkType::Solana + ); + assert!(domain_relayer.paused); + + // Test policy conversion + assert!(domain_relayer.policies.is_some()); + if let Some(RelayerNetworkPolicy::Solana(solana_policy)) = domain_relayer.policies { + assert_eq!( + solana_policy.fee_payment_strategy, + Some(RelayerSolanaFeePaymentStrategy::Relayer) + ); + assert_eq!(solana_policy.min_balance, Some(4000000)); + assert_eq!(solana_policy.max_signatures, Some(7)); + } else { + panic!("Expected Solana domain policy"); + } + } + + #[test] + fn test_try_from_relayer_file_config_to_domain_stellar() { + let config = RelayerFileConfig { + id: "test-stellar".to_string(), + name: "Test Stellar Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: ConfigFileNetworkType::Stellar, + policies: Some(ConfigFileRelayerNetworkPolicy::Stellar( + ConfigFileRelayerStellarPolicy { + min_balance: Some(35000000), + max_fee: Some(200000), + timeout_seconds: Some(90), + }, + )), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let domain_relayer = Relayer::try_from(config).unwrap(); + + assert_eq!( + domain_relayer.network_type, + crate::models::relayer::RelayerNetworkType::Stellar + ); + + // Test policy conversion + assert!(domain_relayer.policies.is_some()); + if let Some(RelayerNetworkPolicy::Stellar(stellar_policy)) = domain_relayer.policies { + assert_eq!(stellar_policy.min_balance, Some(35000000)); + assert_eq!(stellar_policy.max_fee, Some(200000)); + assert_eq!(stellar_policy.timeout_seconds, Some(90)); + } else { + panic!("Expected Stellar domain policy"); + } + } + + #[test] + fn test_try_from_relayer_file_config_validation_error() { + let config = RelayerFileConfig { + id: "".to_string(), // Invalid: empty ID + name: "Test Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: ConfigFileNetworkType::Evm, + policies: None, + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let result = Relayer::try_from(config); + assert!(result.is_err()); + + if let Err(ConfigFileError::MissingField(field)) = result { + assert_eq!(field, "relayer id"); + } else { + panic!("Expected MissingField error for empty ID"); + } + } + + #[test] + fn test_try_from_relayer_file_config_invalid_id_format() { + let config = RelayerFileConfig { + id: "invalid@id".to_string(), // Invalid: contains @ + name: "Test Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: ConfigFileNetworkType::Evm, + policies: None, + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let result = Relayer::try_from(config); + assert!(result.is_err()); + + if let Err(ConfigFileError::InvalidIdFormat(_)) = result { + // Success - expected error type + } else { + panic!("Expected InvalidIdFormat error"); + } + } + + #[test] + fn test_relayers_file_config_validation_success() { + let relayer_config = RelayerFileConfig { + id: "test-relayer".to_string(), + name: "Test Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: ConfigFileNetworkType::Evm, + policies: None, + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let relayers_config = RelayersFileConfig::new(vec![relayer_config]); + let networks_config = create_test_networks_config(); + + // Note: This will fail because we don't have the network in our mock config + // But we're testing that the validation logic runs + let result = relayers_config.validate(&networks_config); + + // We expect this to fail due to network reference, but not due to empty relayers + assert!(result.is_err()); + if let Err(ConfigFileError::InvalidReference(_)) = result { + // Expected - network doesn't exist in our mock config + } else { + panic!("Expected InvalidReference error"); + } + } + + #[test] + fn test_relayers_file_config_validation_empty_relayers() { + let relayers_config = RelayersFileConfig::new(vec![]); + let networks_config = create_test_networks_config(); + + let result = relayers_config.validate(&networks_config); + assert!(result.is_err()); + + if let Err(ConfigFileError::MissingField(field)) = result { + assert_eq!(field, "relayers"); + } else { + panic!("Expected MissingField error for empty relayers"); + } + } + + #[test] + fn test_relayers_file_config_validation_duplicate_ids() { + let relayer_config1 = RelayerFileConfig { + id: "duplicate-id".to_string(), + name: "Test Relayer 1".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: ConfigFileNetworkType::Evm, + policies: None, + signer_id: "test-signer1".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let relayer_config2 = RelayerFileConfig { + id: "duplicate-id".to_string(), // Same ID + name: "Test Relayer 2".to_string(), + network: "testnet".to_string(), + paused: false, + network_type: ConfigFileNetworkType::Solana, + policies: None, + signer_id: "test-signer2".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let relayers_config = RelayersFileConfig::new(vec![relayer_config1, relayer_config2]); + let networks_config = create_test_networks_config(); + + let result = relayers_config.validate(&networks_config); + assert!(result.is_err()); + + // The validation may fail with network reference error before reaching duplicate ID check + // Let's check for either error type since both are valid validation failures + match result { + Err(ConfigFileError::DuplicateId(id)) => { + assert_eq!(id, "duplicate-id"); + } + Err(ConfigFileError::InvalidReference(_)) => { + // Also acceptable - network doesn't exist in our mock config + } + Err(other) => { + panic!( + "Expected DuplicateId or InvalidReference error, got: {:?}", + other + ); + } + Ok(_) => { + panic!("Expected validation to fail but it succeeded"); + } + } + } + + #[test] + fn test_relayers_file_config_validation_empty_network() { + let relayer_config = RelayerFileConfig { + id: "test-relayer".to_string(), + name: "Test Relayer".to_string(), + network: "".to_string(), // Empty network + paused: false, + network_type: ConfigFileNetworkType::Evm, + policies: None, + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let relayers_config = RelayersFileConfig::new(vec![relayer_config]); + let networks_config = create_test_networks_config(); + + let result = relayers_config.validate(&networks_config); + assert!(result.is_err()); + + if let Err(ConfigFileError::InvalidFormat(msg)) = result { + assert!(msg.contains("relayer.network cannot be empty")); + } else { + panic!("Expected InvalidFormat error for empty network"); + } + } + + #[test] + fn test_config_file_policy_serialization() { + // Test that individual policy structs can be serialized/deserialized + let evm_policy = ConfigFileRelayerEvmPolicy { + gas_price_cap: Some(80000000000), + whitelist_receivers: Some(vec!["0xabc".to_string()]), + eip1559_pricing: Some(false), + private_transactions: Some(true), + min_balance: Some(500000000000000000), + gas_limit_estimation: Some(true), + }; + + let serialized = serde_json::to_string(&evm_policy).unwrap(); + let deserialized: ConfigFileRelayerEvmPolicy = serde_json::from_str(&serialized).unwrap(); + assert_eq!(evm_policy, deserialized); + + let solana_policy = ConfigFileRelayerSolanaPolicy { + fee_payment_strategy: Some(ConfigFileRelayerSolanaFeePaymentStrategy::User), + fee_margin_percentage: Some(3.0), + min_balance: Some(6000000), + allowed_tokens: None, + allowed_programs: Some(vec!["Program456".to_string()]), + allowed_accounts: None, + disallowed_accounts: Some(vec!["DisallowedAccount".to_string()]), + max_tx_data_size: Some(1536), + max_signatures: Some(12), + max_allowed_fee_lamports: Some(200000), + swap_config: None, + }; + + let serialized = serde_json::to_string(&solana_policy).unwrap(); + let deserialized: ConfigFileRelayerSolanaPolicy = + serde_json::from_str(&serialized).unwrap(); + assert_eq!(solana_policy, deserialized); + + let stellar_policy = ConfigFileRelayerStellarPolicy { + min_balance: Some(45000000), + max_fee: Some(250000), + timeout_seconds: Some(120), + }; + + let serialized = serde_json::to_string(&stellar_policy).unwrap(); + let deserialized: ConfigFileRelayerStellarPolicy = + serde_json::from_str(&serialized).unwrap(); + assert_eq!(stellar_policy, deserialized); + } +} diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index d9da01266..1647189d8 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -700,9 +700,1130 @@ impl From for crate::models::ApiError { #[cfg(test)] mod tests { use super::*; - use serde_json::json; + // ===== RelayerNetworkType Tests ===== + + #[test] + fn test_relayer_network_type_display() { + assert_eq!(RelayerNetworkType::Evm.to_string(), "evm"); + assert_eq!(RelayerNetworkType::Solana.to_string(), "solana"); + assert_eq!(RelayerNetworkType::Stellar.to_string(), "stellar"); + } + + #[test] + fn test_relayer_network_type_from_config_file_type() { + assert_eq!( + RelayerNetworkType::from(ConfigFileNetworkType::Evm), + RelayerNetworkType::Evm + ); + assert_eq!( + RelayerNetworkType::from(ConfigFileNetworkType::Solana), + RelayerNetworkType::Solana + ); + assert_eq!( + RelayerNetworkType::from(ConfigFileNetworkType::Stellar), + RelayerNetworkType::Stellar + ); + } + + #[test] + fn test_config_file_network_type_from_relayer_type() { + assert_eq!( + ConfigFileNetworkType::from(RelayerNetworkType::Evm), + ConfigFileNetworkType::Evm + ); + assert_eq!( + ConfigFileNetworkType::from(RelayerNetworkType::Solana), + ConfigFileNetworkType::Solana + ); + assert_eq!( + ConfigFileNetworkType::from(RelayerNetworkType::Stellar), + ConfigFileNetworkType::Stellar + ); + } + + #[test] + fn test_relayer_network_type_serialization() { + let evm_type = RelayerNetworkType::Evm; + let serialized = serde_json::to_string(&evm_type).unwrap(); + assert_eq!(serialized, "\"evm\""); + + let deserialized: RelayerNetworkType = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, RelayerNetworkType::Evm); + + // Test all types + let types = vec![ + (RelayerNetworkType::Evm, "\"evm\""), + (RelayerNetworkType::Solana, "\"solana\""), + (RelayerNetworkType::Stellar, "\"stellar\""), + ]; + + for (network_type, expected_json) in types { + let serialized = serde_json::to_string(&network_type).unwrap(); + assert_eq!(serialized, expected_json); + + let deserialized: RelayerNetworkType = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, network_type); + } + } + + // ===== Policy Struct Tests ===== + + #[test] + fn test_relayer_evm_policy_default() { + let default_policy = RelayerEvmPolicy::default(); + assert_eq!(default_policy.min_balance, None); + assert_eq!(default_policy.gas_limit_estimation, None); + assert_eq!(default_policy.gas_price_cap, None); + assert_eq!(default_policy.whitelist_receivers, None); + assert_eq!(default_policy.eip1559_pricing, None); + assert_eq!(default_policy.private_transactions, None); + } + + #[test] + fn test_relayer_evm_policy_serialization() { + let policy = RelayerEvmPolicy { + min_balance: Some(1000000000000000000), + gas_limit_estimation: Some(true), + gas_price_cap: Some(50000000000), + whitelist_receivers: Some(vec!["0x123".to_string(), "0x456".to_string()]), + eip1559_pricing: Some(false), + private_transactions: Some(true), + }; + + let serialized = serde_json::to_string(&policy).unwrap(); + let deserialized: RelayerEvmPolicy = serde_json::from_str(&serialized).unwrap(); + assert_eq!(policy, deserialized); + } + + #[test] + fn test_allowed_token_new() { + let token = AllowedToken::new( + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), + Some(100000), + None, + ); + + assert_eq!(token.mint, "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + assert_eq!(token.max_allowed_fee, Some(100000)); + assert_eq!(token.decimals, None); + assert_eq!(token.symbol, None); + assert_eq!(token.swap_config, None); + } + + #[test] + fn test_allowed_token_new_partial() { + let swap_config = AllowedTokenSwapConfig { + slippage_percentage: Some(0.5), + min_amount: Some(1000), + max_amount: Some(10000000), + retain_min_amount: Some(500), + }; + + let token = AllowedToken::new_partial( + "TokenMint123".to_string(), + Some(50000), + Some(swap_config.clone()), + ); + + assert_eq!(token.mint, "TokenMint123"); + assert_eq!(token.max_allowed_fee, Some(50000)); + assert_eq!(token.swap_config, Some(swap_config)); + } + + #[test] + fn test_allowed_token_swap_config_default() { + let config = AllowedTokenSwapConfig::default(); + assert_eq!(config.slippage_percentage, None); + assert_eq!(config.min_amount, None); + assert_eq!(config.max_amount, None); + assert_eq!(config.retain_min_amount, None); + } + + #[test] + fn test_relayer_solana_fee_payment_strategy_default() { + let default_strategy = RelayerSolanaFeePaymentStrategy::default(); + assert_eq!(default_strategy, RelayerSolanaFeePaymentStrategy::User); + } + + #[test] + fn test_relayer_solana_swap_strategy_default() { + let default_strategy = RelayerSolanaSwapStrategy::default(); + assert_eq!(default_strategy, RelayerSolanaSwapStrategy::Noop); + } + + #[test] + fn test_jupiter_swap_options_default() { + let options = JupiterSwapOptions::default(); + assert_eq!(options.priority_fee_max_lamports, None); + assert_eq!(options.priority_level, None); + assert_eq!(options.dynamic_compute_unit_limit, None); + } + + #[test] + fn test_relayer_solana_swap_policy_default() { + let policy = RelayerSolanaSwapPolicy::default(); + assert_eq!(policy.strategy, None); + assert_eq!(policy.cron_schedule, None); + assert_eq!(policy.min_balance_threshold, None); + assert_eq!(policy.jupiter_swap_options, None); + } + + #[test] + fn test_relayer_solana_policy_default() { + let policy = RelayerSolanaPolicy::default(); + assert_eq!(policy.allowed_programs, None); + assert_eq!(policy.max_signatures, None); + assert_eq!(policy.max_tx_data_size, None); + assert_eq!(policy.min_balance, None); + assert_eq!(policy.allowed_tokens, None); + assert_eq!(policy.fee_payment_strategy, None); + assert_eq!(policy.fee_margin_percentage, None); + assert_eq!(policy.allowed_accounts, None); + assert_eq!(policy.disallowed_accounts, None); + assert_eq!(policy.max_allowed_fee_lamports, None); + assert_eq!(policy.swap_config, None); + } + + #[test] + fn test_relayer_solana_policy_get_allowed_tokens() { + let token1 = AllowedToken::new("mint1".to_string(), Some(1000), None); + let token2 = AllowedToken::new("mint2".to_string(), Some(2000), None); + + let policy = RelayerSolanaPolicy { + allowed_tokens: Some(vec![token1.clone(), token2.clone()]), + ..RelayerSolanaPolicy::default() + }; + + let tokens = policy.get_allowed_tokens(); + assert_eq!(tokens.len(), 2); + assert_eq!(tokens[0], token1); + assert_eq!(tokens[1], token2); + + // Test empty case + let empty_policy = RelayerSolanaPolicy::default(); + let empty_tokens = empty_policy.get_allowed_tokens(); + assert_eq!(empty_tokens.len(), 0); + } + + #[test] + fn test_relayer_solana_policy_get_allowed_token_entry() { + let token1 = AllowedToken::new("mint1".to_string(), Some(1000), None); + let token2 = AllowedToken::new("mint2".to_string(), Some(2000), None); + + let policy = RelayerSolanaPolicy { + allowed_tokens: Some(vec![token1.clone(), token2.clone()]), + ..RelayerSolanaPolicy::default() + }; + + let found_token = policy.get_allowed_token_entry("mint1").unwrap(); + assert_eq!(found_token, token1); + + let not_found = policy.get_allowed_token_entry("mint3"); + assert!(not_found.is_none()); + + // Test empty case + let empty_policy = RelayerSolanaPolicy::default(); + let empty_result = empty_policy.get_allowed_token_entry("mint1"); + assert!(empty_result.is_none()); + } + + #[test] + fn test_relayer_solana_policy_get_swap_config() { + let swap_config = RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + cron_schedule: Some("0 0 * * *".to_string()), + min_balance_threshold: Some(1000000), + jupiter_swap_options: None, + }; + + let policy = RelayerSolanaPolicy { + swap_config: Some(swap_config.clone()), + ..RelayerSolanaPolicy::default() + }; + + let retrieved_config = policy.get_swap_config().unwrap(); + assert_eq!(retrieved_config, swap_config); + + // Test None case + let empty_policy = RelayerSolanaPolicy::default(); + assert!(empty_policy.get_swap_config().is_none()); + } + + #[test] + fn test_relayer_solana_policy_get_allowed_token_decimals() { + let mut token1 = AllowedToken::new("mint1".to_string(), Some(1000), None); + token1.decimals = Some(9); + + let token2 = AllowedToken::new("mint2".to_string(), Some(2000), None); + // token2.decimals is None + + let policy = RelayerSolanaPolicy { + allowed_tokens: Some(vec![token1, token2]), + ..RelayerSolanaPolicy::default() + }; + + assert_eq!(policy.get_allowed_token_decimals("mint1"), Some(9)); + assert_eq!(policy.get_allowed_token_decimals("mint2"), None); + assert_eq!(policy.get_allowed_token_decimals("mint3"), None); + } + + #[test] + fn test_relayer_stellar_policy_default() { + let policy = RelayerStellarPolicy::default(); + assert_eq!(policy.min_balance, None); + assert_eq!(policy.max_fee, None); + assert_eq!(policy.timeout_seconds, None); + } + + // ===== RelayerNetworkPolicy Tests ===== + + #[test] + fn test_relayer_network_policy_get_evm_policy() { + let evm_policy = RelayerEvmPolicy { + gas_price_cap: Some(50000000000), + ..RelayerEvmPolicy::default() + }; + + let network_policy = RelayerNetworkPolicy::Evm(evm_policy.clone()); + assert_eq!(network_policy.get_evm_policy(), evm_policy); + + // Test non-EVM policy returns default + let solana_policy = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy::default()); + assert_eq!(solana_policy.get_evm_policy(), RelayerEvmPolicy::default()); + + let stellar_policy = RelayerNetworkPolicy::Stellar(RelayerStellarPolicy::default()); + assert_eq!(stellar_policy.get_evm_policy(), RelayerEvmPolicy::default()); + } + + #[test] + fn test_relayer_network_policy_get_solana_policy() { + let solana_policy = RelayerSolanaPolicy { + min_balance: Some(5000000), + ..RelayerSolanaPolicy::default() + }; + + let network_policy = RelayerNetworkPolicy::Solana(solana_policy.clone()); + assert_eq!(network_policy.get_solana_policy(), solana_policy); + + // Test non-Solana policy returns default + let evm_policy = RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()); + assert_eq!( + evm_policy.get_solana_policy(), + RelayerSolanaPolicy::default() + ); + + let stellar_policy = RelayerNetworkPolicy::Stellar(RelayerStellarPolicy::default()); + assert_eq!( + stellar_policy.get_solana_policy(), + RelayerSolanaPolicy::default() + ); + } + + #[test] + fn test_relayer_network_policy_get_stellar_policy() { + let stellar_policy = RelayerStellarPolicy { + min_balance: Some(20000000), + max_fee: Some(100000), + timeout_seconds: Some(30), + }; + + let network_policy = RelayerNetworkPolicy::Stellar(stellar_policy.clone()); + assert_eq!(network_policy.get_stellar_policy(), stellar_policy); + + // Test non-Stellar policy returns default + let evm_policy = RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()); + assert_eq!( + evm_policy.get_stellar_policy(), + RelayerStellarPolicy::default() + ); + + let solana_policy = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy::default()); + assert_eq!( + solana_policy.get_stellar_policy(), + RelayerStellarPolicy::default() + ); + } + + // ===== Relayer Construction and Basic Tests ===== + + #[test] + fn test_relayer_new() { + let relayer = Relayer::new( + "test-relayer".to_string(), + "Test Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Evm, + Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default())), + "test-signer".to_string(), + Some("test-notification".to_string()), + None, + ); + + assert_eq!(relayer.id, "test-relayer"); + assert_eq!(relayer.name, "Test Relayer"); + assert_eq!(relayer.network, "mainnet"); + assert!(!relayer.paused); + assert_eq!(relayer.network_type, RelayerNetworkType::Evm); + assert_eq!(relayer.signer_id, "test-signer"); + assert_eq!( + relayer.notification_id, + Some("test-notification".to_string()) + ); + assert!(relayer.policies.is_some()); + assert_eq!(relayer.custom_rpc_urls, None); + } + + // ===== Relayer Validation Tests ===== + + #[test] + fn test_relayer_validation_success() { + let relayer = Relayer::new( + "valid-relayer-id".to_string(), + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Evm, + None, + "valid-signer".to_string(), + None, + None, + ); + + assert!(relayer.validate().is_ok()); + } + + #[test] + fn test_relayer_validation_empty_id() { + let relayer = Relayer::new( + "".to_string(), // Empty ID + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Evm, + None, + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerValidationError::EmptyId + )); + } + + #[test] + fn test_relayer_validation_id_too_long() { + let long_id = "a".repeat(37); // 37 characters, exceeds 36 limit + let relayer = Relayer::new( + long_id, + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Evm, + None, + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerValidationError::IdTooLong + )); + } + + #[test] + fn test_relayer_validation_invalid_id_format() { + let relayer = Relayer::new( + "invalid@id".to_string(), // Contains invalid character @ + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Evm, + None, + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerValidationError::InvalidIdFormat + )); + } + + #[test] + fn test_relayer_validation_empty_name() { + let relayer = Relayer::new( + "valid-id".to_string(), + "".to_string(), // Empty name + "mainnet".to_string(), + false, + RelayerNetworkType::Evm, + None, + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerValidationError::EmptyName + )); + } + + #[test] + fn test_relayer_validation_empty_network() { + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "".to_string(), // Empty network + false, + RelayerNetworkType::Evm, + None, + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerValidationError::EmptyNetwork + )); + } + + #[test] + fn test_relayer_validation_empty_signer_id() { + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Evm, + None, + "".to_string(), // Empty signer ID + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + // This should trigger InvalidPolicy error due to empty signer ID + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Signer ID cannot be empty")); + } else { + panic!("Expected InvalidPolicy error for empty signer ID"); + } + } + + #[test] + fn test_relayer_validation_mismatched_network_type_and_policy() { + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Evm, // EVM network type + Some(RelayerNetworkPolicy::Solana(RelayerSolanaPolicy::default())), // But Solana policy + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Network type") && msg.contains("does not match policy type")); + } else { + panic!("Expected InvalidPolicy error for mismatched network type and policy"); + } + } + + #[test] + fn test_relayer_validation_invalid_rpc_url() { + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Evm, + None, + "valid-signer".to_string(), + None, + Some(vec![RpcConfig::new("invalid-url".to_string())]), // Invalid URL + ); + + let result = relayer.validate(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerValidationError::InvalidRpcUrl(_) + )); + } + + #[test] + fn test_relayer_validation_invalid_rpc_weight() { + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Evm, + None, + "valid-signer".to_string(), + None, + Some(vec![RpcConfig { + url: "https://example.com".to_string(), + weight: 150, + }]), // Weight > 100 + ); + + let result = relayer.validate(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RelayerValidationError::InvalidRpcWeight + )); + } + + // ===== Solana-specific Validation Tests ===== + + #[test] + fn test_relayer_validation_solana_invalid_public_key() { + let policy = RelayerSolanaPolicy { + allowed_programs: Some(vec!["invalid-pubkey".to_string()]), // Invalid Solana pubkey + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Public key must be a valid Solana address")); + } else { + panic!("Expected InvalidPolicy error for invalid Solana public key"); + } + } + + #[test] + fn test_relayer_validation_solana_valid_public_key() { + let policy = RelayerSolanaPolicy { + allowed_programs: Some(vec!["11111111111111111111111111111111".to_string()]), // Valid Solana pubkey + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + assert!(relayer.validate().is_ok()); + } + + #[test] + fn test_relayer_validation_solana_negative_fee_margin() { + let policy = RelayerSolanaPolicy { + fee_margin_percentage: Some(-1.0), // Negative fee margin + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Negative fee margin percentage values are not accepted")); + } else { + panic!("Expected InvalidPolicy error for negative fee margin"); + } + } + + #[test] + fn test_relayer_validation_solana_conflicting_accounts() { + let policy = RelayerSolanaPolicy { + allowed_accounts: Some(vec!["11111111111111111111111111111111".to_string()]), + disallowed_accounts: Some(vec!["22222222222222222222222222222222".to_string()]), + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("allowed_accounts and disallowed_accounts cannot be both present")); + } else { + panic!("Expected InvalidPolicy error for conflicting accounts"); + } + } + + #[test] + fn test_relayer_validation_solana_swap_config_wrong_fee_payment_strategy() { + let swap_config = RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + ..RelayerSolanaSwapPolicy::default() + }; + + let policy = RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), // Relayer strategy + swap_config: Some(swap_config), // But has swap config + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Swap config only supported for user fee payment strategy")); + } else { + panic!("Expected InvalidPolicy error for swap config with relayer fee payment"); + } + } + + #[test] + fn test_relayer_validation_solana_jupiter_strategy_wrong_network() { + let swap_config = RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + ..RelayerSolanaSwapPolicy::default() + }; + + let policy = RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + swap_config: Some(swap_config), + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "testnet".to_string(), // Not mainnet-beta + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("strategy is only supported on mainnet-beta")); + } else { + panic!("Expected InvalidPolicy error for Jupiter strategy on wrong network"); + } + } + + #[test] + fn test_relayer_validation_solana_empty_cron_schedule() { + let swap_config = RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + cron_schedule: Some("".to_string()), // Empty cron schedule + ..RelayerSolanaSwapPolicy::default() + }; + + let policy = RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + swap_config: Some(swap_config), + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet-beta".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Empty cron schedule is not accepted")); + } else { + panic!("Expected InvalidPolicy error for empty cron schedule"); + } + } + + #[test] + fn test_relayer_validation_solana_invalid_cron_schedule() { + let swap_config = RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + cron_schedule: Some("invalid cron".to_string()), // Invalid cron format + ..RelayerSolanaSwapPolicy::default() + }; + + let policy = RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + swap_config: Some(swap_config), + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet-beta".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Invalid cron schedule format")); + } else { + panic!("Expected InvalidPolicy error for invalid cron schedule"); + } + } + + #[test] + fn test_relayer_validation_solana_jupiter_options_wrong_strategy() { + let jupiter_options = JupiterSwapOptions { + priority_fee_max_lamports: Some(10000), + priority_level: Some("high".to_string()), + dynamic_compute_unit_limit: Some(true), + }; + + let swap_config = RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterUltra), // Wrong strategy + jupiter_swap_options: Some(jupiter_options), + ..RelayerSolanaSwapPolicy::default() + }; + + let policy = RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + swap_config: Some(swap_config), + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet-beta".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("JupiterSwap options are only valid for JupiterSwap strategy")); + } else { + panic!("Expected InvalidPolicy error for Jupiter options with wrong strategy"); + } + } + + #[test] + fn test_relayer_validation_solana_jupiter_zero_max_lamports() { + let jupiter_options = JupiterSwapOptions { + priority_fee_max_lamports: Some(0), // Zero is invalid + priority_level: Some("high".to_string()), + dynamic_compute_unit_limit: Some(true), + }; + + let swap_config = RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + jupiter_swap_options: Some(jupiter_options), + ..RelayerSolanaSwapPolicy::default() + }; + + let policy = RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + swap_config: Some(swap_config), + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet-beta".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Max lamports must be greater than 0")); + } else { + panic!("Expected InvalidPolicy error for zero max lamports"); + } + } + + #[test] + fn test_relayer_validation_solana_jupiter_empty_priority_level() { + let jupiter_options = JupiterSwapOptions { + priority_fee_max_lamports: Some(10000), + priority_level: Some("".to_string()), // Empty priority level + dynamic_compute_unit_limit: Some(true), + }; + + let swap_config = RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + jupiter_swap_options: Some(jupiter_options), + ..RelayerSolanaSwapPolicy::default() + }; + + let policy = RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + swap_config: Some(swap_config), + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet-beta".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Priority level cannot be empty")); + } else { + panic!("Expected InvalidPolicy error for empty priority level"); + } + } + + #[test] + fn test_relayer_validation_solana_jupiter_invalid_priority_level() { + let jupiter_options = JupiterSwapOptions { + priority_fee_max_lamports: Some(10000), + priority_level: Some("invalid".to_string()), // Invalid priority level + dynamic_compute_unit_limit: Some(true), + }; + + let swap_config = RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + jupiter_swap_options: Some(jupiter_options), + ..RelayerSolanaSwapPolicy::default() + }; + + let policy = RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + swap_config: Some(swap_config), + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet-beta".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Priority level must be one of: medium, high, veryHigh")); + } else { + panic!("Expected InvalidPolicy error for invalid priority level"); + } + } + + #[test] + fn test_relayer_validation_solana_jupiter_missing_priority_fee() { + let jupiter_options = JupiterSwapOptions { + priority_fee_max_lamports: None, // Missing + priority_level: Some("high".to_string()), + dynamic_compute_unit_limit: Some(true), + }; + + let swap_config = RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + jupiter_swap_options: Some(jupiter_options), + ..RelayerSolanaSwapPolicy::default() + }; + + let policy = RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + swap_config: Some(swap_config), + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet-beta".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Priority Fee Max lamports must be set if priority level is set")); + } else { + panic!("Expected InvalidPolicy error for missing priority fee"); + } + } + + #[test] + fn test_relayer_validation_solana_jupiter_missing_priority_level() { + let jupiter_options = JupiterSwapOptions { + priority_fee_max_lamports: Some(10000), + priority_level: None, // Missing + dynamic_compute_unit_limit: Some(true), + }; + + let swap_config = RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + jupiter_swap_options: Some(jupiter_options), + ..RelayerSolanaSwapPolicy::default() + }; + + let policy = RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + swap_config: Some(swap_config), + ..RelayerSolanaPolicy::default() + }; + + let relayer = Relayer::new( + "valid-id".to_string(), + "Valid Relayer".to_string(), + "mainnet-beta".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(policy)), + "valid-signer".to_string(), + None, + None, + ); + + let result = relayer.validate(); + assert!(result.is_err()); + if let Err(RelayerValidationError::InvalidPolicy(msg)) = result { + assert!(msg.contains("Priority level must be set if priority fee max lamports is set")); + } else { + panic!("Expected InvalidPolicy error for missing priority level"); + } + } + + // ===== Error Conversion Tests ===== + + #[test] + fn test_relayer_validation_error_to_api_error() { + use crate::models::ApiError; + + // Test each variant + let errors = vec![ + (RelayerValidationError::EmptyId, "ID cannot be empty"), + (RelayerValidationError::InvalidIdFormat, "ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long"), + (RelayerValidationError::IdTooLong, "ID must not exceed 36 characters"), + (RelayerValidationError::EmptyName, "Name cannot be empty"), + (RelayerValidationError::EmptyNetwork, "Network cannot be empty"), + (RelayerValidationError::InvalidPolicy("test error".to_string()), "Invalid relayer policy: test error"), + (RelayerValidationError::InvalidRpcUrl("http://invalid".to_string()), "Invalid RPC URL: http://invalid"), + (RelayerValidationError::InvalidRpcWeight, "RPC URL weight must be in range 0-100"), + (RelayerValidationError::InvalidField("test field error".to_string()), "test field error"), + ]; + + for (validation_error, expected_message) in errors { + let api_error: ApiError = validation_error.into(); + if let ApiError::BadRequest(message) = api_error { + assert_eq!(message, expected_message); + } else { + panic!("Expected BadRequest variant"); + } + } + } + + // ===== JSON Patch Tests (already existing) ===== + #[test] fn test_apply_json_patch_comprehensive() { // Create a sample relayer diff --git a/src/models/relayer/repository.rs b/src/models/relayer/repository.rs index ee87608cc..d6729f72a 100644 --- a/src/models/relayer/repository.rs +++ b/src/models/relayer/repository.rs @@ -130,7 +130,10 @@ impl From for RelayerRepoModel { #[cfg(test)] mod tests { - use crate::models::RelayerEvmPolicy; + use crate::models::{ + AllowedToken, RelayerEvmPolicy, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, + RelayerStellarPolicy, + }; use super::*; @@ -150,12 +153,72 @@ mod tests { } } + fn create_test_relayer_solana(paused: bool, system_disabled: bool) -> RelayerRepoModel { + RelayerRepoModel { + id: "test_solana_relayer".to_string(), + name: "Test Solana Relayer".to_string(), + paused, + system_disabled, + network: "mainnet".to_string(), + network_type: NetworkType::Solana, + signer_id: "test_signer".to_string(), + policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + min_balance: Some(1000000), + max_signatures: Some(5), + allowed_tokens: None, + allowed_programs: None, + allowed_accounts: None, + disallowed_accounts: None, + max_tx_data_size: None, + max_allowed_fee_lamports: None, + swap_config: None, + fee_margin_percentage: None, + }), + address: "SolanaAddress123".to_string(), + notification_id: None, + custom_rpc_urls: None, + } + } + + fn create_test_relayer_stellar(paused: bool, system_disabled: bool) -> RelayerRepoModel { + RelayerRepoModel { + id: "test_stellar_relayer".to_string(), + name: "Test Stellar Relayer".to_string(), + paused, + system_disabled, + network: "mainnet".to_string(), + network_type: NetworkType::Stellar, + signer_id: "test_signer".to_string(), + policies: RelayerNetworkPolicy::Stellar(RelayerStellarPolicy { + min_balance: Some(20000000), + max_fee: Some(100000), + timeout_seconds: Some(30), + }), + address: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(), + notification_id: None, + custom_rpc_urls: None, + } + } + #[test] fn test_validate_active_state_success() { let relayer = create_test_relayer(false, false); assert!(relayer.validate_active_state().is_ok()); } + #[test] + fn test_validate_active_state_success_solana() { + let relayer = create_test_relayer_solana(false, false); + assert!(relayer.validate_active_state().is_ok()); + } + + #[test] + fn test_validate_active_state_success_stellar() { + let relayer = create_test_relayer_stellar(false, false); + assert!(relayer.validate_active_state().is_ok()); + } + #[test] fn test_validate_active_state_paused() { let relayer = create_test_relayer(true, false); @@ -164,6 +227,22 @@ mod tests { assert!(matches!(result.unwrap_err(), RelayerError::RelayerPaused)); } + #[test] + fn test_validate_active_state_paused_solana() { + let relayer = create_test_relayer_solana(true, false); + let result = relayer.validate_active_state(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RelayerError::RelayerPaused)); + } + + #[test] + fn test_validate_active_state_paused_stellar() { + let relayer = create_test_relayer_stellar(true, false); + let result = relayer.validate_active_state(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RelayerError::RelayerPaused)); + } + #[test] fn test_validate_active_state_disabled() { let relayer = create_test_relayer(false, true); @@ -172,6 +251,312 @@ mod tests { assert!(matches!(result.unwrap_err(), RelayerError::RelayerDisabled)); } + #[test] + fn test_validate_active_state_disabled_solana() { + let relayer = create_test_relayer_solana(false, true); + let result = relayer.validate_active_state(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RelayerError::RelayerDisabled)); + } + + #[test] + fn test_validate_active_state_disabled_stellar() { + let relayer = create_test_relayer_stellar(false, true); + let result = relayer.validate_active_state(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RelayerError::RelayerDisabled)); + } + + #[test] + fn test_validate_active_state_both_paused_and_disabled() { + // When both are true, should return paused error (checked first) + let relayer = create_test_relayer(true, true); + let result = relayer.validate_active_state(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RelayerError::RelayerPaused)); + } + + #[test] + fn test_conversion_from_repo_model_to_domain_evm() { + let repo_model = create_test_relayer(false, false); + let domain_relayer = Relayer::from(repo_model.clone()); + + assert_eq!(domain_relayer.id, repo_model.id); + assert_eq!(domain_relayer.name, repo_model.name); + assert_eq!(domain_relayer.network, repo_model.network); + assert_eq!(domain_relayer.paused, repo_model.paused); + assert_eq!(domain_relayer.network_type, repo_model.network_type); + assert_eq!(domain_relayer.signer_id, repo_model.signer_id); + assert_eq!(domain_relayer.notification_id, repo_model.notification_id); + assert_eq!(domain_relayer.custom_rpc_urls, repo_model.custom_rpc_urls); + + // Policies should be converted correctly + assert!(domain_relayer.policies.is_some()); + if let Some(RelayerNetworkPolicy::Evm(_)) = domain_relayer.policies { + // Success - correct policy type + } else { + panic!("Expected EVM policy"); + } + } + + #[test] + fn test_conversion_from_repo_model_to_domain_solana() { + let repo_model = create_test_relayer_solana(false, false); + let domain_relayer = Relayer::from(repo_model.clone()); + + assert_eq!(domain_relayer.id, repo_model.id); + assert_eq!(domain_relayer.network_type, RelayerNetworkType::Solana); + + // Policies should be converted correctly + assert!(domain_relayer.policies.is_some()); + if let Some(RelayerNetworkPolicy::Solana(solana_policy)) = domain_relayer.policies { + assert_eq!(solana_policy.min_balance, Some(1000000)); + assert_eq!(solana_policy.max_signatures, Some(5)); + assert_eq!( + solana_policy.fee_payment_strategy, + Some(RelayerSolanaFeePaymentStrategy::Relayer) + ); + } else { + panic!("Expected Solana policy"); + } + } + + #[test] + fn test_conversion_from_repo_model_to_domain_stellar() { + let repo_model = create_test_relayer_stellar(false, false); + let domain_relayer = Relayer::from(repo_model.clone()); + + assert_eq!(domain_relayer.id, repo_model.id); + assert_eq!(domain_relayer.network_type, RelayerNetworkType::Stellar); + + // Policies should be converted correctly + assert!(domain_relayer.policies.is_some()); + if let Some(RelayerNetworkPolicy::Stellar(stellar_policy)) = domain_relayer.policies { + assert_eq!(stellar_policy.min_balance, Some(20000000)); + assert_eq!(stellar_policy.max_fee, Some(100000)); + assert_eq!(stellar_policy.timeout_seconds, Some(30)); + } else { + panic!("Expected Stellar policy"); + } + } + + #[test] + fn test_conversion_from_domain_to_repo_model_evm() { + let domain_relayer = Relayer { + id: "test_evm_relayer".to_string(), + name: "Test EVM Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Evm, + policies: Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + gas_price_cap: Some(100_000_000_000), + eip1559_pricing: Some(true), + min_balance: None, + gas_limit_estimation: None, + whitelist_receivers: None, + private_transactions: None, + })), + signer_id: "test_signer".to_string(), + notification_id: Some("notification_123".to_string()), + custom_rpc_urls: None, + }; + + let repo_model = RelayerRepoModel::from(domain_relayer.clone()); + + assert_eq!(repo_model.id, domain_relayer.id); + assert_eq!(repo_model.name, domain_relayer.name); + assert_eq!(repo_model.network, domain_relayer.network); + assert_eq!(repo_model.paused, domain_relayer.paused); + assert_eq!(repo_model.network_type, domain_relayer.network_type); + assert_eq!(repo_model.signer_id, domain_relayer.signer_id); + assert_eq!(repo_model.notification_id, domain_relayer.notification_id); + assert_eq!(repo_model.custom_rpc_urls, domain_relayer.custom_rpc_urls); + + // Runtime fields should have default values + assert_eq!(repo_model.address, ""); + assert!(!repo_model.system_disabled); + + // Policies should be converted correctly + if let RelayerNetworkPolicy::Evm(evm_policy) = repo_model.policies { + assert_eq!(evm_policy.gas_price_cap, Some(100_000_000_000)); + assert_eq!(evm_policy.eip1559_pricing, Some(true)); + } else { + panic!("Expected EVM policy"); + } + } + + #[test] + fn test_conversion_from_domain_to_repo_model_solana() { + let domain_relayer = Relayer { + id: "test_solana_relayer".to_string(), + name: "Test Solana Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Solana, + policies: Some(RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + min_balance: Some(5000000), + max_signatures: Some(8), + allowed_tokens: Some(vec![AllowedToken::new( + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), + Some(100000), + None, + )]), + allowed_programs: None, + allowed_accounts: None, + disallowed_accounts: None, + max_tx_data_size: None, + max_allowed_fee_lamports: None, + swap_config: None, + fee_margin_percentage: None, + })), + signer_id: "test_signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let repo_model = RelayerRepoModel::from(domain_relayer.clone()); + + assert_eq!(repo_model.network_type, RelayerNetworkType::Solana); + + // Policies should be converted correctly + if let RelayerNetworkPolicy::Solana(solana_policy) = repo_model.policies { + assert_eq!( + solana_policy.fee_payment_strategy, + Some(RelayerSolanaFeePaymentStrategy::User) + ); + assert_eq!(solana_policy.min_balance, Some(5000000)); + assert_eq!(solana_policy.max_signatures, Some(8)); + assert!(solana_policy.allowed_tokens.is_some()); + } else { + panic!("Expected Solana policy"); + } + } + + #[test] + fn test_conversion_from_domain_to_repo_model_stellar() { + let domain_relayer = Relayer { + id: "test_stellar_relayer".to_string(), + name: "Test Stellar Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Stellar, + policies: Some(RelayerNetworkPolicy::Stellar(RelayerStellarPolicy { + min_balance: Some(30000000), + max_fee: Some(150000), + timeout_seconds: Some(60), + })), + signer_id: "test_signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let repo_model = RelayerRepoModel::from(domain_relayer.clone()); + + assert_eq!(repo_model.network_type, RelayerNetworkType::Stellar); + + // Policies should be converted correctly + if let RelayerNetworkPolicy::Stellar(stellar_policy) = repo_model.policies { + assert_eq!(stellar_policy.min_balance, Some(30000000)); + assert_eq!(stellar_policy.max_fee, Some(150000)); + assert_eq!(stellar_policy.timeout_seconds, Some(60)); + } else { + panic!("Expected Stellar policy"); + } + } + + #[test] + fn test_conversion_from_domain_with_no_policies_evm() { + let domain_relayer = Relayer { + id: "test_evm_relayer".to_string(), + name: "Test EVM Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Evm, + policies: None, // No policies provided + signer_id: "test_signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let repo_model = RelayerRepoModel::from(domain_relayer); + + // Should create default EVM policy + if let RelayerNetworkPolicy::Evm(evm_policy) = repo_model.policies { + // Default EVM policy should have all None values + assert_eq!(evm_policy.gas_price_cap, None); + assert_eq!(evm_policy.eip1559_pricing, None); + assert_eq!(evm_policy.min_balance, None); + assert_eq!(evm_policy.gas_limit_estimation, None); + assert_eq!(evm_policy.whitelist_receivers, None); + assert_eq!(evm_policy.private_transactions, None); + } else { + panic!("Expected default EVM policy"); + } + } + + #[test] + fn test_conversion_from_domain_with_no_policies_solana() { + let domain_relayer = Relayer { + id: "test_solana_relayer".to_string(), + name: "Test Solana Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Solana, + policies: None, // No policies provided + signer_id: "test_signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let repo_model = RelayerRepoModel::from(domain_relayer); + + // Should create default Solana policy + if let RelayerNetworkPolicy::Solana(solana_policy) = repo_model.policies { + // Default Solana policy should have all None values + assert_eq!(solana_policy.fee_payment_strategy, None); + assert_eq!(solana_policy.min_balance, None); + assert_eq!(solana_policy.max_signatures, None); + assert_eq!(solana_policy.allowed_tokens, None); + assert_eq!(solana_policy.allowed_programs, None); + assert_eq!(solana_policy.allowed_accounts, None); + assert_eq!(solana_policy.disallowed_accounts, None); + assert_eq!(solana_policy.max_tx_data_size, None); + assert_eq!(solana_policy.max_allowed_fee_lamports, None); + assert_eq!(solana_policy.swap_config, None); + assert_eq!(solana_policy.fee_margin_percentage, None); + } else { + panic!("Expected default Solana policy"); + } + } + + #[test] + fn test_conversion_from_domain_with_no_policies_stellar() { + let domain_relayer = Relayer { + id: "test_stellar_relayer".to_string(), + name: "Test Stellar Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Stellar, + policies: None, // No policies provided + signer_id: "test_signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let repo_model = RelayerRepoModel::from(domain_relayer); + + // Should create default Stellar policy + if let RelayerNetworkPolicy::Stellar(stellar_policy) = repo_model.policies { + // Default Stellar policy should have all None values + assert_eq!(stellar_policy.min_balance, None); + assert_eq!(stellar_policy.max_fee, None); + assert_eq!(stellar_policy.timeout_seconds, None); + } else { + panic!("Expected default Stellar policy"); + } + } + #[test] fn test_relayer_repo_updater_preserves_runtime_fields() { // Create an original relayer with runtime fields set @@ -221,4 +606,326 @@ mod tests { ); assert!(updated.system_disabled); } + + #[test] + fn test_relayer_repo_updater_preserves_runtime_fields_solana() { + // Create an original Solana relayer with runtime fields set + let original = RelayerRepoModel { + id: "test_solana_relayer".to_string(), + name: "Original Solana Name".to_string(), + address: "SolanaOriginalAddress123".to_string(), // Runtime field + system_disabled: true, // Runtime field + paused: false, + network: "mainnet".to_string(), + network_type: NetworkType::Solana, + signer_id: "test_signer".to_string(), + policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy::default()), + notification_id: None, + custom_rpc_urls: None, + }; + + // Create a domain model with different business fields + let domain_update = Relayer { + id: "test_solana_relayer".to_string(), + name: "Updated Solana Name".to_string(), // Changed + paused: true, // Changed + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Solana, + signer_id: "test_signer".to_string(), + policies: Some(RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { + min_balance: Some(2000000), // Changed + ..RelayerSolanaPolicy::default() + })), + notification_id: Some("solana_notification".to_string()), // Changed + custom_rpc_urls: None, + }; + + // Use updater to preserve runtime fields + let updated = + RelayerRepoUpdater::from_existing(original.clone()).apply_domain_update(domain_update); + + // Verify business fields were updated + assert_eq!(updated.name, "Updated Solana Name"); + assert!(updated.paused); + assert_eq!( + updated.notification_id, + Some("solana_notification".to_string()) + ); + + // Verify runtime fields were preserved + assert_eq!(updated.address, "SolanaOriginalAddress123"); + assert!(updated.system_disabled); + + // Verify policies were updated + if let RelayerNetworkPolicy::Solana(solana_policy) = updated.policies { + assert_eq!(solana_policy.min_balance, Some(2000000)); + } else { + panic!("Expected Solana policy"); + } + } + + #[test] + fn test_relayer_repo_updater_preserves_runtime_fields_stellar() { + // Create an original Stellar relayer with runtime fields set + let original = RelayerRepoModel { + id: "test_stellar_relayer".to_string(), + name: "Original Stellar Name".to_string(), + address: "GORIGINALXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(), // Runtime field + system_disabled: false, // Runtime field + paused: true, + network: "mainnet".to_string(), + network_type: NetworkType::Stellar, + signer_id: "test_signer".to_string(), + policies: RelayerNetworkPolicy::Stellar(RelayerStellarPolicy::default()), + notification_id: Some("original_notification".to_string()), + custom_rpc_urls: None, + }; + + // Create a domain model with different business fields + let domain_update = Relayer { + id: "test_stellar_relayer".to_string(), + name: "Updated Stellar Name".to_string(), // Changed + paused: false, // Changed + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Stellar, + signer_id: "test_signer".to_string(), + policies: Some(RelayerNetworkPolicy::Stellar(RelayerStellarPolicy { + min_balance: Some(40000000), // Changed + max_fee: Some(200000), // Changed + timeout_seconds: Some(120), // Changed + })), + notification_id: None, // Changed + custom_rpc_urls: None, + }; + + // Use updater to preserve runtime fields + let updated = + RelayerRepoUpdater::from_existing(original.clone()).apply_domain_update(domain_update); + + // Verify business fields were updated + assert_eq!(updated.name, "Updated Stellar Name"); + assert!(!updated.paused); + assert_eq!(updated.notification_id, None); + + // Verify runtime fields were preserved + assert_eq!( + updated.address, + "GORIGINALXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + ); + assert!(!updated.system_disabled); + + // Verify policies were updated + if let RelayerNetworkPolicy::Stellar(stellar_policy) = updated.policies { + assert_eq!(stellar_policy.min_balance, Some(40000000)); + assert_eq!(stellar_policy.max_fee, Some(200000)); + assert_eq!(stellar_policy.timeout_seconds, Some(120)); + } else { + panic!("Expected Stellar policy"); + } + } + + #[test] + fn test_repo_model_serialization_deserialization_evm() { + let original = create_test_relayer(false, false); + + // Serialize to JSON + let serialized = serde_json::to_string(&original).unwrap(); + assert!(!serialized.is_empty()); + + // Deserialize back + let deserialized: RelayerRepoModel = serde_json::from_str(&serialized).unwrap(); + + // Verify all fields match + assert_eq!(original.id, deserialized.id); + assert_eq!(original.name, deserialized.name); + assert_eq!(original.network, deserialized.network); + assert_eq!(original.paused, deserialized.paused); + assert_eq!(original.network_type, deserialized.network_type); + assert_eq!(original.signer_id, deserialized.signer_id); + assert_eq!(original.address, deserialized.address); + assert_eq!(original.notification_id, deserialized.notification_id); + assert_eq!(original.system_disabled, deserialized.system_disabled); + assert_eq!(original.custom_rpc_urls, deserialized.custom_rpc_urls); + + // Verify policies match + match (&original.policies, &deserialized.policies) { + (RelayerNetworkPolicy::Evm(_), RelayerNetworkPolicy::Evm(_)) => { + // Success - both are EVM policies + } + _ => panic!("Policy types don't match after serialization/deserialization"), + } + } + + #[test] + fn test_repo_model_serialization_deserialization_solana() { + let original = create_test_relayer_solana(true, false); + + // Serialize to JSON + let serialized = serde_json::to_string(&original).unwrap(); + assert!(!serialized.is_empty()); + + // Deserialize back + let deserialized: RelayerRepoModel = serde_json::from_str(&serialized).unwrap(); + + // Verify key fields match + assert_eq!(original.id, deserialized.id); + assert_eq!(original.network_type, RelayerNetworkType::Solana); + assert_eq!(deserialized.network_type, RelayerNetworkType::Solana); + assert_eq!(original.paused, deserialized.paused); + + // Verify policies match + match (&original.policies, &deserialized.policies) { + (RelayerNetworkPolicy::Solana(orig), RelayerNetworkPolicy::Solana(deser)) => { + assert_eq!(orig.fee_payment_strategy, deser.fee_payment_strategy); + assert_eq!(orig.min_balance, deser.min_balance); + assert_eq!(orig.max_signatures, deser.max_signatures); + } + _ => panic!("Policy types don't match after serialization/deserialization"), + } + } + + #[test] + fn test_repo_model_serialization_deserialization_stellar() { + let original = create_test_relayer_stellar(false, true); + + // Serialize to JSON + let serialized = serde_json::to_string(&original).unwrap(); + assert!(!serialized.is_empty()); + + // Deserialize back + let deserialized: RelayerRepoModel = serde_json::from_str(&serialized).unwrap(); + + // Verify key fields match + assert_eq!(original.id, deserialized.id); + assert_eq!(original.network_type, RelayerNetworkType::Stellar); + assert_eq!(deserialized.network_type, RelayerNetworkType::Stellar); + assert_eq!(original.system_disabled, deserialized.system_disabled); + + // Verify policies match + match (&original.policies, &deserialized.policies) { + (RelayerNetworkPolicy::Stellar(orig), RelayerNetworkPolicy::Stellar(deser)) => { + assert_eq!(orig.min_balance, deser.min_balance); + assert_eq!(orig.max_fee, deser.max_fee); + assert_eq!(orig.timeout_seconds, deser.timeout_seconds); + } + _ => panic!("Policy types don't match after serialization/deserialization"), + } + } + + #[test] + fn test_repo_model_default() { + let default_model = RelayerRepoModel::default(); + + assert_eq!(default_model.id, ""); + assert_eq!(default_model.name, ""); + assert_eq!(default_model.network, ""); + assert!(!default_model.paused); + assert_eq!(default_model.network_type, NetworkType::Evm); + assert_eq!(default_model.signer_id, ""); + assert_eq!(default_model.address, "0x"); + assert_eq!(default_model.notification_id, None); + assert!(!default_model.system_disabled); + assert_eq!(default_model.custom_rpc_urls, None); + + // Default should have EVM policy + if let RelayerNetworkPolicy::Evm(_) = default_model.policies { + // Success + } else { + panic!("Default should have EVM policy"); + } + } + + #[test] + fn test_round_trip_conversion_all_network_types() { + // Test round-trip conversion: Domain -> Repo -> Domain for all network types + + // EVM + let original_evm = Relayer { + id: "evm_relayer".to_string(), + name: "EVM Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Evm, + policies: Some(RelayerNetworkPolicy::Evm(RelayerEvmPolicy { + gas_price_cap: Some(50_000_000_000), + eip1559_pricing: Some(true), + min_balance: None, + gas_limit_estimation: None, + whitelist_receivers: None, + private_transactions: None, + })), + signer_id: "evm_signer".to_string(), + notification_id: Some("evm_notification".to_string()), + custom_rpc_urls: None, + }; + + let repo_evm = RelayerRepoModel::from(original_evm.clone()); + let recovered_evm = Relayer::from(repo_evm); + + assert_eq!(original_evm.id, recovered_evm.id); + assert_eq!(original_evm.network_type, recovered_evm.network_type); + assert_eq!(original_evm.notification_id, recovered_evm.notification_id); + + // Solana + let original_solana = Relayer { + id: "solana_relayer".to_string(), + name: "Solana Relayer".to_string(), + network: "mainnet".to_string(), + paused: true, + network_type: RelayerNetworkType::Solana, + policies: Some(RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + min_balance: Some(3000000), + max_signatures: None, + allowed_tokens: None, + allowed_programs: None, + allowed_accounts: None, + disallowed_accounts: None, + max_tx_data_size: None, + max_allowed_fee_lamports: None, + swap_config: None, + fee_margin_percentage: None, + })), + signer_id: "solana_signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + let repo_solana = RelayerRepoModel::from(original_solana.clone()); + let recovered_solana = Relayer::from(repo_solana); + + assert_eq!(original_solana.id, recovered_solana.id); + assert_eq!(original_solana.network_type, recovered_solana.network_type); + assert_eq!(original_solana.paused, recovered_solana.paused); + + // Stellar + let original_stellar = Relayer { + id: "stellar_relayer".to_string(), + name: "Stellar Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Stellar, + policies: Some(RelayerNetworkPolicy::Stellar(RelayerStellarPolicy { + min_balance: Some(50000000), + max_fee: Some(250000), + timeout_seconds: Some(180), + })), + signer_id: "stellar_signer".to_string(), + notification_id: Some("stellar_notification".to_string()), + custom_rpc_urls: None, + }; + + let repo_stellar = RelayerRepoModel::from(original_stellar.clone()); + let recovered_stellar = Relayer::from(repo_stellar); + + assert_eq!(original_stellar.id, recovered_stellar.id); + assert_eq!( + original_stellar.network_type, + recovered_stellar.network_type + ); + assert_eq!( + original_stellar.notification_id, + recovered_stellar.notification_id + ); + } } diff --git a/src/models/relayer/request.rs b/src/models/relayer/request.rs index 8ed3dc1de..db57faa2e 100644 --- a/src/models/relayer/request.rs +++ b/src/models/relayer/request.rs @@ -251,7 +251,10 @@ impl TryFrom for Relayer { #[cfg(test)] mod tests { use super::*; - use crate::models::relayer::{RelayerEvmPolicy, RelayerSolanaPolicy}; + use crate::models::relayer::{ + RelayerEvmPolicy, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, + RelayerStellarPolicy, + }; #[test] fn test_valid_create_request() { @@ -279,6 +282,85 @@ mod tests { assert!(domain_relayer.is_ok()); } + #[test] + fn test_valid_create_request_stellar() { + let request = CreateRelayerRequest { + id: Some("test-stellar-relayer".to_string()), + name: "Test Stellar Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Stellar, + policies: Some(CreateRelayerPolicyRequest::Stellar(RelayerStellarPolicy { + min_balance: Some(20000000), + max_fee: Some(100000), + timeout_seconds: Some(30), + })), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + // Convert to domain model and validate there + let domain_relayer = Relayer::try_from(request); + assert!(domain_relayer.is_ok()); + + // Verify the domain model has correct values + let relayer = domain_relayer.unwrap(); + assert_eq!(relayer.network_type, RelayerNetworkType::Stellar); + if let Some(RelayerNetworkPolicy::Stellar(stellar_policy)) = relayer.policies { + assert_eq!(stellar_policy.min_balance, Some(20000000)); + assert_eq!(stellar_policy.max_fee, Some(100000)); + assert_eq!(stellar_policy.timeout_seconds, Some(30)); + } else { + panic!("Expected Stellar policy"); + } + } + + #[test] + fn test_valid_create_request_solana() { + let request = CreateRelayerRequest { + id: Some("test-solana-relayer".to_string()), + name: "Test Solana Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Solana, + policies: Some(CreateRelayerPolicyRequest::Solana(RelayerSolanaPolicy { + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + min_balance: Some(1000000), + max_signatures: Some(5), + allowed_tokens: None, + allowed_programs: None, + allowed_accounts: None, + disallowed_accounts: None, + max_tx_data_size: None, + max_allowed_fee_lamports: None, + swap_config: None, + fee_margin_percentage: None, + })), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + // Convert to domain model and validate there + let domain_relayer = Relayer::try_from(request); + assert!(domain_relayer.is_ok()); + + // Verify the domain model has correct values + let relayer = domain_relayer.unwrap(); + assert_eq!(relayer.network_type, RelayerNetworkType::Solana); + if let Some(RelayerNetworkPolicy::Solana(solana_policy)) = relayer.policies { + assert_eq!(solana_policy.min_balance, Some(1000000)); + assert_eq!(solana_policy.max_signatures, Some(5)); + assert_eq!( + solana_policy.fee_payment_strategy, + Some(RelayerSolanaFeePaymentStrategy::Relayer) + ); + } else { + panic!("Expected Solana policy"); + } + } + #[test] fn test_invalid_create_request_empty_id() { let request = CreateRelayerRequest { @@ -346,6 +428,46 @@ mod tests { assert!(domain_relayer.is_ok()); } + #[test] + fn test_create_request_stellar_policy_conversion() { + // Test that Stellar policies are correctly converted from request type to domain type + let request = CreateRelayerRequest { + id: Some("test-stellar-relayer".to_string()), + name: "Test Stellar Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Stellar, + policies: Some(CreateRelayerPolicyRequest::Stellar(RelayerStellarPolicy { + min_balance: Some(50000000), + max_fee: Some(150000), + timeout_seconds: Some(60), + })), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + // Test policy conversion + if let Some(policy_request) = &request.policies { + let policy = policy_request + .to_domain_policy(request.network_type) + .unwrap(); + if let RelayerNetworkPolicy::Stellar(stellar_policy) = policy { + assert_eq!(stellar_policy.min_balance, Some(50000000)); + assert_eq!(stellar_policy.max_fee, Some(150000)); + assert_eq!(stellar_policy.timeout_seconds, Some(60)); + } else { + panic!("Expected Stellar policy"); + } + } else { + panic!("Expected policies to be present"); + } + + // Test full conversion to domain relayer + let domain_relayer = Relayer::try_from(request); + assert!(domain_relayer.is_ok()); + } + #[test] fn test_create_request_wrong_policy_type() { // Test that providing wrong policy type for network type fails @@ -377,6 +499,36 @@ mod tests { } } + #[test] + fn test_create_request_stellar_wrong_policy_type() { + // Test that providing Stellar policy for EVM network type fails + let request = CreateRelayerRequest { + id: Some("test-relayer".to_string()), + name: "Test Relayer".to_string(), + network: "mainnet".to_string(), + paused: false, + network_type: RelayerNetworkType::Evm, // EVM network type + policies: Some(CreateRelayerPolicyRequest::Stellar( + RelayerStellarPolicy::default(), + )), // But Stellar policy + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + }; + + // Should fail during policy conversion + if let Some(policy_request) = &request.policies { + let result = policy_request.to_domain_policy(request.network_type); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Policy type does not match relayer network type")); + } else { + panic!("Expected policies to be present"); + } + } + #[test] fn test_create_request_json_deserialization() { // Test that JSON without network_type in policies deserializes correctly @@ -409,6 +561,79 @@ mod tests { } } + #[test] + fn test_create_request_stellar_json_deserialization() { + // Test that Stellar JSON deserializes correctly + let json_input = r#"{ + "name": "Test Stellar Relayer", + "network": "mainnet", + "paused": false, + "network_type": "stellar", + "signer_id": "test-signer", + "policies": { + "min_balance": 25000000, + "max_fee": 200000, + "timeout_seconds": 45 + } + }"#; + + let request: CreateRelayerRequest = serde_json::from_str(json_input).unwrap(); + assert_eq!(request.network_type, RelayerNetworkType::Stellar); + assert!(request.policies.is_some()); + + // Test that it converts to domain model correctly + let domain_relayer = Relayer::try_from(request).unwrap(); + assert_eq!(domain_relayer.network_type, RelayerNetworkType::Stellar); + + if let Some(RelayerNetworkPolicy::Stellar(stellar_policy)) = domain_relayer.policies { + assert_eq!(stellar_policy.min_balance, Some(25000000)); + assert_eq!(stellar_policy.max_fee, Some(200000)); + assert_eq!(stellar_policy.timeout_seconds, Some(45)); + } else { + panic!("Expected Stellar policy"); + } + } + + #[test] + fn test_create_request_solana_json_deserialization() { + // Test that Solana JSON deserializes correctly with complex policy + let json_input = r#"{ + "name": "Test Solana Relayer", + "network": "mainnet", + "paused": false, + "network_type": "solana", + "signer_id": "test-signer", + "policies": { + "fee_payment_strategy": "relayer", + "min_balance": 5000000, + "max_signatures": 8, + "max_tx_data_size": 1024, + "fee_margin_percentage": 2.5 + } + }"#; + + let request: CreateRelayerRequest = serde_json::from_str(json_input).unwrap(); + assert_eq!(request.network_type, RelayerNetworkType::Solana); + assert!(request.policies.is_some()); + + // Test that it converts to domain model correctly + let domain_relayer = Relayer::try_from(request).unwrap(); + assert_eq!(domain_relayer.network_type, RelayerNetworkType::Solana); + + if let Some(RelayerNetworkPolicy::Solana(solana_policy)) = domain_relayer.policies { + assert_eq!(solana_policy.min_balance, Some(5000000)); + assert_eq!(solana_policy.max_signatures, Some(8)); + assert_eq!(solana_policy.max_tx_data_size, Some(1024)); + assert_eq!(solana_policy.fee_margin_percentage, Some(2.5)); + assert_eq!( + solana_policy.fee_payment_strategy, + Some(RelayerSolanaFeePaymentStrategy::Relayer) + ); + } else { + panic!("Expected Solana policy"); + } + } + #[test] fn test_valid_update_request() { let request = UpdateRelayerRequestRaw { @@ -494,6 +719,35 @@ mod tests { } } + #[test] + fn test_update_request_policy_deserialization_stellar() { + // Test Stellar policy deserialization without network_type in user input + let json_input = r#"{ + "policies": { + "max_fee": 75000, + "timeout_seconds": 120, + "min_balance": 15000000 + } + }"#; + + let request: UpdateRelayerRequestRaw = serde_json::from_str(json_input).unwrap(); + + // Validation happens during domain conversion based on network type + // Test with the utility function for Stellar + if let Some(policies_json) = &request.policies { + let network_policy = + deserialize_policy_for_network_type(policies_json, RelayerNetworkType::Stellar) + .unwrap(); + if let RelayerNetworkPolicy::Stellar(stellar_policy) = network_policy { + assert_eq!(stellar_policy.max_fee, Some(75000)); + assert_eq!(stellar_policy.timeout_seconds, Some(120)); + assert_eq!(stellar_policy.min_balance, Some(15000000)); + } else { + panic!("Expected Stellar policy"); + } + } + } + #[test] fn test_update_request_invalid_policy_format() { // Test that invalid policy format fails during validation with utility function @@ -545,6 +799,35 @@ mod tests { assert!(request.policies.is_some()); } + #[test] + fn test_update_request_stellar_policy_partial() { + // Test Stellar policy with only some fields (partial update) + let json_input = r#"{ + "policies": { + "max_fee": 50000 + } + }"#; + + let request: UpdateRelayerRequestRaw = serde_json::from_str(json_input).unwrap(); + + // Should correctly deserialize as raw JSON + assert!(request.policies.is_some()); + + // Test domain conversion with utility function + if let Some(policies_json) = &request.policies { + let network_policy = + deserialize_policy_for_network_type(policies_json, RelayerNetworkType::Stellar) + .unwrap(); + if let RelayerNetworkPolicy::Stellar(stellar_policy) = network_policy { + assert_eq!(stellar_policy.max_fee, Some(50000)); + assert_eq!(stellar_policy.timeout_seconds, None); + assert_eq!(stellar_policy.min_balance, None); + } else { + panic!("Expected Stellar policy"); + } + } + } + #[test] fn test_notification_id_deserialization() { // Test valid notification_id deserialization @@ -614,6 +897,50 @@ mod tests { } } + #[test] + fn test_comprehensive_update_request_stellar() { + // Test a comprehensive Stellar update request + let json_input = r#"{ + "name": "Updated Stellar Relayer", + "paused": false, + "notification_id": "stellar-notification", + "policies": { + "min_balance": 30000000, + "max_fee": 250000, + "timeout_seconds": 90 + }, + "custom_rpc_urls": [ + {"url": "https://stellar-node.example.com", "weight": 100} + ] + }"#; + + let request: UpdateRelayerRequestRaw = serde_json::from_str(json_input).unwrap(); + + // Verify all fields are correctly deserialized + assert_eq!(request.name, Some("Updated Stellar Relayer".to_string())); + assert_eq!(request.paused, Some(false)); + assert_eq!( + request.notification_id, + Some("stellar-notification".to_string()) + ); + assert!(request.policies.is_some()); + assert!(request.custom_rpc_urls.is_some()); + + // Test domain conversion + if let Some(policies_json) = &request.policies { + let network_policy = + deserialize_policy_for_network_type(policies_json, RelayerNetworkType::Stellar) + .unwrap(); + if let RelayerNetworkPolicy::Stellar(stellar_policy) = network_policy { + assert_eq!(stellar_policy.min_balance, Some(30000000)); + assert_eq!(stellar_policy.max_fee, Some(250000)); + assert_eq!(stellar_policy.timeout_seconds, Some(90)); + } else { + panic!("Expected Stellar policy"); + } + } + } + #[test] fn test_create_request_network_type_based_policy_deserialization() { // Test that policies are correctly deserialized based on network_type @@ -666,6 +993,32 @@ mod tests { panic!("Expected Solana policy"); } + // Stellar network with Stellar policy fields + let stellar_json = r#"{ + "name": "Stellar Relayer", + "network": "mainnet", + "paused": false, + "network_type": "stellar", + "signer_id": "test-signer", + "policies": { + "min_balance": 40000000, + "max_fee": 300000, + "timeout_seconds": 180 + } + }"#; + + let stellar_request: CreateRelayerRequest = serde_json::from_str(stellar_json).unwrap(); + assert_eq!(stellar_request.network_type, RelayerNetworkType::Stellar); + + if let Some(CreateRelayerPolicyRequest::Stellar(stellar_policy)) = stellar_request.policies + { + assert_eq!(stellar_policy.min_balance, Some(40000000)); + assert_eq!(stellar_policy.max_fee, Some(300000)); + assert_eq!(stellar_policy.timeout_seconds, Some(180)); + } else { + panic!("Expected Stellar policy"); + } + // Test that wrong policy fields for network type fails let invalid_json = r#"{ "name": "Invalid Relayer", @@ -682,4 +1035,122 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("unknown field")); } + + #[test] + fn test_create_request_invalid_stellar_policy_fields() { + // Test that invalid Stellar policy fields fail during deserialization + let invalid_json = r#"{ + "name": "Invalid Stellar Relayer", + "network": "mainnet", + "paused": false, + "network_type": "stellar", + "signer_id": "test-signer", + "policies": { + "gas_price_cap": 100000000000 + } + }"#; + + let result = serde_json::from_str::(invalid_json); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("unknown field")); + } + + #[test] + fn test_create_request_empty_policies() { + // Test create request with empty policies for each network type + let evm_json = r#"{ + "name": "EVM Relayer No Policies", + "network": "mainnet", + "paused": false, + "network_type": "evm", + "signer_id": "test-signer" + }"#; + + let evm_request: CreateRelayerRequest = serde_json::from_str(evm_json).unwrap(); + assert_eq!(evm_request.network_type, RelayerNetworkType::Evm); + assert!(evm_request.policies.is_none()); + + let stellar_json = r#"{ + "name": "Stellar Relayer No Policies", + "network": "mainnet", + "paused": false, + "network_type": "stellar", + "signer_id": "test-signer" + }"#; + + let stellar_request: CreateRelayerRequest = serde_json::from_str(stellar_json).unwrap(); + assert_eq!(stellar_request.network_type, RelayerNetworkType::Stellar); + assert!(stellar_request.policies.is_none()); + + let solana_json = r#"{ + "name": "Solana Relayer No Policies", + "network": "mainnet", + "paused": false, + "network_type": "solana", + "signer_id": "test-signer" + }"#; + + let solana_request: CreateRelayerRequest = serde_json::from_str(solana_json).unwrap(); + assert_eq!(solana_request.network_type, RelayerNetworkType::Solana); + assert!(solana_request.policies.is_none()); + } + + #[test] + fn test_deserialize_policy_utility_function_all_networks() { + // Test the utility function with all network types + + // EVM policy + let evm_json = serde_json::json!({ + "gas_price_cap": "75000000000", + "private_transactions": false, + "min_balance": "2000000000000000000" + }); + + let evm_policy = + deserialize_policy_for_network_type(&evm_json, RelayerNetworkType::Evm).unwrap(); + if let RelayerNetworkPolicy::Evm(policy) = evm_policy { + assert_eq!(policy.gas_price_cap, Some(75000000000)); + assert_eq!(policy.private_transactions, Some(false)); + assert_eq!(policy.min_balance, Some(2000000000000000000)); + } else { + panic!("Expected EVM policy"); + } + + // Solana policy + let solana_json = serde_json::json!({ + "fee_payment_strategy": "user", + "max_tx_data_size": 512, + "fee_margin_percentage": 1.5 + }); + + let solana_policy = + deserialize_policy_for_network_type(&solana_json, RelayerNetworkType::Solana).unwrap(); + if let RelayerNetworkPolicy::Solana(policy) = solana_policy { + assert_eq!( + policy.fee_payment_strategy, + Some(RelayerSolanaFeePaymentStrategy::User) + ); + assert_eq!(policy.max_tx_data_size, Some(512)); + assert_eq!(policy.fee_margin_percentage, Some(1.5)); + } else { + panic!("Expected Solana policy"); + } + + // Stellar policy + let stellar_json = serde_json::json!({ + "max_fee": 125000, + "timeout_seconds": 240 + }); + + let stellar_policy = + deserialize_policy_for_network_type(&stellar_json, RelayerNetworkType::Stellar) + .unwrap(); + if let RelayerNetworkPolicy::Stellar(policy) = stellar_policy { + assert_eq!(policy.max_fee, Some(125000)); + assert_eq!(policy.timeout_seconds, Some(240)); + assert_eq!(policy.min_balance, None); + } else { + panic!("Expected Stellar policy"); + } + } } diff --git a/src/models/relayer/response.rs b/src/models/relayer/response.rs index 93da662ce..1f4e162a0 100644 --- a/src/models/relayer/response.rs +++ b/src/models/relayer/response.rs @@ -32,9 +32,13 @@ pub struct DeletePendingTransactionsResponse { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] #[serde(untagged)] pub enum RelayerNetworkPolicyResponse { + // Order matters for untagged enums - put most distinctive variants first + // EVM has unique fields (gas_price_cap, whitelist_receivers, eip1559_pricing) so it should be tried first Evm(EvmPolicyResponse), - Solana(SolanaPolicyResponse), + // Stellar has unique fields (max_fee, timeout_seconds) so it should be tried next Stellar(StellarPolicyResponse), + // Solana has many fields but some overlap with others, so it should be tried last + Solana(SolanaPolicyResponse), } impl From for RelayerNetworkPolicyResponse { @@ -180,10 +184,139 @@ fn is_empty_policy(policy: &RelayerNetworkPolicy) -> bool { } } +/// Network policy response models for OpenAPI documentation +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +pub struct NetworkPolicyResponse { + #[serde(flatten)] + pub policy: RelayerNetworkPolicy, +} + +/// EVM policy response model for OpenAPI documentation +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct EvmPolicyResponse { + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub min_balance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub gas_limit_estimation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub gas_price_cap: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub whitelist_receivers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub eip1559_pricing: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub private_transactions: Option, +} + +/// Solana policy response model for OpenAPI documentation +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct SolanaPolicyResponse { + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub allowed_programs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub max_signatures: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub max_tx_data_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub min_balance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub allowed_tokens: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub fee_payment_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub fee_margin_percentage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub allowed_accounts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub disallowed_accounts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub max_allowed_fee_lamports: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub swap_config: Option, +} + +/// Stellar policy response model for OpenAPI documentation +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct StellarPolicyResponse { + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub max_fee: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub timeout_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub min_balance: Option, +} + +impl From for EvmPolicyResponse { + fn from(policy: RelayerEvmPolicy) -> Self { + Self { + min_balance: policy.min_balance, + gas_limit_estimation: policy.gas_limit_estimation, + gas_price_cap: policy.gas_price_cap, + whitelist_receivers: policy.whitelist_receivers, + eip1559_pricing: policy.eip1559_pricing, + private_transactions: policy.private_transactions, + } + } +} + +impl From for SolanaPolicyResponse { + fn from(policy: RelayerSolanaPolicy) -> Self { + Self { + allowed_programs: policy.allowed_programs, + max_signatures: policy.max_signatures, + max_tx_data_size: policy.max_tx_data_size, + min_balance: policy.min_balance, + allowed_tokens: policy.allowed_tokens, + fee_payment_strategy: policy.fee_payment_strategy, + fee_margin_percentage: policy.fee_margin_percentage, + allowed_accounts: policy.allowed_accounts, + disallowed_accounts: policy.disallowed_accounts, + max_allowed_fee_lamports: policy.max_allowed_fee_lamports, + swap_config: policy.swap_config, + } + } +} + +impl From for StellarPolicyResponse { + fn from(policy: RelayerStellarPolicy) -> Self { + Self { + min_balance: policy.min_balance, + max_fee: policy.max_fee, + timeout_seconds: policy.timeout_seconds, + } + } +} + #[cfg(test)] mod tests { use super::*; - use crate::models::relayer::RelayerEvmPolicy; + use crate::models::relayer::{ + AllowedToken, RelayerEvmPolicy, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, + RelayerSolanaSwapPolicy, RelayerSolanaSwapStrategy, RelayerStellarPolicy, + }; #[test] fn test_from_domain_relayer() { @@ -234,6 +367,91 @@ mod tests { assert_eq!(response.system_disabled, None); } + #[test] + fn test_from_domain_relayer_solana() { + let relayer = Relayer::new( + "test-solana-relayer".to_string(), + "Test Solana Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Solana, + Some(RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { + allowed_programs: Some(vec!["11111111111111111111111111111111".to_string()]), + max_signatures: Some(5), + min_balance: Some(1000000), + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + allowed_tokens: Some(vec![AllowedToken::new( + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), + Some(100000), + None, + )]), + max_tx_data_size: None, + fee_margin_percentage: None, + allowed_accounts: None, + disallowed_accounts: None, + max_allowed_fee_lamports: None, + swap_config: None, + })), + "test-signer".to_string(), + None, + None, + ); + + let response: RelayerResponse = relayer.clone().into(); + + assert_eq!(response.id, relayer.id); + assert_eq!(response.network_type, RelayerNetworkType::Solana); + assert!(response.policies.is_some()); + + if let Some(RelayerNetworkPolicyResponse::Solana(solana_response)) = response.policies { + assert_eq!( + solana_response.allowed_programs, + Some(vec!["11111111111111111111111111111111".to_string()]) + ); + assert_eq!(solana_response.max_signatures, Some(5)); + assert_eq!(solana_response.min_balance, Some(1000000)); + assert_eq!( + solana_response.fee_payment_strategy, + Some(RelayerSolanaFeePaymentStrategy::Relayer) + ); + } else { + panic!("Expected Solana policy response"); + } + } + + #[test] + fn test_from_domain_relayer_stellar() { + let relayer = Relayer::new( + "test-stellar-relayer".to_string(), + "Test Stellar Relayer".to_string(), + "mainnet".to_string(), + false, + RelayerNetworkType::Stellar, + Some(RelayerNetworkPolicy::Stellar(RelayerStellarPolicy { + min_balance: Some(20000000), + max_fee: Some(100000), + timeout_seconds: Some(30), + })), + "test-signer".to_string(), + None, + None, + ); + + let response: RelayerResponse = relayer.clone().into(); + + assert_eq!(response.id, relayer.id); + assert_eq!(response.network_type, RelayerNetworkType::Stellar); + assert!(response.policies.is_some()); + + if let Some(RelayerNetworkPolicyResponse::Stellar(stellar_response)) = response.policies { + assert_eq!(stellar_response.min_balance, Some(20000000)); + assert_eq!(stellar_response.max_fee, Some(100000)); + assert_eq!(stellar_response.timeout_seconds, Some(30)); + } else { + panic!("Expected Stellar policy response"); + } + } + #[test] fn test_response_serialization() { let response = RelayerResponse { @@ -267,6 +485,94 @@ mod tests { assert_eq!(response.name, deserialized.name); } + #[test] + fn test_solana_response_serialization() { + let response = RelayerResponse { + id: "test-solana-relayer".to_string(), + name: "Test Solana Relayer".to_string(), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Solana, + paused: false, + policies: Some(RelayerNetworkPolicyResponse::Solana(SolanaPolicyResponse { + allowed_programs: Some(vec!["11111111111111111111111111111111".to_string()]), + max_signatures: Some(5), + max_tx_data_size: Some(1024), + min_balance: Some(1000000), + allowed_tokens: Some(vec![AllowedToken::new( + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), + Some(100000), + None, + )]), + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + fee_margin_percentage: Some(5.0), + allowed_accounts: None, + disallowed_accounts: None, + max_allowed_fee_lamports: Some(500000), + swap_config: Some(RelayerSolanaSwapPolicy { + strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + cron_schedule: Some("0 0 * * *".to_string()), + min_balance_threshold: Some(500000), + jupiter_swap_options: None, + }), + })), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: Some("SolanaAddress123...".to_string()), + system_disabled: Some(false), + }; + + // Should serialize without errors + let serialized = serde_json::to_string(&response).unwrap(); + assert!(!serialized.is_empty()); + + // Should deserialize back to the same struct + let deserialized: RelayerResponse = serde_json::from_str(&serialized).unwrap(); + assert_eq!(response.id, deserialized.id); + assert_eq!(response.network_type, RelayerNetworkType::Solana); + } + + #[test] + fn test_stellar_response_serialization() { + let response = RelayerResponse { + id: "test-stellar-relayer".to_string(), + name: "Test Stellar Relayer".to_string(), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Stellar, + paused: false, + policies: Some(RelayerNetworkPolicyResponse::Stellar( + StellarPolicyResponse { + min_balance: Some(20000000), + max_fee: Some(100000), + timeout_seconds: Some(30), + }, + )), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: Some("GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string()), + system_disabled: Some(false), + }; + + // Should serialize without errors + let serialized = serde_json::to_string(&response).unwrap(); + assert!(!serialized.is_empty()); + + // Should deserialize back to the same struct + let deserialized: RelayerResponse = serde_json::from_str(&serialized).unwrap(); + assert_eq!(response.id, deserialized.id); + assert_eq!(response.network_type, RelayerNetworkType::Stellar); + + // Verify Stellar-specific fields + if let Some(RelayerNetworkPolicyResponse::Stellar(stellar_policy)) = deserialized.policies { + assert_eq!(stellar_policy.min_balance, Some(20000000)); + assert_eq!(stellar_policy.max_fee, Some(100000)); + assert_eq!(stellar_policy.timeout_seconds, Some(30)); + } else { + panic!("Expected Stellar policy in deserialized response"); + } + } + #[test] fn test_response_without_redundant_network_type() { let response = RelayerResponse { @@ -301,12 +607,91 @@ mod tests { "Should only have one network_type field at top level, not in policies" ); - println!("serialized: {:?}", serialized); - assert!(serialized.contains(r#""gas_price_cap": 100000000000"#)); assert!(serialized.contains(r#""eip1559_pricing": true"#)); } + #[test] + fn test_solana_response_without_redundant_network_type() { + let response = RelayerResponse { + id: "test-solana-relayer".to_string(), + name: "Test Solana Relayer".to_string(), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Solana, + paused: false, + policies: Some(RelayerNetworkPolicyResponse::Solana(SolanaPolicyResponse { + allowed_programs: Some(vec!["11111111111111111111111111111111".to_string()]), + max_signatures: Some(5), + max_tx_data_size: None, + min_balance: Some(1000000), + allowed_tokens: None, + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + fee_margin_percentage: None, + allowed_accounts: None, + disallowed_accounts: None, + max_allowed_fee_lamports: None, + swap_config: None, + })), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: Some("SolanaAddress123...".to_string()), + system_disabled: Some(false), + }; + + let serialized = serde_json::to_string_pretty(&response).unwrap(); + + assert!(serialized.contains(r#""network_type": "solana""#)); + + // Count occurrences - should only be 1 (at top level) + let network_type_count = serialized.matches(r#""network_type""#).count(); + assert_eq!( + network_type_count, 1, + "Should only have one network_type field at top level, not in policies" + ); + + assert!(serialized.contains(r#""max_signatures": 5"#)); + assert!(serialized.contains(r#""fee_payment_strategy": "relayer""#)); + } + + #[test] + fn test_stellar_response_without_redundant_network_type() { + let response = RelayerResponse { + id: "test-stellar-relayer".to_string(), + name: "Test Stellar Relayer".to_string(), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Stellar, + paused: false, + policies: Some(RelayerNetworkPolicyResponse::Stellar( + StellarPolicyResponse { + min_balance: Some(20000000), + max_fee: Some(100000), + timeout_seconds: Some(30), + }, + )), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: Some("GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string()), + system_disabled: Some(false), + }; + + let serialized = serde_json::to_string_pretty(&response).unwrap(); + + assert!(serialized.contains(r#""network_type": "stellar""#)); + + // Count occurrences - should only be 1 (at top level) + let network_type_count = serialized.matches(r#""network_type""#).count(); + assert_eq!( + network_type_count, 1, + "Should only have one network_type field at top level, not in policies" + ); + + assert!(serialized.contains(r#""min_balance": 20000000"#)); + assert!(serialized.contains(r#""max_fee": 100000"#)); + assert!(serialized.contains(r#""timeout_seconds": 30"#)); + } + #[test] fn test_empty_policies_not_returned_in_response() { // Create a repository model with empty policies (all None - user didn't set any) @@ -338,6 +723,68 @@ mod tests { ); } + #[test] + fn test_empty_solana_policies_not_returned_in_response() { + // Create a repository model with empty Solana policies (all None - user didn't set any) + let repo_model = RelayerRepoModel { + id: "test-solana-relayer".to_string(), + name: "Test Solana Relayer".to_string(), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Solana, + paused: false, + policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy::default()), // All None values + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: "SolanaAddress123...".to_string(), + system_disabled: false, + }; + + // Convert to response + let response = RelayerResponse::from(repo_model); + + // Empty policies should not be included in response + assert_eq!(response.policies, None); + + // Verify serialization doesn't include policies field + let serialized = serde_json::to_string(&response).unwrap(); + assert!( + !serialized.contains("policies"), + "Empty Solana policies should not appear in JSON response" + ); + } + + #[test] + fn test_empty_stellar_policies_not_returned_in_response() { + // Create a repository model with empty Stellar policies (all None - user didn't set any) + let repo_model = RelayerRepoModel { + id: "test-stellar-relayer".to_string(), + name: "Test Stellar Relayer".to_string(), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Stellar, + paused: false, + policies: RelayerNetworkPolicy::Stellar(RelayerStellarPolicy::default()), // All None values + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(), + system_disabled: false, + }; + + // Convert to response + let response = RelayerResponse::from(repo_model); + + // Empty policies should not be included in response + assert_eq!(response.policies, None); + + // Verify serialization doesn't include policies field + let serialized = serde_json::to_string(&response).unwrap(); + assert!( + !serialized.contains("policies"), + "Empty Stellar policies should not appear in JSON response" + ); + } + #[test] fn test_user_provided_policies_returned_in_response() { // Create a repository model with user-provided policies @@ -379,127 +826,212 @@ mod tests { "User-provided policy values should appear in JSON response" ); } -} -/// Network policy response models for OpenAPI documentation -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -pub struct NetworkPolicyResponse { - #[serde(flatten)] - pub policy: RelayerNetworkPolicy, -} + #[test] + fn test_user_provided_solana_policies_returned_in_response() { + // Create a repository model with user-provided Solana policies + let repo_model = RelayerRepoModel { + id: "test-solana-relayer".to_string(), + name: "Test Solana Relayer".to_string(), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Solana, + paused: false, + policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { + max_signatures: Some(5), + fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + min_balance: Some(1000000), + allowed_programs: None, // Some fields can still be None + max_tx_data_size: None, + allowed_tokens: None, + fee_margin_percentage: None, + allowed_accounts: None, + disallowed_accounts: None, + max_allowed_fee_lamports: None, + swap_config: None, + }), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: "SolanaAddress123...".to_string(), + system_disabled: false, + }; -/// EVM policy response model for OpenAPI documentation -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -pub struct EvmPolicyResponse { - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub min_balance: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub gas_limit_estimation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub gas_price_cap: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub whitelist_receivers: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub eip1559_pricing: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub private_transactions: Option, -} + // Convert to response + let response = RelayerResponse::from(repo_model); -/// Solana policy response model for OpenAPI documentation -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -pub struct SolanaPolicyResponse { - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub allowed_programs: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub max_signatures: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub max_tx_data_size: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub min_balance: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub allowed_tokens: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub fee_payment_strategy: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub fee_margin_percentage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub allowed_accounts: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub disallowed_accounts: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub max_allowed_fee_lamports: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub swap_config: Option, -} + // User-provided policies should be included in response + assert!(response.policies.is_some()); -/// Stellar policy response model for OpenAPI documentation -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] -pub struct StellarPolicyResponse { - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub max_fee: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub timeout_seconds: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub min_balance: Option, -} + // Verify serialization includes policies field + let serialized = serde_json::to_string(&response).unwrap(); + assert!( + serialized.contains("policies"), + "User-provided Solana policies should appear in JSON response" + ); + assert!( + serialized.contains("max_signatures"), + "User-provided Solana policy values should appear in JSON response" + ); + assert!( + serialized.contains("fee_payment_strategy"), + "User-provided Solana policy values should appear in JSON response" + ); + } -impl From for EvmPolicyResponse { - fn from(policy: RelayerEvmPolicy) -> Self { - Self { - min_balance: policy.min_balance, - gas_limit_estimation: policy.gas_limit_estimation, - gas_price_cap: policy.gas_price_cap, - whitelist_receivers: policy.whitelist_receivers, - eip1559_pricing: policy.eip1559_pricing, - private_transactions: policy.private_transactions, - } + #[test] + fn test_user_provided_stellar_policies_returned_in_response() { + // Create a repository model with user-provided Stellar policies + let repo_model = RelayerRepoModel { + id: "test-stellar-relayer".to_string(), + name: "Test Stellar Relayer".to_string(), + network: "mainnet".to_string(), + network_type: RelayerNetworkType::Stellar, + paused: false, + policies: RelayerNetworkPolicy::Stellar(RelayerStellarPolicy { + max_fee: Some(100000), + timeout_seconds: Some(30), + min_balance: None, // Some fields can still be None + }), + signer_id: "test-signer".to_string(), + notification_id: None, + custom_rpc_urls: None, + address: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(), + system_disabled: false, + }; + + // Convert to response + let response = RelayerResponse::from(repo_model); + + // User-provided policies should be included in response + assert!(response.policies.is_some()); + + // Verify serialization includes policies field + let serialized = serde_json::to_string(&response).unwrap(); + assert!( + serialized.contains("policies"), + "User-provided Stellar policies should appear in JSON response" + ); + assert!( + serialized.contains("max_fee"), + "User-provided Stellar policy values should appear in JSON response" + ); + assert!( + serialized.contains("timeout_seconds"), + "User-provided Stellar policy values should appear in JSON response" + ); } -} -impl From for SolanaPolicyResponse { - fn from(policy: RelayerSolanaPolicy) -> Self { - Self { - allowed_programs: policy.allowed_programs, - max_signatures: policy.max_signatures, - max_tx_data_size: policy.max_tx_data_size, - min_balance: policy.min_balance, - allowed_tokens: policy.allowed_tokens, - fee_payment_strategy: policy.fee_payment_strategy, - fee_margin_percentage: policy.fee_margin_percentage, - allowed_accounts: policy.allowed_accounts, - disallowed_accounts: policy.disallowed_accounts, - max_allowed_fee_lamports: policy.max_allowed_fee_lamports, - swap_config: policy.swap_config, - } + #[test] + fn test_relayer_status_serialization() { + // Test EVM status + let evm_status = RelayerStatus::Evm { + balance: "1000000000000000000".to_string(), + pending_transactions_count: 5, + last_confirmed_transaction_timestamp: Some("2024-01-01T00:00:00Z".to_string()), + system_disabled: false, + paused: false, + nonce: "42".to_string(), + }; + + let serialized = serde_json::to_string(&evm_status).unwrap(); + assert!(serialized.contains(r#""network_type":"evm""#)); + assert!(serialized.contains(r#""nonce":"42""#)); + assert!(serialized.contains(r#""balance":"1000000000000000000""#)); + + // Test Solana status + let solana_status = RelayerStatus::Solana { + balance: "5000000000".to_string(), + pending_transactions_count: 3, + last_confirmed_transaction_timestamp: None, + system_disabled: false, + paused: true, + }; + + let serialized = serde_json::to_string(&solana_status).unwrap(); + assert!(serialized.contains(r#""network_type":"solana""#)); + assert!(serialized.contains(r#""balance":"5000000000""#)); + assert!(serialized.contains(r#""paused":true"#)); + + // Test Stellar status + let stellar_status = RelayerStatus::Stellar { + balance: "1000000000".to_string(), + pending_transactions_count: 2, + last_confirmed_transaction_timestamp: Some("2024-01-01T12:00:00Z".to_string()), + system_disabled: true, + paused: false, + sequence_number: "123456789".to_string(), + }; + + let serialized = serde_json::to_string(&stellar_status).unwrap(); + assert!(serialized.contains(r#""network_type":"stellar""#)); + assert!(serialized.contains(r#""sequence_number":"123456789""#)); + assert!(serialized.contains(r#""system_disabled":true"#)); } -} -impl From for StellarPolicyResponse { - fn from(policy: RelayerStellarPolicy) -> Self { - Self { - min_balance: policy.min_balance, - max_fee: policy.max_fee, - timeout_seconds: policy.timeout_seconds, + #[test] + fn test_relayer_status_deserialization() { + // Test EVM status deserialization + let evm_json = r#"{ + "network_type": "evm", + "balance": "1000000000000000000", + "pending_transactions_count": 5, + "last_confirmed_transaction_timestamp": "2024-01-01T00:00:00Z", + "system_disabled": false, + "paused": false, + "nonce": "42" + }"#; + + let status: RelayerStatus = serde_json::from_str(evm_json).unwrap(); + if let RelayerStatus::Evm { nonce, balance, .. } = status { + assert_eq!(nonce, "42"); + assert_eq!(balance, "1000000000000000000"); + } else { + panic!("Expected EVM status"); + } + + // Test Solana status deserialization + let solana_json = r#"{ + "network_type": "solana", + "balance": "5000000000", + "pending_transactions_count": 3, + "last_confirmed_transaction_timestamp": null, + "system_disabled": false, + "paused": true + }"#; + + let status: RelayerStatus = serde_json::from_str(solana_json).unwrap(); + if let RelayerStatus::Solana { + balance, paused, .. + } = status + { + assert_eq!(balance, "5000000000"); + assert!(paused); + } else { + panic!("Expected Solana status"); + } + + // Test Stellar status deserialization + let stellar_json = r#"{ + "network_type": "stellar", + "balance": "1000000000", + "pending_transactions_count": 2, + "last_confirmed_transaction_timestamp": "2024-01-01T12:00:00Z", + "system_disabled": true, + "paused": false, + "sequence_number": "123456789" + }"#; + + let status: RelayerStatus = serde_json::from_str(stellar_json).unwrap(); + if let RelayerStatus::Stellar { + sequence_number, + system_disabled, + .. + } = status + { + assert_eq!(sequence_number, "123456789"); + assert!(system_disabled); + } else { + panic!("Expected Stellar status"); } } } From 5a7fde26995e9fc4c74f9d02f54e5ba9ee7c03fa Mon Sep 17 00:00:00 2001 From: Zeljko Date: Thu, 24 Jul 2025 12:10:46 +0200 Subject: [PATCH 41/59] chore: fixes --- src/api/controllers/relayer.rs | 34 +-- .../rpc/methods/get_supported_tokens.rs | 12 +- src/models/relayer/response.rs | 243 ++++++++++++++---- src/utils/serde/u128_deserializer.rs | 16 ++ 4 files changed, 238 insertions(+), 67 deletions(-) diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 7535f0548..ed6ccfdcb 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -23,7 +23,7 @@ use crate::{ NetworkTransactionRequest, NetworkType, NotificationRepoModel, PaginationMeta, PaginationQuery, Relayer as RelayerDomainModel, RelayerRepoModel, RelayerRepoUpdater, RelayerResponse, SignerRepoModel, ThinDataAppState, TransactionRepoModel, - TransactionResponse, UpdateRelayerRequestRaw, + TransactionResponse, TransactionStatus, UpdateRelayerRequestRaw, }, repositories::{ NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, @@ -141,7 +141,7 @@ where // Convert request to domain relayer (validates automatically) let relayer = crate::models::Relayer::try_from(request)?; - // Validate dependencies before creating the relayer + // Check if signer exists let signer_model = state .signer_repository .get_by_id(relayer.signer_id.clone()) @@ -161,7 +161,7 @@ where ))); } - // check if signer is already in use by another relayer on the same network + // Check if signer is already in use by another relayer on the same network let relayers = state .relayer_repository .list_by_signer_id(&relayer.signer_id) @@ -259,7 +259,7 @@ where .apply_json_patch(&patch) .map_err(ApiError::from)?; - // 3. Repository concern - Use existing RelayerRepoUpdater to preserve runtime fields + // Use existing RelayerRepoUpdater to preserve runtime fields let updated_repo_model = RelayerRepoUpdater::from_existing(relayer).apply_domain_update(updated_domain); @@ -301,27 +301,27 @@ where TCR: TransactionCounterTrait + Send + Sync + 'static, PR: PluginRepositoryTrait + Send + Sync + 'static, { - // First check if the relayer exists + // Check if the relayer exists let _relayer = get_relayer_by_id(relayer_id.clone(), &state).await?; // Check if the relayer has any transactions (pending or otherwise) - use crate::models::PaginationQuery; let transactions = state .transaction_repository - .find_by_relayer_id( + .find_by_status( &relayer_id, - PaginationQuery { - page: 1, - per_page: 1, - }, + &[ + TransactionStatus::Pending, + TransactionStatus::Sent, + TransactionStatus::Submitted, + ], ) .await?; - if transactions.total > 0 { + if !transactions.is_empty() { return Err(ApiError::BadRequest(format!( "Cannot delete relayer '{}' because it has {} transaction(s). Please wait for all transactions to complete or cancel them before deleting the relayer.", relayer_id, - transactions.total + transactions.len() ))); } @@ -1057,9 +1057,9 @@ mod tests { solana_policy.fee_payment_strategy, Some(RelayerSolanaFeePaymentStrategy::Relayer) ); - assert_eq!(solana_policy.min_balance, Some(5000000)); + assert_eq!(solana_policy.min_balance, 5000000); assert_eq!(solana_policy.max_signatures, Some(10)); - assert_eq!(solana_policy.max_tx_data_size, Some(1232)); + assert_eq!(solana_policy.max_tx_data_size, 1232); assert_eq!(solana_policy.max_allowed_fee_lamports, Some(50000)); } else { panic!("Expected Solana policies"); @@ -1879,9 +1879,9 @@ mod tests { solana_policy.fee_payment_strategy, Some(RelayerSolanaFeePaymentStrategy::User) ); - assert_eq!(solana_policy.min_balance, Some(2000000)); + assert_eq!(solana_policy.min_balance, 2000000); assert_eq!(solana_policy.max_signatures, Some(5)); - assert_eq!(solana_policy.max_tx_data_size, Some(800)); + assert_eq!(solana_policy.max_tx_data_size, 800); assert_eq!(solana_policy.max_allowed_fee_lamports, Some(25000)); assert_eq!(solana_policy.fee_margin_percentage, Some(15.0)); } else { diff --git a/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs b/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs index 60eddcf3f..9f574218c 100644 --- a/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs +++ b/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs @@ -12,6 +12,7 @@ use log::info; use crate::{ + constants::DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE, jobs::JobProducerTrait, models::{GetSupportedTokensItem, GetSupportedTokensRequestParams, GetSupportedTokensResult}, services::{JupiterServiceTrait, SolanaProviderTrait, SolanaSignTrait}, @@ -45,10 +46,13 @@ where symbol: token.symbol.as_deref().unwrap_or("").to_string(), decimals: token.decimals.unwrap_or(0), max_allowed_fee: token.max_allowed_fee, - conversion_slippage_percentage: token - .swap_config - .as_ref() - .and_then(|config| config.slippage_percentage), + conversion_slippage_percentage: Some( + token + .swap_config + .as_ref() + .and_then(|config| config.slippage_percentage) + .unwrap_or(DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE), + ), }) .collect() }) diff --git a/src/models/relayer/response.rs b/src/models/relayer/response.rs index 1f4e162a0..4a5e3e741 100644 --- a/src/models/relayer/response.rs +++ b/src/models/relayer/response.rs @@ -16,6 +16,10 @@ use super::{ RelayerRepoModel, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, RelayerSolanaSwapPolicy, RelayerStellarPolicy, RpcConfig, }; +use crate::constants::{ + DEFAULT_EVM_MIN_BALANCE, DEFAULT_SOLANA_MAX_TX_DATA_SIZE, DEFAULT_SOLANA_MIN_BALANCE, + DEFAULT_STELLAR_MIN_BALANCE, +}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -58,7 +62,7 @@ impl From for RelayerNetworkPolicyResponse { } /// Relayer response model for API endpoints -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[derive(Debug, Serialize, Clone, PartialEq, ToSchema)] pub struct RelayerResponse { pub id: String, pub name: String, @@ -109,15 +113,45 @@ pub enum RelayerStatus { }, } +/// Convert RelayerNetworkPolicy to RelayerNetworkPolicyResponse based on network type +fn convert_policy_to_response( + policy: RelayerNetworkPolicy, + network_type: RelayerNetworkType, +) -> RelayerNetworkPolicyResponse { + match (policy, network_type) { + (RelayerNetworkPolicy::Evm(evm_policy), RelayerNetworkType::Evm) => { + RelayerNetworkPolicyResponse::Evm(EvmPolicyResponse::from(evm_policy)) + } + (RelayerNetworkPolicy::Solana(solana_policy), RelayerNetworkType::Solana) => { + RelayerNetworkPolicyResponse::Solana(SolanaPolicyResponse::from(solana_policy)) + } + (RelayerNetworkPolicy::Stellar(stellar_policy), RelayerNetworkType::Stellar) => { + RelayerNetworkPolicyResponse::Stellar(StellarPolicyResponse::from(stellar_policy)) + } + // Handle mismatched cases by falling back to the policy type + (RelayerNetworkPolicy::Evm(evm_policy), _) => { + RelayerNetworkPolicyResponse::Evm(EvmPolicyResponse::from(evm_policy)) + } + (RelayerNetworkPolicy::Solana(solana_policy), _) => { + RelayerNetworkPolicyResponse::Solana(SolanaPolicyResponse::from(solana_policy)) + } + (RelayerNetworkPolicy::Stellar(stellar_policy), _) => { + RelayerNetworkPolicyResponse::Stellar(StellarPolicyResponse::from(stellar_policy)) + } + } +} + impl From for RelayerResponse { fn from(relayer: Relayer) -> Self { Self { - id: relayer.id, - name: relayer.name, - network: relayer.network, + id: relayer.id.clone(), + name: relayer.name.clone(), + network: relayer.network.clone(), network_type: relayer.network_type, paused: relayer.paused, - policies: relayer.policies.map(RelayerNetworkPolicyResponse::from), + policies: relayer + .policies + .map(|policy| convert_policy_to_response(policy, relayer.network_type)), signer_id: relayer.signer_id, notification_id: relayer.notification_id, custom_rpc_urls: relayer.custom_rpc_urls, @@ -133,7 +167,10 @@ impl From for RelayerResponse { let policies = if is_empty_policy(&model.policies) { None // Don't return empty/default policies in API response } else { - Some(RelayerNetworkPolicyResponse::from(model.policies)) + Some(convert_policy_to_response( + model.policies.clone(), + model.network_type, + )) }; Self { @@ -152,6 +189,100 @@ impl From for RelayerResponse { } } +/// Custom Deserialize implementation for RelayerResponse that uses network_type to deserialize policies +impl<'de> serde::Deserialize<'de> for RelayerResponse { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + use serde_json::Value; + + // First, deserialize to a generic Value to extract network_type + let value: Value = Value::deserialize(deserializer)?; + + // Extract the network_type field + let network_type: RelayerNetworkType = value + .get("network_type") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .ok_or_else(|| D::Error::missing_field("network_type"))?; + + // Extract policies field if present + let policies = if let Some(policies_value) = value.get("policies") { + if policies_value.is_null() { + None + } else { + // Deserialize policies based on network_type + let policy_response = match network_type { + RelayerNetworkType::Evm => { + let evm_policy: EvmPolicyResponse = + serde_json::from_value(policies_value.clone()) + .map_err(D::Error::custom)?; + RelayerNetworkPolicyResponse::Evm(evm_policy) + } + RelayerNetworkType::Solana => { + let solana_policy: SolanaPolicyResponse = + serde_json::from_value(policies_value.clone()) + .map_err(D::Error::custom)?; + RelayerNetworkPolicyResponse::Solana(solana_policy) + } + RelayerNetworkType::Stellar => { + let stellar_policy: StellarPolicyResponse = + serde_json::from_value(policies_value.clone()) + .map_err(D::Error::custom)?; + RelayerNetworkPolicyResponse::Stellar(stellar_policy) + } + }; + Some(policy_response) + } + } else { + None + }; + + // Deserialize all other fields normally + Ok(RelayerResponse { + id: value + .get("id") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .ok_or_else(|| D::Error::missing_field("id"))?, + name: value + .get("name") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .ok_or_else(|| D::Error::missing_field("name"))?, + network: value + .get("network") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .ok_or_else(|| D::Error::missing_field("network"))?, + network_type, + paused: value + .get("paused") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .ok_or_else(|| D::Error::missing_field("paused"))?, + policies, + signer_id: value + .get("signer_id") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .ok_or_else(|| D::Error::missing_field("signer_id"))?, + notification_id: value + .get("notification_id") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or(None), + custom_rpc_urls: value + .get("custom_rpc_urls") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or(None), + address: value + .get("address") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or(None), + system_disabled: value + .get("system_disabled") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or(None), + }) + } +} + /// Check if a policy is "empty" (all fields are None) indicating it's a default fn is_empty_policy(policy: &RelayerNetworkPolicy) -> bool { match policy { @@ -191,17 +322,45 @@ pub struct NetworkPolicyResponse { pub policy: RelayerNetworkPolicy, } +/// Default function for EVM min balance +fn default_evm_min_balance() -> u128 { + DEFAULT_EVM_MIN_BALANCE +} + +/// Default function for Solana min balance +fn default_solana_min_balance() -> u64 { + DEFAULT_SOLANA_MIN_BALANCE +} + +/// Default function for Stellar min balance +fn default_stellar_min_balance() -> u64 { + DEFAULT_STELLAR_MIN_BALANCE +} + +/// Default function for Solana max tx data size +fn default_solana_max_tx_data_size() -> u16 { + DEFAULT_SOLANA_MAX_TX_DATA_SIZE +} /// EVM policy response model for OpenAPI documentation #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] #[serde(deny_unknown_fields)] pub struct EvmPolicyResponse { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default = "default_evm_min_balance", + serialize_with = "crate::utils::serialize_u128_as_number", + deserialize_with = "crate::utils::deserialize_u128_as_number" + )] #[schema(nullable = false)] - pub min_balance: Option, + pub min_balance: u128, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] pub gas_limit_estimation: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "crate::utils::serialize_optional_u128_as_number", + deserialize_with = "crate::utils::deserialize_optional_u128_as_number", + default + )] #[schema(nullable = false)] pub gas_price_cap: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -225,12 +384,12 @@ pub struct SolanaPolicyResponse { #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] pub max_signatures: Option, - #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] - pub max_tx_data_size: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default = "default_solana_max_tx_data_size")] + pub max_tx_data_size: u16, + #[serde(default = "default_solana_min_balance")] #[schema(nullable = false)] - pub min_balance: Option, + pub min_balance: u64, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] pub allowed_tokens: Option>, @@ -264,15 +423,15 @@ pub struct StellarPolicyResponse { #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] pub timeout_seconds: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default = "default_stellar_min_balance")] #[schema(nullable = false)] - pub min_balance: Option, + pub min_balance: u64, } impl From for EvmPolicyResponse { fn from(policy: RelayerEvmPolicy) -> Self { Self { - min_balance: policy.min_balance, + min_balance: policy.min_balance.unwrap_or(DEFAULT_EVM_MIN_BALANCE), gas_limit_estimation: policy.gas_limit_estimation, gas_price_cap: policy.gas_price_cap, whitelist_receivers: policy.whitelist_receivers, @@ -287,8 +446,10 @@ impl From for SolanaPolicyResponse { Self { allowed_programs: policy.allowed_programs, max_signatures: policy.max_signatures, - max_tx_data_size: policy.max_tx_data_size, - min_balance: policy.min_balance, + max_tx_data_size: policy + .max_tx_data_size + .unwrap_or(DEFAULT_SOLANA_MAX_TX_DATA_SIZE), + min_balance: policy.min_balance.unwrap_or(DEFAULT_SOLANA_MIN_BALANCE), allowed_tokens: policy.allowed_tokens, fee_payment_strategy: policy.fee_payment_strategy, fee_margin_percentage: policy.fee_margin_percentage, @@ -303,7 +464,7 @@ impl From for SolanaPolicyResponse { impl From for StellarPolicyResponse { fn from(policy: RelayerStellarPolicy) -> Self { Self { - min_balance: policy.min_balance, + min_balance: policy.min_balance.unwrap_or(DEFAULT_STELLAR_MIN_BALANCE), max_fee: policy.max_fee, timeout_seconds: policy.timeout_seconds, } @@ -354,7 +515,7 @@ mod tests { whitelist_receivers: None, eip1559_pricing: Some(true), private_transactions: None, - min_balance: None, + min_balance: Some(DEFAULT_EVM_MIN_BALANCE), gas_limit_estimation: None, } .into() @@ -404,16 +565,8 @@ mod tests { assert!(response.policies.is_some()); if let Some(RelayerNetworkPolicyResponse::Solana(solana_response)) = response.policies { - assert_eq!( - solana_response.allowed_programs, - Some(vec!["11111111111111111111111111111111".to_string()]) - ); + assert_eq!(solana_response.min_balance, 1000000); assert_eq!(solana_response.max_signatures, Some(5)); - assert_eq!(solana_response.min_balance, Some(1000000)); - assert_eq!( - solana_response.fee_payment_strategy, - Some(RelayerSolanaFeePaymentStrategy::Relayer) - ); } else { panic!("Expected Solana policy response"); } @@ -444,9 +597,7 @@ mod tests { assert!(response.policies.is_some()); if let Some(RelayerNetworkPolicyResponse::Stellar(stellar_response)) = response.policies { - assert_eq!(stellar_response.min_balance, Some(20000000)); - assert_eq!(stellar_response.max_fee, Some(100000)); - assert_eq!(stellar_response.timeout_seconds, Some(30)); + assert_eq!(stellar_response.min_balance, 20000000); } else { panic!("Expected Stellar policy response"); } @@ -461,11 +612,11 @@ mod tests { network_type: RelayerNetworkType::Evm, paused: false, policies: Some(RelayerNetworkPolicyResponse::Evm(EvmPolicyResponse { - gas_price_cap: Some(100_000_000_000), + gas_price_cap: Some(50000000000), whitelist_receivers: None, eip1559_pricing: Some(true), private_transactions: None, - min_balance: None, + min_balance: DEFAULT_EVM_MIN_BALANCE, gas_limit_estimation: None, })), signer_id: "test-signer".to_string(), @@ -496,8 +647,8 @@ mod tests { policies: Some(RelayerNetworkPolicyResponse::Solana(SolanaPolicyResponse { allowed_programs: Some(vec!["11111111111111111111111111111111".to_string()]), max_signatures: Some(5), - max_tx_data_size: Some(1024), - min_balance: Some(1000000), + max_tx_data_size: DEFAULT_SOLANA_MAX_TX_DATA_SIZE, + min_balance: 1000000, allowed_tokens: Some(vec![AllowedToken::new( "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), Some(100000), @@ -542,9 +693,9 @@ mod tests { paused: false, policies: Some(RelayerNetworkPolicyResponse::Stellar( StellarPolicyResponse { - min_balance: Some(20000000), - max_fee: Some(100000), - timeout_seconds: Some(30), + max_fee: Some(5000), + timeout_seconds: None, + min_balance: 20000000, }, )), signer_id: "test-signer".to_string(), @@ -565,9 +716,9 @@ mod tests { // Verify Stellar-specific fields if let Some(RelayerNetworkPolicyResponse::Stellar(stellar_policy)) = deserialized.policies { - assert_eq!(stellar_policy.min_balance, Some(20000000)); - assert_eq!(stellar_policy.max_fee, Some(100000)); - assert_eq!(stellar_policy.timeout_seconds, Some(30)); + assert_eq!(stellar_policy.min_balance, 20000000); + assert_eq!(stellar_policy.max_fee, Some(5000)); + assert_eq!(stellar_policy.timeout_seconds, None); } else { panic!("Expected Stellar policy in deserialized response"); } @@ -586,7 +737,7 @@ mod tests { whitelist_receivers: None, eip1559_pricing: Some(true), private_transactions: None, - min_balance: None, + min_balance: DEFAULT_EVM_MIN_BALANCE, gas_limit_estimation: None, })), signer_id: "test-signer".to_string(), @@ -622,8 +773,8 @@ mod tests { policies: Some(RelayerNetworkPolicyResponse::Solana(SolanaPolicyResponse { allowed_programs: Some(vec!["11111111111111111111111111111111".to_string()]), max_signatures: Some(5), - max_tx_data_size: None, - min_balance: Some(1000000), + max_tx_data_size: DEFAULT_SOLANA_MAX_TX_DATA_SIZE, + min_balance: 1000000, allowed_tokens: None, fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), fee_margin_percentage: None, @@ -664,7 +815,7 @@ mod tests { paused: false, policies: Some(RelayerNetworkPolicyResponse::Stellar( StellarPolicyResponse { - min_balance: Some(20000000), + min_balance: 20000000, max_fee: Some(100000), timeout_seconds: Some(30), }, diff --git a/src/utils/serde/u128_deserializer.rs b/src/utils/serde/u128_deserializer.rs index 431146664..c020d23c8 100644 --- a/src/utils/serde/u128_deserializer.rs +++ b/src/utils/serde/u128_deserializer.rs @@ -111,6 +111,22 @@ where Ok(value) } +/// Serialize u128 as number (non-optional) +pub fn serialize_u128_as_number(value: &u128, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_u128(*value) +} + +/// Deserialize u128 from number (non-optional) +pub fn deserialize_u128_as_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + u128::deserialize(deserializer) +} + // Deserialize optional u64 pub fn deserialize_optional_u64<'de, D>(deserializer: D) -> Result, D::Error> where From 5603fdb7c28ceba6649ccdc5c5b74c0b8fb54bc3 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Thu, 24 Jul 2025 12:48:44 +0200 Subject: [PATCH 42/59] chore: impr --- src/models/relayer/response.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/models/relayer/response.rs b/src/models/relayer/response.rs index 4a5e3e741..8891c966b 100644 --- a/src/models/relayer/response.rs +++ b/src/models/relayer/response.rs @@ -17,8 +17,8 @@ use super::{ RelayerSolanaSwapPolicy, RelayerStellarPolicy, RpcConfig, }; use crate::constants::{ - DEFAULT_EVM_MIN_BALANCE, DEFAULT_SOLANA_MAX_TX_DATA_SIZE, DEFAULT_SOLANA_MIN_BALANCE, - DEFAULT_STELLAR_MIN_BALANCE, + DEFAULT_EVM_GAS_LIMIT_ESTIMATION, DEFAULT_EVM_MIN_BALANCE, DEFAULT_SOLANA_MAX_TX_DATA_SIZE, + DEFAULT_SOLANA_MIN_BALANCE, DEFAULT_STELLAR_MIN_BALANCE, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -327,6 +327,10 @@ fn default_evm_min_balance() -> u128 { DEFAULT_EVM_MIN_BALANCE } +fn default_evm_gas_limit_estimation() -> bool { + DEFAULT_EVM_GAS_LIMIT_ESTIMATION +} + /// Default function for Solana min balance fn default_solana_min_balance() -> u64 { DEFAULT_SOLANA_MIN_BALANCE @@ -352,9 +356,9 @@ pub struct EvmPolicyResponse { )] #[schema(nullable = false)] pub min_balance: u128, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default = "default_evm_gas_limit_estimation")] #[schema(nullable = false)] - pub gas_limit_estimation: Option, + pub gas_limit_estimation: bool, #[serde( skip_serializing_if = "Option::is_none", serialize_with = "crate::utils::serialize_optional_u128_as_number", @@ -432,7 +436,9 @@ impl From for EvmPolicyResponse { fn from(policy: RelayerEvmPolicy) -> Self { Self { min_balance: policy.min_balance.unwrap_or(DEFAULT_EVM_MIN_BALANCE), - gas_limit_estimation: policy.gas_limit_estimation, + gas_limit_estimation: policy + .gas_limit_estimation + .unwrap_or(DEFAULT_EVM_GAS_LIMIT_ESTIMATION), gas_price_cap: policy.gas_price_cap, whitelist_receivers: policy.whitelist_receivers, eip1559_pricing: policy.eip1559_pricing, @@ -516,7 +522,7 @@ mod tests { eip1559_pricing: Some(true), private_transactions: None, min_balance: Some(DEFAULT_EVM_MIN_BALANCE), - gas_limit_estimation: None, + gas_limit_estimation: Some(DEFAULT_EVM_GAS_LIMIT_ESTIMATION), } .into() )) @@ -617,7 +623,7 @@ mod tests { eip1559_pricing: Some(true), private_transactions: None, min_balance: DEFAULT_EVM_MIN_BALANCE, - gas_limit_estimation: None, + gas_limit_estimation: DEFAULT_EVM_GAS_LIMIT_ESTIMATION, })), signer_id: "test-signer".to_string(), notification_id: None, @@ -738,7 +744,7 @@ mod tests { eip1559_pricing: Some(true), private_transactions: None, min_balance: DEFAULT_EVM_MIN_BALANCE, - gas_limit_estimation: None, + gas_limit_estimation: DEFAULT_EVM_GAS_LIMIT_ESTIMATION, })), signer_id: "test-signer".to_string(), notification_id: None, From 3b3962e83a3a99dc8705655111f2b730d81f24e2 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Thu, 24 Jul 2025 13:00:28 +0200 Subject: [PATCH 43/59] chore: add nosemgrep rule --- src/api/controllers/relayer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index ed6ccfdcb..42b9ffff4 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -791,7 +791,7 @@ mod tests { use std::env; fn setup_test_env() { - env::set_var("API_KEY", "7EF1CB7C-5003-4696-B384-C72AF8C3E15D"); // noboost + env::set_var("API_KEY", "7EF1CB7C-5003-4696-B384-C72AF8C3E15D"); // noboost nosemgrep env::set_var("REDIS_URL", "redis://localhost:6379"); } From f8e098b00d256bcd012314b0dbd602e9b75c1264 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Sat, 26 Jul 2025 00:33:25 +0200 Subject: [PATCH 44/59] feat: securely store secrets in storage --- Cargo.lock | 25 + Cargo.toml | 13 + docs/ENCRYPTION.md | 249 +++++++++ src/api/controllers/relayer.rs | 13 +- src/api/controllers/signer.rs | 17 +- src/api/routes/relayer.rs | 8 +- src/bootstrap/config_processor.rs | 36 +- src/bootstrap/initialize_app_state.rs | 8 + src/config/server_config.rs | 10 + src/domain/relayer/mod.rs | 2 +- src/domain/relayer/solana/dex/mod.rs | 14 +- src/domain/relayer/solana/mod.rs | 2 +- src/domain/transaction/mod.rs | 4 +- src/models/notification/repository.rs | 11 +- src/models/plain_or_env_value.rs | 8 +- src/models/signer/mod.rs | 4 +- src/models/signer/repository.rs | 306 ++++++----- src/models/signer/response.rs | 8 +- src/repositories/signer/mod.rs | 8 +- src/repositories/signer/signer_in_memory.rs | 6 +- src/repositories/signer/signer_redis.rs | 38 +- src/services/google_cloud_kms/mod.rs | 3 +- src/services/signer/evm/local_signer.rs | 9 +- src/services/signer/evm/mod.rs | 32 +- src/services/signer/evm/vault_signer.rs | 7 +- src/services/signer/mod.rs | 6 +- src/services/signer/solana/local_signer.rs | 18 +- src/services/signer/solana/mod.rs | 33 +- src/services/signer/solana/vault_signer.rs | 16 +- .../signer/solana/vault_transit_signer.rs | 18 +- src/services/signer/stellar/local_signer.rs | 13 +- src/services/signer/stellar/mod.rs | 9 +- src/services/signer/stellar/vault_signer.rs | 12 +- src/utils/encryption.rs | 494 ++++++++++++++++++ src/utils/mocks.rs | 8 +- src/utils/mod.rs | 3 + src/utils/serde/mod.rs | 3 + src/utils/serde/repository_encryption.rs | 397 ++++++++++++++ tests/integration/metrics.rs | 4 + 39 files changed, 1585 insertions(+), 290 deletions(-) create mode 100644 docs/ENCRYPTION.md create mode 100644 src/utils/encryption.rs create mode 100644 src/utils/serde/repository_encryption.rs diff --git a/Cargo.lock b/Cargo.lock index 874b7e9ae..dfdee34a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,6 +234,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aes-gcm-siv" version = "0.11.1" @@ -3671,6 +3685,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.31.1" @@ -5306,6 +5330,7 @@ dependencies = [ "actix-governor", "actix-rt", "actix-web", + "aes-gcm", "alloy", "apalis", "apalis-cron", diff --git a/Cargo.toml b/Cargo.toml index 17dc9d9ac..df7f00714 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ secrets = { version = "1.2"} libsodium-sys = "0.2.7" zeroize = "1.8" subtle = "2.6" +aes-gcm = "0.10" ed25519-dalek = "2.1" stellar-strkey = "0.0.13" soroban-rs = "0.2.5" @@ -120,5 +121,17 @@ path = "helpers/generate_uuid.rs" name = "generate_openapi" path = "helpers/generate_openapi.rs" +[[example]] +name = "test_encryption" +path = "helpers/test_encryption.rs" + +[[example]] +name = "test_signer_encryption" +path = "helpers/test_signer_encryption.rs" + +[[example]] +name = "test_storage_encryption" +path = "helpers/test_storage_encryption.rs" + [lib] path = "src/lib.rs" diff --git a/docs/ENCRYPTION.md b/docs/ENCRYPTION.md new file mode 100644 index 000000000..f3225f62c --- /dev/null +++ b/docs/ENCRYPTION.md @@ -0,0 +1,249 @@ +# Field-Level Encryption for Sensitive Data + +The OpenZeppelin Relayer now includes field-level encryption to protect sensitive data at rest in Redis. This feature ensures that private keys, API secrets, and other sensitive information are encrypted before being stored. + +## Overview + +The encryption system uses **AES-256-GCM** encryption with the following features: + +- **Transparent encryption/decryption**: Happens automatically in the repository layer +- **Field-level encryption**: Only sensitive fields are encrypted, not entire records +- **Backward compatibility**: Can read both encrypted and legacy base64-encoded data +- **Memory protection**: Uses `SecretString` and `SecretVec` for in-memory protection +- **Authenticated encryption**: AES-GCM provides both confidentiality and integrity + +## Protected Data + +The following sensitive fields are automatically encrypted: + +### Signer Configurations +- **Private keys** (`raw_key` in LocalSignerConfig) +- **API secrets** (Turnkey, Vault, Google Cloud KMS credentials) +- **Role IDs and Secret IDs** (Vault authentication) +- **Service account private keys** (Google Cloud KMS) + +### Other Sensitive Fields +- Any field using `SecretString` type +- Custom sensitive fields marked for encryption + +## Setup + +### 1. Generate Encryption Key + +Generate a 32-byte encryption key using OpenSSL: + +```bash +# Generate and export the key +export STORAGE_ENCRYPTION_KEY=$(openssl rand -base64 32) + +# Or generate hex-encoded key (alternative) +export STORAGE_ENCRYPTION_KEY_HEX=$(openssl rand -hex 32) +``` + +### 2. Environment Configuration + +Set one of the following environment variables: + +```bash +# Option 1: Base64-encoded key (recommended) +export STORAGE_ENCRYPTION_KEY="your-base64-encoded-32-byte-key" + +# Option 2: Hex-encoded key (alternative) +export STORAGE_ENCRYPTION_KEY_HEX="your-hex-encoded-32-byte-key" +``` + +### 3. Production Deployment + +For production deployments, consider using: + +- **Container secrets**: Mount the key as a secret volume +- **Environment injection**: Use your orchestration platform's secret management +- **External secret management**: AWS Secrets Manager, HashiCorp Vault, etc. + +Example Docker Compose: +```yaml +services: + relayer: + image: openzeppelin/relayer + environment: + - STORAGE_ENCRYPTION_KEY=${ENCRYPTION_KEY} + secrets: + - encryption_key + +secrets: + encryption_key: + external: true +``` + +## How It Works + +### Encryption Process + +1. **Data Input**: Sensitive data enters as plaintext +2. **Encryption**: Data is encrypted with AES-256-GCM using a random nonce +3. **Storage**: Encrypted data structure (nonce + ciphertext + version) is stored directly as JSON + +### Decryption Process + +1. **Data Retrieval**: JSON-encoded encrypted data is retrieved from storage +2. **Decryption**: Data is decrypted using the configured encryption key +3. **Fallback**: If encryption is not configured, data is treated as JSON-encoded strings + +### Data Format + +Encrypted data is stored directly as JSON with this structure: +```json +{ + "nonce": "base64-encoded-12-byte-nonce", + "ciphertext": "base64-encoded-encrypted-data-with-auth-tag", + "version": 1 +} +``` + +When encryption is disabled (development mode), data is stored as simple JSON strings. + +## Migration + +### For New Deployments +- Set up encryption key before first run +- All sensitive data will be encrypted from the start + +### For Existing Deployments +- **Data migration required** if you have existing sensitive data +- Export existing data before upgrading +- Set up encryption key +- Re-import data (will be encrypted during import) + +### Migration Steps +If you have existing deployments with sensitive data: + +1. **Backup**: Export all existing signer configurations +2. **Setup**: Configure encryption keys in your environment +3. **Clear**: Remove existing signer data from Redis (optional but recommended) +4. **Import**: Re-create signers through the API (data will be automatically encrypted) +5. **Verify**: Test that encrypted data can be properly read and used + +## Security Considerations + +### Key Management +- **Never commit keys to version control** +- **Rotate keys periodically** (requires data re-encryption) +- **Use secure key storage** in production +- **Limit key access** to essential personnel only + +### Operational Security +- **Monitor key access logs** +- **Use different keys per environment** +- **Implement key backup and recovery procedures** +- **Consider HSM integration** for high-security environments + +### Development vs Production +- **Development**: Can run without encryption (falls back to base64) +- **Production**: Always require encryption keys +- **Testing**: Use test-specific keys, never production keys + +## Troubleshooting + +### Common Issues + +#### 1. Key Not Found Error +``` +Missing encryption key environment variable: Either STORAGE_ENCRYPTION_KEY (base64) or STORAGE_ENCRYPTION_KEY_HEX (hex) must be set +``` +**Solution**: Set one of the required environment variables. + +#### 2. Invalid Key Length +``` +Invalid key length: expected 32 bytes, got X +``` +**Solution**: Ensure your key is exactly 32 bytes when decoded. + +#### 3. Decryption Failed +``` +Failed to decrypt raw_key: Decryption failed +``` +**Possible causes**: +- Wrong encryption key +- Corrupted data +- Mixed data from different keys + +### Validation + +Test your encryption setup: + +```bash +# Check if encryption is configured +curl http://localhost:3000/health + +# Create a test signer to verify encryption works +curl -X POST http://localhost:3000/relayers \ + -H "Content-Type: application/json" \ + -d '{"id": "test", "type": "local", "config": {...}}' +``` + +### Logging + +Enable debug logging to see encryption operations: +```bash +RUST_LOG=debug ./openzeppelin-relayer +``` + +Look for log messages like: +- `"Retrieved signer with ID: ..."` +- `"Created signer with ID: ..."` + +## Performance Impact + +### Encryption Overhead +- **CPU**: Minimal overhead (~1-5% for typical workloads) +- **Memory**: Slight increase due to JSON structure +- **Storage**: ~15-25% increase due to encryption metadata (reduced from previous base64-in-base64 format) + +### Optimization Tips +- **Batch operations**: Already optimized for bulk reads/writes +- **Connection pooling**: Use Redis connection pooling (already implemented) +- **Monitoring**: Monitor Redis performance metrics + +## Compliance and Auditing + +### Standards Compliance +- **Encryption**: AES-256-GCM (NIST approved) +- **Key size**: 256-bit keys +- **Nonces**: Cryptographically secure random generation +- **Authentication**: Integrated with GCM mode + +### Audit Trail +- All encryption/decryption operations are logged +- Failed decryption attempts are logged as warnings +- Key access should be monitored externally + +### Data Protection +- **Data at rest**: Encrypted in Redis +- **Data in transit**: Use TLS for Redis connections +- **Data in memory**: Protected with `SecretString`/`SecretVec` +- **Data in logs**: Sensitive data is redacted from logs + +## Example Implementation + +Here's how encryption works in practice: + +```rust +// Creating a signer (automatically encrypted) +let signer = SignerRepoModel { + id: "my-signer".to_string(), + config: SignerConfig::Local(LocalSignerConfig { + raw_key: SecretVec::new(32, |buf| { + buf.copy_from_slice(&private_key_bytes); + }), + }), +}; + +// This will be automatically encrypted before storage +repository.create(signer).await?; + +// Reading a signer (automatically decrypted) +let retrieved_signer = repository.get_by_id("my-signer".to_string()).await?; +// retrieved_signer contains decrypted data, ready to use +``` + +The encryption/decryption happens transparently - your application code doesn't need to change. \ No newline at end of file diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 42b9ffff4..279c91082 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -22,8 +22,8 @@ use crate::{ ApiResponse, CreateRelayerRequest, DefaultAppState, NetworkRepoModel, NetworkTransactionRequest, NetworkType, NotificationRepoModel, PaginationMeta, PaginationQuery, Relayer as RelayerDomainModel, RelayerRepoModel, RelayerRepoUpdater, - RelayerResponse, SignerRepoModel, ThinDataAppState, TransactionRepoModel, - TransactionResponse, TransactionStatus, UpdateRelayerRequestRaw, + RelayerResponse, Signer as SignerDomainModel, SignerRepoModel, ThinDataAppState, + TransactionRepoModel, TransactionResponse, TransactionStatus, UpdateRelayerRequestRaw, }, repositories::{ NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, @@ -185,9 +185,12 @@ where let mut relayer_model = RelayerRepoModel::from(relayer); // get address from signer and set it to relayer model - let signer_service = SignerFactory::create_signer(&relayer_model.network_type, &signer_model) - .await - .map_err(|e| ApiError::InternalError(e.to_string()))?; + let signer_service = SignerFactory::create_signer( + &relayer_model.network_type, + &SignerDomainModel::from(signer_model.clone()), + ) + .await + .map_err(|e| ApiError::InternalError(e.to_string()))?; let address = signer_service .address() .await diff --git a/src/api/controllers/signer.rs b/src/api/controllers/signer.rs index 3e5b9d44e..3e9a5f692 100644 --- a/src/api/controllers/signer.rs +++ b/src/api/controllers/signer.rs @@ -230,10 +230,11 @@ mod tests { use super::*; use crate::{ models::{ - AwsKmsSignerRequestConfig, GoogleCloudKmsSignerKeyRequestConfig, - GoogleCloudKmsSignerRequestConfig, GoogleCloudKmsSignerServiceAccountRequestConfig, - LocalSignerConfig, LocalSignerRequestConfig, SignerConfig, SignerConfigRequest, - SignerType, SignerTypeRequest, TurnkeySignerRequestConfig, VaultSignerRequestConfig, + AwsKmsSignerConfigStorage, AwsKmsSignerRequestConfig, + GoogleCloudKmsSignerKeyRequestConfig, GoogleCloudKmsSignerRequestConfig, + GoogleCloudKmsSignerServiceAccountRequestConfig, LocalSignerConfigStorage, + LocalSignerRequestConfig, SignerConfigRequest, SignerConfigStorage, SignerType, + SignerTypeRequest, TurnkeySignerRequestConfig, VaultSignerRequestConfig, }, utils::mocks::mockutils::create_mock_app_state, }; @@ -242,14 +243,14 @@ mod tests { /// Helper function to create a test signer model fn create_test_signer_model(id: &str, signer_type: SignerType) -> SignerRepoModel { let config = match signer_type { - SignerType::Local => SignerConfig::Local(LocalSignerConfig { + SignerType::Local => SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), - SignerType::AwsKms => SignerConfig::AwsKms(crate::models::AwsKmsSignerConfig { + SignerType::AwsKms => SignerConfigStorage::AwsKms(AwsKmsSignerConfigStorage { region: Some("us-east-1".to_string()), key_id: "test-key-id".to_string(), }), - _ => SignerConfig::Local(LocalSignerConfig { + _ => SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), }; @@ -967,7 +968,7 @@ mod tests { async fn test_signer_response_conversion() { let signer_model = SignerRepoModel { id: "test-id".to_string(), - config: SignerConfig::Local(LocalSignerConfig { + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), }; diff --git a/src/api/routes/relayer.rs b/src/api/routes/relayer.rs index 622dbca6e..b728389e8 100644 --- a/src/api/routes/relayer.rs +++ b/src/api/routes/relayer.rs @@ -264,9 +264,11 @@ mod tests { // Create local signer first let test_signer = crate::models::SignerRepoModel { id: "test-signer".to_string(), - config: crate::models::SignerConfig::Local(crate::models::LocalSignerConfig { - raw_key: secrets::SecretVec::new(32, |v| v.copy_from_slice(&[0u8; 32])), - }), + config: crate::models::SignerConfigStorage::Local( + crate::models::LocalSignerConfigStorage { + raw_key: secrets::SecretVec::new(32, |v| v.copy_from_slice(&[0u8; 32])), + }, + ), }; signer_repo.create(test_signer).await.unwrap(); diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index c69729993..e315630e0 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -6,8 +6,9 @@ use crate::{ config::{Config, RepositoryStorageType, ServerConfig}, jobs::JobProducerTrait, models::{ - signer::Signer, NetworkRepoModel, NotificationRepoModel, PluginModel, RelayerRepoModel, - SignerFileConfig, SignerRepoModel, ThinDataAppState, TransactionRepoModel, + NetworkRepoModel, NotificationRepoModel, PluginModel, RelayerRepoModel, + Signer as SignerDomainModel, SignerFileConfig, SignerRepoModel, ThinDataAppState, + TransactionRepoModel, }, repositories::{ NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, @@ -58,7 +59,7 @@ where /// Process a signer configuration from the config file and convert it into a `SignerRepoModel`. async fn process_signer(signer: &SignerFileConfig) -> Result { // Convert config to domain model (this validates and applies business logic) - let domain_signer = Signer::try_from(signer.clone()) + let domain_signer = SignerDomainModel::try_from(signer.clone()) .wrap_err("Failed to convert signer config to domain model")?; // Convert domain model to repository model for storage @@ -217,10 +218,14 @@ where .iter() .find(|s| s.id == repo_model.signer_id) .ok_or_else(|| eyre::eyre!("Signer not found"))?; + let network_type = repo_model.network_type; - let signer_service = SignerFactory::create_signer(&network_type, signer_model) - .await - .wrap_err("Failed to create signer service")?; + let signer_service = SignerFactory::create_signer( + &network_type, + &SignerDomainModel::from(signer_model.clone()), + ) + .await + .wrap_err("Failed to create signer service")?; let address = signer_service.address().await?; repo_model.address = address.to_string(); @@ -345,13 +350,12 @@ mod tests { config::{ConfigFileNetworkType, NetworksFileConfig, PluginFileConfig}, constants::DEFAULT_PLUGIN_TIMEOUT_SECONDS, jobs::MockJobProducerTrait, - models::relayer::RelayerFileConfig, models::{ - AppState, AwsKmsSignerFileConfig, GoogleCloudKmsKeyFileConfig, - GoogleCloudKmsServiceAccountFileConfig, GoogleCloudKmsSignerFileConfig, - LocalSignerFileConfig, NetworkType, NotificationConfig, NotificationType, - PlainOrEnvValue, SecretString, SignerConfig, SignerFileConfig, SignerFileConfigEnum, - VaultSignerFileConfig, VaultTransitSignerFileConfig, + relayer::RelayerFileConfig, AppState, AwsKmsSignerFileConfig, + GoogleCloudKmsKeyFileConfig, GoogleCloudKmsServiceAccountFileConfig, + GoogleCloudKmsSignerFileConfig, LocalSignerFileConfig, NetworkType, NotificationConfig, + NotificationType, PlainOrEnvValue, SecretString, SignerConfigStorage, SignerFileConfig, + SignerFileConfigEnum, VaultSignerFileConfig, VaultTransitSignerFileConfig, }, repositories::{ InMemoryNetworkRepository, InMemoryNotificationRepository, InMemoryPluginRepository, @@ -439,7 +443,7 @@ mod tests { assert_eq!(model.id, "test-signer"); match model.config { - SignerConfig::Local(config) => { + SignerConfigStorage::Local(config) => { assert!(!config.raw_key.is_empty()); assert_eq!(config.raw_key.len(), 32); } @@ -478,7 +482,7 @@ mod tests { assert_eq!(model.id, "vault-transit-signer"); match model.config { - SignerConfig::VaultTransit(config) => { + SignerConfigStorage::VaultTransit(config) => { assert_eq!(config.key_name, "test-transit-key"); assert_eq!(config.address, "https://vault.example.com"); assert_eq!(config.namespace, Some("test-namespace".to_string())); @@ -515,7 +519,7 @@ mod tests { assert_eq!(model.id, "aws-kms-signer"); match model.config { - SignerConfig::AwsKms(_) => {} + SignerConfigStorage::AwsKms(_) => {} _ => panic!("Expected AwsKms config"), } @@ -623,7 +627,7 @@ mod tests { assert_eq!(model.id, "vault-signer"); match model.config { - SignerConfig::Vault(_) => {} + SignerConfigStorage::Vault(_) => {} _ => panic!("Expected Vault config"), } diff --git a/src/bootstrap/initialize_app_state.rs b/src/bootstrap/initialize_app_state.rs index f2333e9df..e2bf36fd6 100644 --- a/src/bootstrap/initialize_app_state.rs +++ b/src/bootstrap/initialize_app_state.rs @@ -48,6 +48,14 @@ pub async fn initialize_repositories(config: &ServerConfig) -> eyre::Result { warn!("⚠️ Redis repository storage support is experimental. Use with caution."); + + if config.storage_encryption_key.is_none() + && config.storage_encryption_key_hex.is_none() + { + warn!("⚠️ Storage encryption key is not set. Please set the STORAGE_ENCRYPTION_KEY or STORAGE_ENCRYPTION_KEY_HEX environment variable."); + return Err(eyre::eyre!("Storage encryption key is not set. Please set the STORAGE_ENCRYPTION_KEY or STORAGE_ENCRYPTION_KEY_HEX environment variable.")); + } + let connection_manager = initialize_redis_connection(config).await?; RepositoryCollection { diff --git a/src/config/server_config.rs b/src/config/server_config.rs index 5c8d1faf5..eb72c7197 100644 --- a/src/config/server_config.rs +++ b/src/config/server_config.rs @@ -60,6 +60,10 @@ pub struct ServerConfig { pub repository_storage_type: RepositoryStorageType, /// Flag to force config file processing. pub reset_storage_on_start: bool, + /// The encryption key for the storage. + pub storage_encryption_key: Option, + /// The encryption key for the storage in hex format. + pub storage_encryption_key_hex: Option, } impl ServerConfig { @@ -168,6 +172,12 @@ impl ServerConfig { reset_storage_on_start: env::var("RESET_STORAGE_ON_START") .map(|v| v.to_lowercase() == "true") .unwrap_or(false), + storage_encryption_key: env::var("STORAGE_ENCRYPTION_KEY") + .map(|v| SecretString::new(&v)) + .ok(), + storage_encryption_key_hex: env::var("STORAGE_ENCRYPTION_KEY_HEX") + .map(|v| SecretString::new(&v)) + .ok(), } } } diff --git a/src/domain/relayer/mod.rs b/src/domain/relayer/mod.rs index f41924312..878288878 100644 --- a/src/domain/relayer/mod.rs +++ b/src/domain/relayer/mod.rs @@ -369,7 +369,7 @@ impl< let network = EvmNetwork::try_from(network_repo)?; let evm_provider = get_network_provider(&network, relayer.custom_rpc_urls.clone())?; - let signer_service = EvmSignerFactory::create_evm_signer(signer).await?; + let signer_service = EvmSignerFactory::create_evm_signer(signer.into()).await?; let transaction_counter_service = Arc::new(TransactionCounterService::new( relayer.id.clone(), relayer.address.clone(), diff --git a/src/domain/relayer/solana/dex/mod.rs b/src/domain/relayer/solana/dex/mod.rs index 0a637e803..26539d432 100644 --- a/src/domain/relayer/solana/dex/mod.rs +++ b/src/domain/relayer/solana/dex/mod.rs @@ -148,8 +148,8 @@ mod tests { use crate::{ models::{ - LocalSignerConfig, RelayerSolanaPolicy, RelayerSolanaSwapPolicy, SignerConfig, - SignerRepoModel, + LocalSignerConfigStorage, RelayerSolanaPolicy, RelayerSolanaSwapPolicy, + SignerConfigStorage, SignerRepoModel, }, services::{MockSolanaProviderTrait, SolanaSignerFactory}, }; @@ -161,7 +161,7 @@ mod tests { let raw_key = SecretVec::new(32, |v| v.copy_from_slice(&seed)); SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Local(LocalSignerConfig { raw_key }), + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key }), } } @@ -183,7 +183,7 @@ mod tests { let provider = Arc::new(MockSolanaProviderTrait::new()); let signer_service = Arc::new( - SolanaSignerFactory::create_solana_signer(&create_test_signer_model()).unwrap(), + SolanaSignerFactory::create_solana_signer(&create_test_signer_model().into()).unwrap(), ); let jupiter_service = Arc::new(JupiterService::new_from_network(relayer.network.as_str())); @@ -215,7 +215,7 @@ mod tests { let provider = Arc::new(MockSolanaProviderTrait::new()); let signer_service = Arc::new( - SolanaSignerFactory::create_solana_signer(&create_test_signer_model()).unwrap(), + SolanaSignerFactory::create_solana_signer(&create_test_signer_model().into()).unwrap(), ); let jupiter_service = Arc::new(JupiterService::new_from_network(relayer.network.as_str())); @@ -247,7 +247,7 @@ mod tests { let provider = Arc::new(MockSolanaProviderTrait::new()); let signer_service = Arc::new( - SolanaSignerFactory::create_solana_signer(&create_test_signer_model()).unwrap(), + SolanaSignerFactory::create_solana_signer(&create_test_signer_model().into()).unwrap(), ); let jupiter_service = Arc::new(JupiterService::new_from_network(relayer.network.as_str())); @@ -274,7 +274,7 @@ mod tests { let provider = Arc::new(MockSolanaProviderTrait::new()); let signer_service = Arc::new( - SolanaSignerFactory::create_solana_signer(&create_test_signer_model()).unwrap(), + SolanaSignerFactory::create_solana_signer(&create_test_signer_model().into()).unwrap(), ); let jupiter_service = Arc::new(JupiterService::new_from_network(relayer.network.as_str())); diff --git a/src/domain/relayer/solana/mod.rs b/src/domain/relayer/solana/mod.rs index 8d2d6a5bb..ffbdffbe4 100644 --- a/src/domain/relayer/solana/mod.rs +++ b/src/domain/relayer/solana/mod.rs @@ -52,7 +52,7 @@ pub async fn create_solana_relayer< &network, relayer.custom_rpc_urls.clone(), )?); - let signer_service = Arc::new(SolanaSignerFactory::create_solana_signer(&signer)?); + let signer_service = Arc::new(SolanaSignerFactory::create_solana_signer(&signer.into())?); let jupiter_service = Arc::new(JupiterService::new_from_network(relayer.network.as_str())); let rpc_methods = SolanaRpcMethodsImpl::new( relayer.clone(), diff --git a/src/domain/transaction/mod.rs b/src/domain/transaction/mod.rs index edd2c93c8..39fae6327 100644 --- a/src/domain/transaction/mod.rs +++ b/src/domain/transaction/mod.rs @@ -411,7 +411,7 @@ impl RelayerTransactionFactory { .map_err(|e| TransactionError::NetworkConfiguration(e.to_string()))?; let evm_provider = get_network_provider(&network, relayer.custom_rpc_urls.clone())?; - let signer_service = EvmSignerFactory::create_evm_signer(signer).await?; + let signer_service = EvmSignerFactory::create_evm_signer(signer.into()).await?; let network_extra_fee_calculator = get_network_extra_fee_calculator_service(network.clone(), evm_provider.clone()); let price_calculator = evm::PriceCalculator::new( @@ -464,7 +464,7 @@ impl RelayerTransactionFactory { } NetworkType::Stellar => { let signer_service = - Arc::new(StellarSignerFactory::create_stellar_signer(&signer)?); + Arc::new(StellarSignerFactory::create_stellar_signer(&signer.into())?); let network_repo = network_repository .get_by_name(NetworkType::Stellar, &relayer.network) diff --git a/src/models/notification/repository.rs b/src/models/notification/repository.rs index 3cbdd6f34..b4c238a11 100644 --- a/src/models/notification/repository.rs +++ b/src/models/notification/repository.rs @@ -9,7 +9,10 @@ //! Acts as the bridge between the domain layer and actual data storage implementations //! (in-memory, Redis, etc.), ensuring consistent data representation across repositories. -use crate::models::{notification::Notification, NotificationType, NotificationValidationError}; +use crate::models::{ + notification::Notification, NotificationType, NotificationValidationError, SecretString, +}; +use crate::utils::{deserialize_option_secret_string, serialize_option_secret_string}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -18,7 +21,11 @@ pub struct NotificationRepoModel { pub id: String, pub notification_type: NotificationType, pub url: String, - pub signing_key: Option, + #[serde( + serialize_with = "serialize_option_secret_string", + deserialize_with = "deserialize_option_secret_string" + )] + pub signing_key: Option, } impl From for NotificationRepoModel { diff --git a/src/models/plain_or_env_value.rs b/src/models/plain_or_env_value.rs index 3a757dc82..e837c84d6 100644 --- a/src/models/plain_or_env_value.rs +++ b/src/models/plain_or_env_value.rs @@ -298,7 +298,13 @@ mod tests { let serialized = serde_json::to_string(&plain).unwrap(); assert!(serialized.contains(r#""type":"plain"#)); - assert!(serialized.contains(r#""value":"REDACTED"#)); + // Value should be protected (either REDACTED or base64-encoded) + assert!( + serialized.contains(r#""value":"REDACTED"#) + || (serialized.contains(r#""value":""#) && !serialized.contains("test-secret")), + "Expected protected value, got: {}", + serialized + ); } #[test] diff --git a/src/models/signer/mod.rs b/src/models/signer/mod.rs index e18a4879e..ba7b10ff7 100644 --- a/src/models/signer/mod.rs +++ b/src/models/signer/mod.rs @@ -15,8 +15,8 @@ mod repository; pub use repository::{ AwsKmsSignerConfigStorage, GoogleCloudKmsSignerConfigStorage, GoogleCloudKmsSignerKeyConfigStorage, GoogleCloudKmsSignerServiceAccountConfigStorage, - LocalSignerConfigStorage, SignerConfigStorage, SignerRepoModel, SignerRepoModelStorage, - TurnkeySignerConfigStorage, VaultSignerConfigStorage, VaultTransitSignerConfigStorage, + LocalSignerConfigStorage, SignerConfigStorage, SignerRepoModel, TurnkeySignerConfigStorage, + VaultSignerConfigStorage, VaultTransitSignerConfigStorage, }; mod config; diff --git a/src/models/signer/repository.rs b/src/models/signer/repository.rs index 864428bc0..35b1f2dfb 100644 --- a/src/models/signer/repository.rs +++ b/src/models/signer/repository.rs @@ -20,48 +20,20 @@ use crate::{ }, SecretString, }, - utils::{base64_decode, base64_encode}, + utils::{ + deserialize_secret_string, deserialize_secret_vec, serialize_secret_string, + serialize_secret_vec, + }, }; use secrets::SecretVec; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -/// Helper function to serialize secrets as base64 for storage -fn serialize_secret_base64(secret: &SecretVec, serializer: S) -> Result -where - S: Serializer, -{ - let base64 = base64_encode(secret.borrow().as_ref()); - serializer.serialize_str(&base64) -} - -/// Helper function to deserialize secrets from base64 storage -fn deserialize_secret_base64<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let base64_str = String::deserialize(deserializer)?; - let decoded = base64_decode(&base64_str) - .map_err(|e| serde::de::Error::custom(format!("Invalid base64: {}", e)))?; - Ok(SecretVec::new(decoded.len(), |v| { - v.copy_from_slice(&decoded) - })) -} - +use serde::{Deserialize, Serialize}; /// Repository model for signer storage and retrieval #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignerRepoModel { - pub id: String, - pub config: SignerConfig, -} - -/// Storage model for direct serialization/deserialization -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SignerRepoModelStorage { pub id: String, pub config: SignerConfigStorage, } -/// Storage-optimized configuration for signers #[derive(Debug, Clone, Serialize, Deserialize)] pub enum SignerConfigStorage { Local(LocalSignerConfigStorage), @@ -76,12 +48,28 @@ pub enum SignerConfigStorage { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalSignerConfigStorage { #[serde( - serialize_with = "serialize_secret_base64", - deserialize_with = "deserialize_secret_base64" + serialize_with = "serialize_secret_vec", + deserialize_with = "deserialize_secret_vec" )] pub raw_key: SecretVec, } +impl From for LocalSignerConfigStorage { + fn from(config: LocalSignerConfig) -> Self { + Self { + raw_key: config.raw_key, + } + } +} + +impl From for LocalSignerConfig { + fn from(storage: LocalSignerConfigStorage) -> Self { + Self { + raw_key: storage.raw_key, + } + } +} + /// Storage representations for other signer types (these are simpler as they don't contain secrets that need encoding) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AwsKmsSignerConfigStorage { @@ -93,8 +81,16 @@ pub struct AwsKmsSignerConfigStorage { pub struct VaultSignerConfigStorage { pub address: String, pub namespace: Option, - pub role_id: String, // Stored as string for simplicity - pub secret_id: String, // Stored as string for simplicity + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + pub role_id: SecretString, + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + pub secret_id: SecretString, pub key_name: String, pub mount_point: Option, } @@ -104,8 +100,16 @@ pub struct VaultTransitSignerConfigStorage { pub key_name: String, pub address: String, pub namespace: Option, - pub role_id: String, // Stored as string for simplicity - pub secret_id: String, // Stored as string for simplicity + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + pub role_id: SecretString, + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + pub secret_id: SecretString, pub pubkey: String, pub mount_point: Option, } @@ -113,7 +117,11 @@ pub struct VaultTransitSignerConfigStorage { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TurnkeySignerConfigStorage { pub api_public_key: String, - pub api_private_key: String, // Stored as string for simplicity + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + pub api_private_key: SecretString, pub organization_id: String, pub private_key_id: String, pub public_key: String, @@ -121,10 +129,22 @@ pub struct TurnkeySignerConfigStorage { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GoogleCloudKmsSignerServiceAccountConfigStorage { - pub private_key: String, // Stored as string for simplicity - pub private_key_id: String, // Stored as string for simplicity + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + pub private_key: SecretString, + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + pub private_key_id: SecretString, pub project_id: String, - pub client_email: String, // Stored as string for simplicity + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + pub client_email: SecretString, pub client_id: String, pub auth_uri: String, pub token_uri: String, @@ -152,85 +172,17 @@ impl From for SignerRepoModel { fn from(signer: Signer) -> Self { Self { id: signer.id, - config: signer.config, + config: signer.config.into(), } } } -/// Convert repository model to storage model -impl From for SignerRepoModelStorage { - fn from(model: SignerRepoModel) -> Self { - Self { - id: model.id, - config: model.config.into(), - } - } -} - -/// Convert storage model back to repository model -impl From for SignerRepoModel { - fn from(storage_model: SignerRepoModelStorage) -> Self { - Self { - id: storage_model.id, - config: storage_model.config.into(), - } - } -} - -/// Convert from repository model to domain model +/// Convert from repository model to domain model impl From for Signer { fn from(repo_model: SignerRepoModel) -> Self { Self { id: repo_model.id, - config: repo_model.config, - } - } -} - -/// Convert domain config to storage config -impl From for SignerConfigStorage { - fn from(config: SignerConfig) -> Self { - match config { - SignerConfig::Local(local) => SignerConfigStorage::Local(local.into()), - SignerConfig::AwsKms(aws) => SignerConfigStorage::AwsKms(aws.into()), - SignerConfig::Vault(vault) => SignerConfigStorage::Vault(vault.into()), - SignerConfig::VaultTransit(vault_transit) => { - SignerConfigStorage::VaultTransit(vault_transit.into()) - } - SignerConfig::Turnkey(turnkey) => SignerConfigStorage::Turnkey(turnkey.into()), - SignerConfig::GoogleCloudKms(gcp) => SignerConfigStorage::GoogleCloudKms(gcp.into()), - } - } -} - -/// Convert storage config to domain config -impl From for SignerConfig { - fn from(storage: SignerConfigStorage) -> Self { - match storage { - SignerConfigStorage::Local(local) => SignerConfig::Local(local.into()), - SignerConfigStorage::AwsKms(aws) => SignerConfig::AwsKms(aws.into()), - SignerConfigStorage::Vault(vault) => SignerConfig::Vault(vault.into()), - SignerConfigStorage::VaultTransit(vault_transit) => { - SignerConfig::VaultTransit(vault_transit.into()) - } - SignerConfigStorage::Turnkey(turnkey) => SignerConfig::Turnkey(turnkey.into()), - SignerConfigStorage::GoogleCloudKms(gcp) => SignerConfig::GoogleCloudKms(gcp.into()), - } - } -} - -impl From for LocalSignerConfigStorage { - fn from(config: LocalSignerConfig) -> Self { - Self { - raw_key: config.raw_key, - } - } -} - -impl From for LocalSignerConfig { - fn from(storage: LocalSignerConfigStorage) -> Self { - Self { - raw_key: storage.raw_key, + config: repo_model.config.into(), } } } @@ -258,8 +210,8 @@ impl From for VaultSignerConfigStorage { Self { address: config.address, namespace: config.namespace, - role_id: config.role_id.to_str().to_string(), - secret_id: config.secret_id.to_str().to_string(), + role_id: config.role_id, + secret_id: config.secret_id, key_name: config.key_name, mount_point: config.mount_point, } @@ -271,8 +223,8 @@ impl From for VaultSignerConfig { Self { address: storage.address, namespace: storage.namespace, - role_id: SecretString::new(&storage.role_id), - secret_id: SecretString::new(&storage.secret_id), + role_id: storage.role_id, + secret_id: storage.secret_id, key_name: storage.key_name, mount_point: storage.mount_point, } @@ -285,8 +237,8 @@ impl From for VaultTransitSignerConfigStorage { key_name: config.key_name, address: config.address, namespace: config.namespace, - role_id: config.role_id.to_str().to_string(), - secret_id: config.secret_id.to_str().to_string(), + role_id: config.role_id, + secret_id: config.secret_id, pubkey: config.pubkey, mount_point: config.mount_point, } @@ -299,8 +251,8 @@ impl From for VaultTransitSignerConfig { key_name: storage.key_name, address: storage.address, namespace: storage.namespace, - role_id: SecretString::new(&storage.role_id), - secret_id: SecretString::new(&storage.secret_id), + role_id: storage.role_id, + secret_id: storage.secret_id, pubkey: storage.pubkey, mount_point: storage.mount_point, } @@ -311,7 +263,7 @@ impl From for TurnkeySignerConfigStorage { fn from(config: TurnkeySignerConfig) -> Self { Self { api_public_key: config.api_public_key, - api_private_key: config.api_private_key.to_str().to_string(), + api_private_key: config.api_private_key, organization_id: config.organization_id, private_key_id: config.private_key_id, public_key: config.public_key, @@ -323,7 +275,7 @@ impl From for TurnkeySignerConfig { fn from(storage: TurnkeySignerConfigStorage) -> Self { Self { api_public_key: storage.api_public_key, - api_private_key: SecretString::new(&storage.api_private_key), + api_private_key: storage.api_private_key, organization_id: storage.organization_id, private_key_id: storage.private_key_id, public_key: storage.public_key, @@ -354,10 +306,10 @@ impl From { fn from(config: GoogleCloudKmsSignerServiceAccountConfig) -> Self { Self { - private_key: config.private_key.to_str().to_string(), - private_key_id: config.private_key_id.to_str().to_string(), + private_key: config.private_key, + private_key_id: config.private_key_id, project_id: config.project_id, - client_email: config.client_email.to_str().to_string(), + client_email: config.client_email, client_id: config.client_id, auth_uri: config.auth_uri, token_uri: config.token_uri, @@ -373,10 +325,10 @@ impl From { fn from(storage: GoogleCloudKmsSignerServiceAccountConfigStorage) -> Self { Self { - private_key: SecretString::new(&storage.private_key), - private_key_id: SecretString::new(&storage.private_key_id), + private_key: storage.private_key, + private_key_id: storage.private_key_id, project_id: storage.project_id, - client_email: SecretString::new(&storage.client_email), + client_email: storage.client_email, client_id: storage.client_id, auth_uri: storage.auth_uri, token_uri: storage.token_uri, @@ -417,6 +369,86 @@ impl SignerRepoModel { } } +impl From for SignerConfigStorage { + fn from(config: SignerConfig) -> Self { + match config { + SignerConfig::Local(local) => SignerConfigStorage::Local(local.into()), + SignerConfig::Vault(vault) => SignerConfigStorage::Vault(vault.into()), + SignerConfig::VaultTransit(vault_transit) => { + SignerConfigStorage::VaultTransit(vault_transit.into()) + } + SignerConfig::AwsKms(aws_kms) => SignerConfigStorage::AwsKms(aws_kms.into()), + SignerConfig::Turnkey(turnkey) => SignerConfigStorage::Turnkey(turnkey.into()), + SignerConfig::GoogleCloudKms(gcp) => SignerConfigStorage::GoogleCloudKms(gcp.into()), + } + } +} + +impl From for SignerConfig { + fn from(storage: SignerConfigStorage) -> Self { + match storage { + SignerConfigStorage::Local(local) => SignerConfig::Local(local.into()), + SignerConfigStorage::Vault(vault) => SignerConfig::Vault(vault.into()), + SignerConfigStorage::VaultTransit(vault_transit) => { + SignerConfig::VaultTransit(vault_transit.into()) + } + SignerConfigStorage::AwsKms(aws_kms) => SignerConfig::AwsKms(aws_kms.into()), + SignerConfigStorage::Turnkey(turnkey) => SignerConfig::Turnkey(turnkey.into()), + SignerConfigStorage::GoogleCloudKms(gcp) => SignerConfig::GoogleCloudKms(gcp.into()), + } + } +} + +impl SignerConfigStorage { + /// Get local signer config, returns error if not a local signer + pub fn get_local(&self) -> Option<&LocalSignerConfigStorage> { + match self { + Self::Local(config) => Some(config), + _ => None, + } + } + + /// Get vault transit signer config, returns error if not a vault transit signer + pub fn get_vault_transit(&self) -> Option<&VaultTransitSignerConfigStorage> { + match self { + Self::VaultTransit(config) => Some(config), + _ => None, + } + } + + /// Get vault signer config, returns error if not a vault signer + pub fn get_vault(&self) -> Option<&VaultSignerConfigStorage> { + match self { + Self::Vault(config) => Some(config), + _ => None, + } + } + + /// Get turnkey signer config, returns error if not a turnkey signer + pub fn get_turnkey(&self) -> Option<&TurnkeySignerConfigStorage> { + match self { + Self::Turnkey(config) => Some(config), + _ => None, + } + } + + /// Get google cloud kms signer config, returns error if not a google cloud kms signer + pub fn get_google_cloud_kms(&self) -> Option<&GoogleCloudKmsSignerConfigStorage> { + match self { + Self::GoogleCloudKms(config) => Some(config), + _ => None, + } + } + + /// Get aws kms signer config, returns error if not an aws kms signer + pub fn get_aws_kms(&self) -> Option<&AwsKmsSignerConfigStorage> { + match self { + Self::AwsKms(config) => Some(config), + _ => None, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -434,21 +466,21 @@ mod tests { let repo_model = SignerRepoModel::from(core); assert_eq!(repo_model.id, "test-id"); - assert!(matches!(repo_model.config, SignerConfig::Local(_))); + assert!(matches!(repo_model.config, SignerConfigStorage::Local(_))); } #[test] fn test_to_core_signer() { - use crate::models::signer::AwsKmsSignerConfig; + use crate::models::signer::AwsKmsSignerConfigStorage; - let domain_config = AwsKmsSignerConfig { + let domain_config = AwsKmsSignerConfigStorage { region: Some("us-east-1".to_string()), key_id: "test-key".to_string(), }; let repo_model = SignerRepoModel { id: "test-id".to_string(), - config: SignerConfig::AwsKms(domain_config), + config: SignerConfigStorage::AwsKms(domain_config), }; let core = Signer::from(repo_model); @@ -461,13 +493,17 @@ mod tests { #[test] fn test_validation() { + use secrets::SecretVec; + let domain_config = LocalSignerConfig { raw_key: SecretVec::new(32, |v| v.fill(1)), }; + // Convert to storage config properly + let storage_config = LocalSignerConfigStorage::from(domain_config); let repo_model = SignerRepoModel { id: "test-id".to_string(), - config: SignerConfig::Local(domain_config), + config: SignerConfigStorage::Local(storage_config), }; assert!(repo_model.validate().is_ok()); diff --git a/src/models/signer/response.rs b/src/models/signer/response.rs index c9360a74b..5e195e9d8 100644 --- a/src/models/signer/response.rs +++ b/src/models/signer/response.rs @@ -164,14 +164,14 @@ impl From for SignerResponse { #[cfg(test)] mod tests { use super::*; - use crate::models::{LocalSignerConfig, SignerConfig}; + use crate::models::{LocalSignerConfigStorage, SignerConfigStorage}; use secrets::SecretVec; #[test] fn test_signer_response_from_repo_model() { let repo_model = SignerRepoModel { id: "test-signer".to_string(), - config: SignerConfig::Local(LocalSignerConfig { + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), }; @@ -214,14 +214,14 @@ mod tests { fn test_signer_type_mapping_from_config() { let test_cases = vec![ ( - SignerConfig::Local(LocalSignerConfig { + SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), SignerType::Local, SignerConfigResponse::Plain {}, ), ( - SignerConfig::AwsKms(crate::models::AwsKmsSignerConfig { + SignerConfigStorage::AwsKms(crate::models::AwsKmsSignerConfigStorage { region: Some("us-east-1".to_string()), key_id: "test-key".to_string(), }), diff --git a/src/repositories/signer/mod.rs b/src/repositories/signer/mod.rs index 9d7a71bd4..cbe478922 100644 --- a/src/repositories/signer/mod.rs +++ b/src/repositories/signer/mod.rs @@ -136,13 +136,13 @@ impl Repository for SignerRepositoryStorage { #[cfg(test)] mod tests { use super::*; - use crate::models::{LocalSignerConfig, SignerConfig}; + use crate::models::{LocalSignerConfigStorage, SignerConfigStorage}; use secrets::SecretVec; fn create_local_signer(id: String) -> SignerRepoModel { SignerRepoModel { id: id.clone(), - config: SignerConfig::Local(LocalSignerConfig { + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), } @@ -235,7 +235,7 @@ mod tests { // Update with different config let updated_signer = SignerRepoModel { id: "update-test".to_string(), - config: SignerConfig::Local(LocalSignerConfig { + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[2; 32])), }), }; @@ -248,7 +248,7 @@ mod tests { // Test updating non-existent signer let non_existent_signer = SignerRepoModel { id: "non-existent".to_string(), - config: SignerConfig::Local(LocalSignerConfig { + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[3; 32])), }), }; diff --git a/src/repositories/signer/signer_in_memory.rs b/src/repositories/signer/signer_in_memory.rs index 4514f3fd0..21cdee090 100644 --- a/src/repositories/signer/signer_in_memory.rs +++ b/src/repositories/signer/signer_in_memory.rs @@ -161,14 +161,14 @@ impl Repository for InMemorySignerRepository { mod tests { use secrets::SecretVec; - use crate::models::{LocalSignerConfig, SignerConfig}; + use crate::models::{LocalSignerConfigStorage, SignerConfigStorage}; use super::*; fn create_test_signer(id: String) -> SignerRepoModel { SignerRepoModel { id: id.clone(), - config: SignerConfig::Local(LocalSignerConfig { + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::zero(0), }), } @@ -203,7 +203,7 @@ mod tests { // Update the signer let updated_signer = SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Local(LocalSignerConfig { + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[2; 32])), }), }; diff --git a/src/repositories/signer/signer_redis.rs b/src/repositories/signer/signer_redis.rs index f5e894a05..f4f091ca2 100644 --- a/src/repositories/signer/signer_redis.rs +++ b/src/repositories/signer/signer_redis.rs @@ -1,6 +1,6 @@ //! Redis-backed implementation of the signer repository. -use crate::models::{RepositoryError, SignerRepoModel, SignerRepoModelStorage}; +use crate::models::{RepositoryError, SignerRepoModel}; use crate::repositories::redis_base::RedisRepository; use crate::repositories::*; use async_trait::async_trait; @@ -115,10 +115,8 @@ impl RedisSignerRepository { for (i, value) in values.into_iter().enumerate() { match value { Some(json) => { - match self - .deserialize_entity::(&json, &ids[i], "signer") - { - Ok(signer) => signers.push(SignerRepoModel::from(signer)), + match self.deserialize_entity::(&json, &ids[i], "signer") { + Ok(signer) => signers.push(signer), Err(e) => { failed_count += 1; error!("Failed to deserialize signer {}: {}", ids[i], e); @@ -190,11 +188,8 @@ impl Repository for RedisSignerRepository { } } - // Convert to storage model in order to serialize key properly - let signer_storage: SignerRepoModelStorage = signer.clone().into(); - - // Serialize signer - let serialized = self.serialize_entity(&signer_storage, |s| &s.id, "signer")?; + // Serialize signer (encryption happens automatically for human-readable formats) + let serialized = self.serialize_entity(&signer, |s| &s.id, "signer")?; // Store signer let result: Result<(), RedisError> = conn.set(&key, &serialized).await; @@ -223,9 +218,8 @@ impl Repository for RedisSignerRepository { let result: Result, RedisError> = conn.get(&key).await; match result { Ok(Some(data)) => { - let signer_storage = - self.deserialize_entity::(&data, &id, "signer")?; - let signer = SignerRepoModel::from(signer_storage); + // Deserialize signer (decryption happens automatically) + let signer = self.deserialize_entity::(&data, &id, "signer")?; debug!("Retrieved signer with ID: {}", id); Ok(signer) } @@ -287,7 +281,7 @@ impl Repository for RedisSignerRepository { } } - // Serialize signer + // Serialize signer (encryption happens automatically for human-readable formats) let serialized = self.serialize_entity(&signer, |s| &s.id, "signer")?; // Update signer @@ -474,14 +468,14 @@ impl Repository for RedisSignerRepository { #[cfg(test)] mod tests { use super::*; - use crate::models::{LocalSignerConfig, SignerConfig}; + use crate::models::{LocalSignerConfigStorage, SignerConfigStorage}; use secrets::SecretVec; use std::sync::Arc; fn create_local_signer(id: &str) -> SignerRepoModel { SignerRepoModel { id: id.to_string(), - config: SignerConfig::Local(LocalSignerConfig { + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), } @@ -545,8 +539,8 @@ mod tests { .unwrap(); assert_eq!(signer.id, deserialized.id); - assert!(matches!(signer.config, SignerConfig::Local(_))); - assert!(matches!(deserialized.config, SignerConfig::Local(_))); + assert!(matches!(signer.config, SignerConfigStorage::Local(_))); + assert!(matches!(deserialized.config, SignerConfigStorage::Local(_))); } #[tokio::test] @@ -576,7 +570,7 @@ mod tests { // Get the signer let retrieved = repo.get_by_id(signer_name.clone()).await.unwrap(); assert_eq!(retrieved.id, signer.id); - assert!(matches!(retrieved.config, SignerConfig::Local(_))); + assert!(matches!(retrieved.config, SignerConfigStorage::Local(_))); } #[tokio::test] @@ -602,7 +596,7 @@ mod tests { // Update the signer let updated_signer = SignerRepoModel { id: signer_name.clone(), - config: SignerConfig::Local(LocalSignerConfig { + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[2; 32])), }), }; @@ -612,7 +606,7 @@ mod tests { // Verify the update let retrieved = repo.get_by_id(signer_name).await.unwrap(); - assert!(matches!(retrieved.config, SignerConfig::Local(_))); + assert!(matches!(retrieved.config, SignerConfigStorage::Local(_))); } #[tokio::test] @@ -750,7 +744,7 @@ mod tests { let repo = setup_test_repo().await; let signer = SignerRepoModel { id: "".to_string(), - config: SignerConfig::Local(LocalSignerConfig { + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key: SecretVec::new(32, |v| v.copy_from_slice(&[1; 32])), }), }; diff --git a/src/services/google_cloud_kms/mod.rs b/src/services/google_cloud_kms/mod.rs index fce67fcef..3fcba6183 100644 --- a/src/services/google_cloud_kms/mod.rs +++ b/src/services/google_cloud_kms/mod.rs @@ -405,8 +405,7 @@ impl From for GoogleCloudKmsError { mod tests { use super::*; use crate::models::{ - GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, - GoogleCloudKmsSignerServiceAccountConfig, SecretString, + GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, SecretString, }; use alloy::primitives::utils::eip191_message; use serde_json::json; diff --git a/src/services/signer/evm/local_signer.rs b/src/services/signer/evm/local_signer.rs index de2ff0d38..111292d84 100644 --- a/src/services/signer/evm/local_signer.rs +++ b/src/services/signer/evm/local_signer.rs @@ -34,7 +34,8 @@ use crate::{ }, models::{ Address, EvmTransactionData, EvmTransactionDataSignature, EvmTransactionDataTrait, - NetworkTransactionData, SignerError, SignerRepoModel, SignerType, TransactionRepoModel, + NetworkTransactionData, Signer as SignerDomainModel, SignerError, SignerRepoModel, + SignerType, TransactionRepoModel, }, services::Signer, }; @@ -49,7 +50,7 @@ pub struct LocalSigner { } impl LocalSigner { - pub fn new(signer_model: &SignerRepoModel) -> Result { + pub fn new(signer_model: &SignerDomainModel) -> Result { let config = signer_model .config .get_local() @@ -182,10 +183,10 @@ mod tests { use super::*; use std::str::FromStr; - fn create_test_signer_model() -> SignerRepoModel { + fn create_test_signer_model() -> SignerDomainModel { let seed = vec![1u8; 32]; let raw_key = SecretVec::new(32, |v| v.copy_from_slice(&seed)); - SignerRepoModel { + SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key }), } diff --git a/src/services/signer/evm/mod.rs b/src/services/signer/evm/mod.rs index 1350e3c87..cbdadf60b 100644 --- a/src/services/signer/evm/mod.rs +++ b/src/services/signer/evm/mod.rs @@ -34,8 +34,8 @@ use crate::{ SignTypedDataRequest, }, models::{ - Address, NetworkTransactionData, SignerConfig, SignerRepoModel, SignerType, - TransactionRepoModel, VaultSignerConfig, + Address, NetworkTransactionData, Signer as SignerDomainModel, SignerConfig, + SignerRepoModel, SignerType, TransactionRepoModel, VaultSignerConfig, }, services::{ signer::Signer, @@ -124,7 +124,7 @@ pub struct EvmSignerFactory; impl EvmSignerFactory { pub async fn create_evm_signer( - signer_model: SignerRepoModel, + signer_model: SignerDomainModel, ) -> Result { let signer = match &signer_model.config { SignerConfig::Local(_) => EvmSigner::Local(LocalSigner::new(&signer_model)?), @@ -208,7 +208,7 @@ mod tests { #[tokio::test] async fn test_create_evm_signer_local() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), @@ -224,7 +224,7 @@ mod tests { #[tokio::test] async fn test_create_evm_signer_test() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), @@ -240,7 +240,7 @@ mod tests { #[tokio::test] async fn test_create_evm_signer_vault() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Vault(VaultSignerConfig { address: "https://vault.test.com".to_string(), @@ -261,7 +261,7 @@ mod tests { #[tokio::test] async fn test_create_evm_signer_aws_kms() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::AwsKms(AwsKmsSignerConfig { region: Some("us-east-1".to_string()), @@ -278,7 +278,7 @@ mod tests { #[tokio::test] async fn test_create_evm_signer_vault_transit() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::VaultTransit(VaultTransitSignerConfig { key_name: "test".to_string(), @@ -301,7 +301,7 @@ mod tests { #[tokio::test] async fn test_create_evm_signer_turnkey() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Turnkey(TurnkeySignerConfig { api_private_key: SecretString::new("api_private_key"), @@ -325,7 +325,7 @@ mod tests { #[tokio::test] async fn test_address_evm_signer_local() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), @@ -342,7 +342,7 @@ mod tests { #[tokio::test] async fn test_address_evm_signer_test() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), @@ -359,7 +359,7 @@ mod tests { #[tokio::test] async fn test_address_evm_signer_turnkey() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Turnkey(TurnkeySignerConfig { api_private_key: SecretString::new("api_private_key"), @@ -383,7 +383,7 @@ mod tests { #[tokio::test] async fn test_sign_data_evm_signer_local() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), @@ -414,7 +414,7 @@ mod tests { #[tokio::test] async fn test_sign_transaction_evm() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), @@ -459,7 +459,7 @@ mod tests { #[tokio::test] async fn test_create_evm_signer_google_cloud_kms() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { service_account: GoogleCloudKmsSignerServiceAccountConfig { @@ -491,7 +491,7 @@ mod tests { #[tokio::test] async fn test_sign_data_with_different_message_types() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), diff --git a/src/services/signer/evm/vault_signer.rs b/src/services/signer/evm/vault_signer.rs index 37ebd3a46..fcc2b798f 100644 --- a/src/services/signer/evm/vault_signer.rs +++ b/src/services/signer/evm/vault_signer.rs @@ -18,7 +18,10 @@ use crate::{ SignDataRequest, SignDataResponse, SignDataResponseEvm, SignTransactionResponse, SignTypedDataRequest, }, - models::{Address, NetworkTransactionData, SignerError, SignerRepoModel, VaultSignerConfig}, + models::{ + Address, NetworkTransactionData, Signer as SignerDomainModel, SignerError, SignerRepoModel, + VaultSignerConfig, + }, services::{ signer::evm::{local_signer::LocalSigner, DataSignerTrait}, vault::{VaultService, VaultServiceTrait}, @@ -131,7 +134,7 @@ impl VaultSigner { async fn load_signer_from_vault(&self) -> Result { let raw_key = self.fetch_private_key().await?; let local_config = crate::models::LocalSignerConfig { raw_key }; - let local_model = SignerRepoModel { + let local_model = SignerDomainModel { id: self.key_name.clone(), config: crate::models::SignerConfig::Local(local_config), }; diff --git a/src/services/signer/mod.rs b/src/services/signer/mod.rs index 36d3eef2f..1966d9240 100644 --- a/src/services/signer/mod.rs +++ b/src/services/signer/mod.rs @@ -41,8 +41,8 @@ pub use stellar::*; use crate::{ domain::{SignDataRequest, SignDataResponse, SignTransactionResponse, SignTypedDataRequest}, models::{ - Address, NetworkTransactionData, NetworkType, SignerError, SignerFactoryError, - SignerRepoModel, SignerType, TransactionError, TransactionRepoModel, + Address, NetworkTransactionData, NetworkType, Signer as SignerDomainModel, SignerError, + SignerFactoryError, SignerType, TransactionError, TransactionRepoModel, }, }; @@ -134,7 +134,7 @@ pub struct SignerFactory; impl SignerFactory { pub async fn create_signer( network_type: &NetworkType, - signer_model: &SignerRepoModel, + signer_model: &SignerDomainModel, ) -> Result { let signer = match network_type { NetworkType::Evm => { diff --git a/src/services/signer/solana/local_signer.rs b/src/services/signer/solana/local_signer.rs index 57d57a3c9..e7d1062ba 100644 --- a/src/services/signer/solana/local_signer.rs +++ b/src/services/signer/solana/local_signer.rs @@ -23,7 +23,10 @@ use crate::{ SignDataRequest, SignDataResponse, SignDataResponseEvm, SignTransactionResponse, SignTypedDataRequest, }, - models::{Address, NetworkTransactionData, SignerError, SignerRepoModel, TransactionRepoModel}, + models::{ + Address, NetworkTransactionData, Signer as SignerDomainModel, SignerError, + TransactionRepoModel, + }, services::Signer, }; @@ -34,7 +37,7 @@ pub struct LocalSigner { } impl LocalSigner { - pub fn new(signer_model: &SignerRepoModel) -> Result { + pub fn new(signer_model: &SignerDomainModel) -> Result { let config = signer_model .config .get_local() @@ -86,7 +89,10 @@ impl Signer for LocalSigner { #[cfg(test)] mod tests { use crate::{ - models::{LocalSignerConfig, SignerConfig, SignerType, SolanaTransactionData}, + models::{ + LocalSignerConfig, Signer as SignerDomainModel, SignerConfig, SignerType, + SolanaTransactionData, + }, services::Signer, }; @@ -101,7 +107,7 @@ mod tests { } fn create_testing_signer() -> LocalSigner { - let model = SignerRepoModel { + let model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: valid_seed(), @@ -122,7 +128,7 @@ mod tests { fn test_new_local_signer_invalid_keypair() { let seed = vec![1u8; 10]; let raw_key = SecretVec::new(10, |v| v.copy_from_slice(&seed)); - let model = SignerRepoModel { + let model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key }), }; @@ -180,7 +186,7 @@ mod tests { #[tokio::test] async fn test_pubkey_matches_keypair_pubkey() { let seed = valid_seed(); - let model = SignerRepoModel { + let model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: seed.clone(), diff --git a/src/services/signer/solana/mod.rs b/src/services/signer/solana/mod.rs index 7125e0a13..8515a0366 100644 --- a/src/services/signer/solana/mod.rs +++ b/src/services/signer/solana/mod.rs @@ -38,8 +38,8 @@ use crate::{ SignTypedDataRequest, }, models::{ - Address, NetworkTransactionData, SignerConfig, SignerRepoModel, SignerType, - TransactionRepoModel, VaultSignerConfig, + Address, NetworkTransactionData, Signer as SignerDomainModel, SignerConfig, + SignerRepoModel, SignerType, TransactionRepoModel, VaultSignerConfig, }, services::{GoogleCloudKmsService, TurnkeyService, VaultConfig, VaultService}, }; @@ -132,7 +132,7 @@ pub struct SolanaSignerFactory; impl SolanaSignerFactory { pub fn create_solana_signer( - signer_model: &SignerRepoModel, + signer_model: &SignerDomainModel, ) -> Result { let signer = match &signer_model.config { SignerConfig::Local(_) => SolanaSigner::Local(LocalSigner::new(signer_model)?), @@ -209,7 +209,8 @@ mod solana_signer_factory_tests { use crate::models::{ AwsKmsSignerConfig, GoogleCloudKmsSignerConfig, GoogleCloudKmsSignerKeyConfig, GoogleCloudKmsSignerServiceAccountConfig, LocalSignerConfig, SecretString, SignerConfig, - SignerRepoModel, SolanaTransactionData, TurnkeySignerConfig, VaultTransitSignerConfig, + SignerRepoModel, SolanaTransactionData, TurnkeySignerConfig, VaultSignerConfig, + VaultTransitSignerConfig, }; use mockall::predicate::*; use secrets::SecretVec; @@ -229,7 +230,7 @@ mod solana_signer_factory_tests { #[test] fn test_create_solana_signer_local() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), @@ -246,7 +247,7 @@ mod solana_signer_factory_tests { #[test] fn test_create_solana_signer_test() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), @@ -263,7 +264,7 @@ mod solana_signer_factory_tests { #[test] fn test_create_solana_signer_vault() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Vault(VaultSignerConfig { address: "https://vault.test.com".to_string(), @@ -285,7 +286,7 @@ mod solana_signer_factory_tests { #[test] fn test_create_solana_signer_vault_transit() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::VaultTransit(VaultTransitSignerConfig { key_name: "test".to_string(), @@ -308,7 +309,7 @@ mod solana_signer_factory_tests { #[test] fn test_create_solana_signer_turnkey() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Turnkey(TurnkeySignerConfig { api_private_key: SecretString::new("api_private_key"), @@ -329,7 +330,7 @@ mod solana_signer_factory_tests { #[tokio::test] async fn test_create_solana_signer_google_cloud_kms() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { service_account: GoogleCloudKmsSignerServiceAccountConfig { @@ -363,7 +364,7 @@ mod solana_signer_factory_tests { #[tokio::test] async fn test_address_solana_signer_local() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), @@ -380,7 +381,7 @@ mod solana_signer_factory_tests { #[tokio::test] async fn test_address_solana_signer_vault_transit() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::VaultTransit(VaultTransitSignerConfig { key_name: "test".to_string(), @@ -405,7 +406,7 @@ mod solana_signer_factory_tests { #[tokio::test] async fn test_address_solana_signer_turnkey() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Turnkey(TurnkeySignerConfig { api_private_key: SecretString::new("api_private_key"), @@ -429,7 +430,7 @@ mod solana_signer_factory_tests { #[tokio::test] async fn test_address_solana_signer_google_cloud_kms() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::GoogleCloudKms(GoogleCloudKmsSignerConfig { service_account: GoogleCloudKmsSignerServiceAccountConfig { @@ -464,7 +465,7 @@ mod solana_signer_factory_tests { #[tokio::test] async fn test_sign_solana_signer_local() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), @@ -480,7 +481,7 @@ mod solana_signer_factory_tests { #[tokio::test] async fn test_sign_solana_signer_test() { - let signer_model = SignerRepoModel { + let signer_model = SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key: test_key_bytes(), diff --git a/src/services/signer/solana/vault_signer.rs b/src/services/signer/solana/vault_signer.rs index fe85fc9c3..003141300 100644 --- a/src/services/signer/solana/vault_signer.rs +++ b/src/services/signer/solana/vault_signer.rs @@ -14,10 +14,14 @@ use std::sync::Arc; use tokio::sync::{Mutex, RwLock}; use zeroize::Zeroizing; +use crate::models::{LocalSignerConfig, SignerConfig}; use crate::services::SolanaSignTrait; use crate::{ domain::{SignDataRequest, SignDataResponse, SignTransactionResponse, SignTypedDataRequest}, - models::{Address, NetworkTransactionData, SignerError, SignerRepoModel, VaultSignerConfig}, + models::{ + Address, NetworkTransactionData, Signer as SignerDomainModel, SignerError, + VaultSignerConfig, + }, services::{ signer::solana::local_signer::LocalSigner, vault::{VaultService, VaultServiceTrait}, @@ -129,10 +133,10 @@ impl VaultSigner { /// Loads a new signer from vault async fn load_signer_from_vault(&self) -> Result { let raw_key = self.fetch_private_key().await?; - let local_config = crate::models::LocalSignerConfig { raw_key }; - let local_model = SignerRepoModel { + let local_config = LocalSignerConfig { raw_key }; + let local_model = SignerDomainModel { id: self.key_name.clone(), - config: crate::models::SignerConfig::Local(local_config), + config: SignerConfig::Local(local_config), }; LocalSigner::new(&local_model) @@ -231,7 +235,9 @@ impl SolanaSignTrait for VaultSigner { #[cfg(test)] mod tests { use super::*; - use crate::models::{SecretString, SignerConfig, VaultSignerConfig}; + use crate::models::{ + SecretString, Signer as SignerDomainModel, SignerConfigStorage, VaultSignerConfig, + }; use crate::services::vault::VaultError; use async_trait::async_trait; diff --git a/src/services/signer/solana/vault_transit_signer.rs b/src/services/signer/solana/vault_transit_signer.rs index bbfefc600..56fa09633 100644 --- a/src/services/signer/solana/vault_transit_signer.rs +++ b/src/services/signer/solana/vault_transit_signer.rs @@ -25,7 +25,10 @@ use crate::{ SignDataRequest, SignDataResponse, SignDataResponseEvm, SignTransactionResponse, SignTypedDataRequest, }, - models::{Address, NetworkTransactionData, SignerError, SignerRepoModel, TransactionRepoModel}, + models::{ + Address, NetworkTransactionData, Signer as SignerDomainModel, SignerError, + TransactionRepoModel, + }, services::{Signer, VaultConfig, VaultService, VaultServiceTrait}, utils::{base64_decode, base64_encode}, }; @@ -44,7 +47,7 @@ where } impl VaultTransitSigner { - pub fn new(signer_model: &SignerRepoModel, vault_service: DefaultVaultService) -> Self { + pub fn new(signer_model: &SignerDomainModel, vault_service: DefaultVaultService) -> Self { let config = signer_model .config .get_vault_transit() @@ -60,7 +63,7 @@ impl VaultTransitSigner { #[cfg(test)] impl VaultTransitSigner { - pub fn new_with_service(signer_model: &SignerRepoModel, vault_service: T) -> Self { + pub fn new_with_service(signer_model: &SignerDomainModel, vault_service: T) -> Self { let config = signer_model .config .get_vault_transit() @@ -136,13 +139,16 @@ impl Signer for VaultTransitSigner { mod tests { use super::*; use crate::{ - models::{SecretString, SignerConfig, SolanaTransactionData, VaultTransitSignerConfig}, + models::{ + SecretString, Signer as SignerDomainModel, SignerConfig, SolanaTransactionData, + VaultTransitSignerConfig, + }, services::{vault::VaultError, MockVaultServiceTrait}, }; use mockall::predicate::*; - fn create_test_signer_model() -> SignerRepoModel { - SignerRepoModel { + fn create_test_signer_model() -> SignerDomainModel { + SignerDomainModel { id: "test-vault-transit-signer".to_string(), config: SignerConfig::VaultTransit(VaultTransitSignerConfig { key_name: "transit-key".to_string(), diff --git a/src/services/signer/stellar/local_signer.rs b/src/services/signer/stellar/local_signer.rs index 362005b6a..6aaa6fe35 100644 --- a/src/services/signer/stellar/local_signer.rs +++ b/src/services/signer/stellar/local_signer.rs @@ -17,7 +17,9 @@ use crate::{ SignDataRequest, SignDataResponse, SignTransactionResponse, SignTransactionResponseStellar, SignTypedDataRequest, }, - models::{Address, NetworkTransactionData, SignerError, SignerRepoModel, TransactionInput}, + models::{ + Address, NetworkTransactionData, Signer as SignerDomainModel, SignerError, TransactionInput, + }, services::Signer, }; use async_trait::async_trait; @@ -39,7 +41,7 @@ pub struct LocalSigner { } impl LocalSigner { - pub fn new(signer_model: &SignerRepoModel) -> Result { + pub fn new(signer_model: &SignerDomainModel) -> Result { let config = signer_model .config .get_local() @@ -187,14 +189,15 @@ impl Signer for LocalSigner { mod tests { use super::*; use crate::models::{ - EvmTransactionData, LocalSignerConfig, SignerConfig, StellarTransactionData, + EvmTransactionData, LocalSignerConfig, Signer as SignerDomainModel, SignerConfig, + StellarTransactionData, }; use secrets::SecretVec; - fn create_test_signer_model() -> SignerRepoModel { + fn create_test_signer_model() -> SignerDomainModel { let seed = vec![1u8; 32]; let raw_key = SecretVec::new(32, |v| v.copy_from_slice(&seed)); - SignerRepoModel { + SignerDomainModel { id: "test".to_string(), config: SignerConfig::Local(LocalSignerConfig { raw_key }), } diff --git a/src/services/signer/stellar/mod.rs b/src/services/signer/stellar/mod.rs index 905581c80..be9c42066 100644 --- a/src/services/signer/stellar/mod.rs +++ b/src/services/signer/stellar/mod.rs @@ -10,7 +10,10 @@ use vault_signer::*; use crate::{ domain::{SignDataRequest, SignDataResponse, SignTransactionResponse, SignTypedDataRequest}, - models::{Address, NetworkTransactionData, SignerConfig, SignerRepoModel}, + models::{ + Address, NetworkTransactionData, Signer as SignerDomainModel, SignerConfig, + SignerRepoModel, SignerType, TransactionRepoModel, VaultSignerConfig, + }, services::{ signer::{SignerError, SignerFactoryError}, Signer, VaultConfig, VaultService, @@ -47,7 +50,9 @@ impl Signer for StellarSigner { pub struct StellarSignerFactory; impl StellarSignerFactory { - pub fn create_stellar_signer(m: &SignerRepoModel) -> Result { + pub fn create_stellar_signer( + m: &SignerDomainModel, + ) -> Result { let signer = match &m.config { SignerConfig::Local(_) => { let local_signer = LocalSigner::new(m)?; diff --git a/src/services/signer/stellar/vault_signer.rs b/src/services/signer/stellar/vault_signer.rs index 2a9dc3467..e556d3516 100644 --- a/src/services/signer/stellar/vault_signer.rs +++ b/src/services/signer/stellar/vault_signer.rs @@ -13,9 +13,13 @@ use std::sync::Arc; use tokio::sync::{Mutex, RwLock}; use zeroize::Zeroizing; +use crate::models::{LocalSignerConfig, SignerConfig}; use crate::{ domain::{SignDataRequest, SignDataResponse, SignTransactionResponse, SignTypedDataRequest}, - models::{Address, NetworkTransactionData, SignerError, SignerRepoModel, VaultSignerConfig}, + models::{ + Address, NetworkTransactionData, Signer as SignerDomainModel, SignerError, + VaultSignerConfig, + }, services::{ signer::stellar::local_signer::LocalSigner, vault::{VaultService, VaultServiceTrait}, @@ -127,10 +131,10 @@ impl VaultSigner { /// Loads a new signer from vault async fn load_signer_from_vault(&self) -> Result { let raw_key = self.fetch_private_key().await?; - let local_config = crate::models::LocalSignerConfig { raw_key }; - let local_model = SignerRepoModel { + let local_config = LocalSignerConfig { raw_key }; + let local_model = SignerDomainModel { id: self.key_name.clone(), - config: crate::models::SignerConfig::Local(local_config), + config: SignerConfig::Local(local_config), }; LocalSigner::new(&local_model) diff --git a/src/utils/encryption.rs b/src/utils/encryption.rs new file mode 100644 index 000000000..ddce1c2ae --- /dev/null +++ b/src/utils/encryption.rs @@ -0,0 +1,494 @@ +//! Field-level encryption utilities for sensitive data protection +//! +//! This module provides secure encryption and decryption of sensitive fields using AES-256-GCM. +//! It's designed to be used transparently in the repository layer to protect data at rest. + +use aes_gcm::{ + aead::{rand_core::RngCore, Aead, KeyInit, OsRng}, + Aes256Gcm, Key, Nonce, +}; +use serde::{Deserialize, Serialize}; +use std::env; +use thiserror::Error; +use zeroize::Zeroize; + +use crate::utils::{base64_decode, base64_encode}; + +#[derive(Error, Debug, Clone)] +pub enum EncryptionError { + #[error("Encryption failed: {0}")] + EncryptionFailed(String), + #[error("Decryption failed: {0}")] + DecryptionFailed(String), + #[error("Key derivation failed: {0}")] + KeyDerivationFailed(String), + #[error("Invalid encrypted data format: {0}")] + InvalidFormat(String), + #[error("Missing encryption key environment variable: {0}")] + MissingKey(String), + #[error("Invalid key length: expected 32 bytes, got {0}")] + InvalidKeyLength(usize), +} + +/// Encrypted data container that holds the nonce and ciphertext +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncryptedData { + /// Base64-encoded nonce (12 bytes for GCM) + pub nonce: String, + /// Base64-encoded ciphertext with authentication tag + pub ciphertext: String, + /// Version for future compatibility + pub version: u8, +} + +/// Main encryption service for field-level encryption +#[derive(Clone)] +pub struct FieldEncryption { + cipher: Aes256Gcm, +} + +impl FieldEncryption { + /// Creates a new FieldEncryption instance using a key from environment variables + /// + /// # Environment Variables + /// - `STORAGE_ENCRYPTION_KEY`: Base64-encoded 32-byte encryption key + /// - `STORAGE_ENCRYPTION_KEY_HEX`: Hex-encoded 32-byte encryption key (alternative) + /// + /// # Example + /// ```bash + /// export STORAGE_ENCRYPTION_KEY=$(openssl rand -base64 32) + /// ``` + pub fn new() -> Result { + let key = Self::load_key_from_env()?; + let cipher = Aes256Gcm::new(&key); + Ok(Self { cipher }) + } + + /// Creates a new FieldEncryption instance with a provided key (for testing) + pub fn new_with_key(key: &[u8; 32]) -> Result { + let key = Key::::from_slice(key); + let cipher = Aes256Gcm::new(key); + Ok(Self { cipher }) + } + + /// Loads encryption key from environment variables + fn load_key_from_env() -> Result, EncryptionError> { + // Try base64-encoded key first + if let Ok(key_b64) = env::var("STORAGE_ENCRYPTION_KEY") { + let key_bytes = base64_decode(&key_b64).map_err(|e| { + EncryptionError::KeyDerivationFailed(format!("Invalid base64 key: {}", e)) + })?; + + if key_bytes.len() != 32 { + return Err(EncryptionError::InvalidKeyLength(key_bytes.len())); + } + + return Ok(*Key::::from_slice(&key_bytes)); + } + + // Try hex-encoded key as alternative + if let Ok(key_hex) = env::var("STORAGE_ENCRYPTION_KEY_HEX") { + let key_bytes = hex::decode(&key_hex).map_err(|e| { + EncryptionError::KeyDerivationFailed(format!("Invalid hex key: {}", e)) + })?; + + if key_bytes.len() != 32 { + return Err(EncryptionError::InvalidKeyLength(key_bytes.len())); + } + + return Ok(*Key::::from_slice(&key_bytes)); + } + + Err(EncryptionError::MissingKey( + "Either STORAGE_ENCRYPTION_KEY (base64) or STORAGE_ENCRYPTION_KEY_HEX (hex) must be set".to_string() + )) + } + + /// Encrypts plaintext data and returns an EncryptedData structure + pub fn encrypt(&self, plaintext: &[u8]) -> Result { + // Generate random 12-byte nonce for GCM + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt the data + let ciphertext = self + .cipher + .encrypt(nonce, plaintext) + .map_err(|e| EncryptionError::EncryptionFailed(e.to_string()))?; + + Ok(EncryptedData { + nonce: base64_encode(&nonce_bytes), + ciphertext: base64_encode(&ciphertext), + version: 1, + }) + } + + /// Decrypts an EncryptedData structure and returns the plaintext + pub fn decrypt(&self, encrypted_data: &EncryptedData) -> Result, EncryptionError> { + if encrypted_data.version != 1 { + return Err(EncryptionError::InvalidFormat(format!( + "Unsupported encryption version: {}", + encrypted_data.version + ))); + } + + // Decode nonce and ciphertext + let nonce_bytes = base64_decode(&encrypted_data.nonce) + .map_err(|e| EncryptionError::InvalidFormat(format!("Invalid nonce: {}", e)))?; + + let ciphertext_bytes = base64_decode(&encrypted_data.ciphertext) + .map_err(|e| EncryptionError::InvalidFormat(format!("Invalid ciphertext: {}", e)))?; + + if nonce_bytes.len() != 12 { + return Err(EncryptionError::InvalidFormat(format!( + "Invalid nonce length: expected 12, got {}", + nonce_bytes.len() + ))); + } + + let nonce = Nonce::from_slice(&nonce_bytes); + + // Decrypt the data + let plaintext = self + .cipher + .decrypt(nonce, ciphertext_bytes.as_ref()) + .map_err(|e| EncryptionError::DecryptionFailed(e.to_string()))?; + + Ok(plaintext) + } + + /// Encrypts a string and returns base64-encoded encrypted data (opaque format) + pub fn encrypt_string(&self, plaintext: &str) -> Result { + let encrypted_data = self.encrypt(plaintext.as_bytes())?; + let json_data = serde_json::to_string(&encrypted_data).map_err(|e| { + EncryptionError::EncryptionFailed(format!("Serialization failed: {}", e)) + })?; + + // Base64 encode the entire JSON to make it opaque + Ok(base64_encode(json_data.as_bytes())) + } + + /// Decrypts a base64-encoded encrypted string + pub fn decrypt_string(&self, encrypted_base64: &str) -> Result { + // Decode from base64 to get the JSON + let json_bytes = base64_decode(encrypted_base64) + .map_err(|e| EncryptionError::InvalidFormat(format!("Invalid base64: {}", e)))?; + + let encrypted_json = String::from_utf8(json_bytes).map_err(|e| { + EncryptionError::InvalidFormat(format!("Invalid UTF-8 in decoded data: {}", e)) + })?; + + let encrypted_data: EncryptedData = serde_json::from_str(&encrypted_json).map_err(|e| { + EncryptionError::InvalidFormat(format!("Invalid JSON structure: {}", e)) + })?; + + let plaintext_bytes = self.decrypt(&encrypted_data)?; + String::from_utf8(plaintext_bytes).map_err(|e| { + EncryptionError::DecryptionFailed(format!("Invalid UTF-8 in plaintext: {}", e)) + }) + } + + /// Utility function to generate a new encryption key for setup + pub fn generate_key() -> String { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + let key_b64 = base64_encode(&key); + + // Zero out the key from memory + let mut key_zeroize = key; + key_zeroize.zeroize(); + + key_b64 + } + + /// Checks if encryption is properly configured + pub fn is_configured() -> bool { + env::var("STORAGE_ENCRYPTION_KEY").is_ok() || env::var("STORAGE_ENCRYPTION_KEY_HEX").is_ok() + } +} + +/// Global encryption instance (lazy-initialized) +static ENCRYPTION_INSTANCE: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +/// Gets the global encryption instance +pub fn get_encryption() -> Result<&'static FieldEncryption, &'static EncryptionError> { + ENCRYPTION_INSTANCE + .get_or_init(|| FieldEncryption::new()) + .as_ref() +} + +/// Encrypts sensitive data if encryption is configured, otherwise returns base64-encoded plaintext +pub fn encrypt_sensitive_field(data: &str) -> Result { + if FieldEncryption::is_configured() { + match get_encryption() { + Ok(encryption) => encryption.encrypt_string(data), + Err(e) => Err(e.clone()), + } + } else { + // For development/testing when encryption is not configured, + // base64-encode the JSON string for consistency + let json_data = serde_json::to_string(data).map_err(|e| { + EncryptionError::EncryptionFailed(format!("JSON encoding failed: {}", e)) + })?; + Ok(base64_encode(json_data.as_bytes())) + } +} + +/// Decrypts sensitive data from base64 format +pub fn decrypt_sensitive_field(data: &str) -> Result { + // Always try to decode base64 first + let json_bytes = base64_decode(data) + .map_err(|e| EncryptionError::InvalidFormat(format!("Invalid base64: {}", e)))?; + + let json_str = String::from_utf8(json_bytes) + .map_err(|e| EncryptionError::InvalidFormat(format!("Invalid UTF-8: {}", e)))?; + + // Try to parse as encrypted data first (if encryption is configured) + if FieldEncryption::is_configured() { + if let Ok(encryption) = get_encryption() { + // Check if this looks like encrypted data by trying to parse as EncryptedData + if let Ok(encrypted_data) = serde_json::from_str::(&json_str) { + // This is encrypted data, decrypt it + let plaintext_bytes = encryption.decrypt(&encrypted_data)?; + return String::from_utf8(plaintext_bytes).map_err(|e| { + EncryptionError::DecryptionFailed(format!("Invalid UTF-8 in plaintext: {}", e)) + }); + } + } + } + + // If we get here, either encryption is not configured, or this is fallback data + // Try to parse as JSON string (fallback format) + serde_json::from_str(&json_str) + .map_err(|e| EncryptionError::DecryptionFailed(format!("Invalid JSON string: {}", e))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn test_encrypt_decrypt_data() { + let key = [0u8; 32]; // Test key + let encryption = FieldEncryption::new_with_key(&key).unwrap(); + + let plaintext = b"This is a secret message!"; + let encrypted = encryption.encrypt(plaintext).unwrap(); + let decrypted = encryption.decrypt(&encrypted).unwrap(); + + assert_eq!(plaintext, decrypted.as_slice()); + } + + #[test] + fn test_encrypt_decrypt_string() { + let key = [1u8; 32]; // Different test key + let encryption = FieldEncryption::new_with_key(&key).unwrap(); + + let plaintext = "Sensitive API key: sk-1234567890abcdef"; + let encrypted = encryption.encrypt_string(plaintext).unwrap(); + let decrypted = encryption.decrypt_string(&encrypted).unwrap(); + + assert_eq!(plaintext, decrypted); + } + + #[test] + fn test_different_keys_produce_different_results() { + let key1 = [1u8; 32]; + let key2 = [2u8; 32]; + let encryption1 = FieldEncryption::new_with_key(&key1).unwrap(); + let encryption2 = FieldEncryption::new_with_key(&key2).unwrap(); + + let plaintext = "secret"; + let encrypted1 = encryption1.encrypt_string(plaintext).unwrap(); + let encrypted2 = encryption2.encrypt_string(plaintext).unwrap(); + + assert_ne!(encrypted1, encrypted2); + + // Each should decrypt with their own key + assert_eq!(encryption1.decrypt_string(&encrypted1).unwrap(), plaintext); + assert_eq!(encryption2.decrypt_string(&encrypted2).unwrap(), plaintext); + + // But not with the other key + assert!(encryption1.decrypt_string(&encrypted2).is_err()); + assert!(encryption2.decrypt_string(&encrypted1).is_err()); + } + + #[test] + fn test_nonce_uniqueness() { + let key = [3u8; 32]; + let encryption = FieldEncryption::new_with_key(&key).unwrap(); + + let plaintext = "same message"; + let encrypted1 = encryption.encrypt_string(plaintext).unwrap(); + let encrypted2 = encryption.encrypt_string(plaintext).unwrap(); + + // Same plaintext should produce different ciphertext due to random nonces + assert_ne!(encrypted1, encrypted2); + + // Both should decrypt to the same plaintext + assert_eq!(encryption.decrypt_string(&encrypted1).unwrap(), plaintext); + assert_eq!(encryption.decrypt_string(&encrypted2).unwrap(), plaintext); + } + + #[test] + fn test_invalid_encrypted_data() { + let key = [4u8; 32]; + let encryption = FieldEncryption::new_with_key(&key).unwrap(); + + // Test with invalid base64 + assert!(encryption.decrypt_string("invalid base64!").is_err()); + + // Test with valid base64 but invalid JSON inside + assert!(encryption + .decrypt_string(&base64_encode(b"not json")) + .is_err()); + + // Test with valid base64 but wrong JSON structure inside + let invalid_json_b64 = base64_encode(b"{\"wrong\": \"structure\"}"); + assert!(encryption.decrypt_string(&invalid_json_b64).is_err()); + + // Test with plain JSON (old format) - should fail since we only accept base64 + assert!(encryption + .decrypt_string(&base64_encode( + b"{\"nonce\":\"test\",\"ciphertext\":\"test\",\"version\":1}" + )) + .is_err()); + } + + #[test] + fn test_generate_key() { + let key1 = FieldEncryption::generate_key(); + let key2 = FieldEncryption::generate_key(); + + // Keys should be different + assert_ne!(key1, key2); + + // Keys should be valid base64 + assert!(base64_decode(&key1).is_ok()); + assert!(base64_decode(&key2).is_ok()); + + // Decoded keys should be 32 bytes + assert_eq!(base64_decode(&key1).unwrap().len(), 32); + assert_eq!(base64_decode(&key2).unwrap().len(), 32); + } + + #[test] + fn test_env_key_loading() { + // Test base64 key + let test_key = FieldEncryption::generate_key(); + env::set_var("STORAGE_ENCRYPTION_KEY", &test_key); + env::remove_var("STORAGE_ENCRYPTION_KEY_HEX"); + + let encryption = FieldEncryption::new().unwrap(); + let plaintext = "test message"; + let encrypted = encryption.encrypt_string(plaintext).unwrap(); + let decrypted = encryption.decrypt_string(&encrypted).unwrap(); + assert_eq!(plaintext, decrypted); + + // Test hex key + env::remove_var("STORAGE_ENCRYPTION_KEY"); + let key_bytes = base64_decode(&test_key).unwrap(); + let key_hex = hex::encode(&key_bytes); + env::set_var("STORAGE_ENCRYPTION_KEY_HEX", &key_hex); + + let encryption2 = FieldEncryption::new().unwrap(); + let encrypted2 = encryption2.encrypt_string(plaintext).unwrap(); + let decrypted2 = encryption2.decrypt_string(&encrypted2).unwrap(); + assert_eq!(plaintext, decrypted2); + + // Clean up + env::remove_var("STORAGE_ENCRYPTION_KEY"); + env::remove_var("STORAGE_ENCRYPTION_KEY_HEX"); + } + + #[test] + fn test_high_level_encryption_functions() { + let plaintext = "sensitive data"; + + // Test that the high-level encrypt/decrypt functions work together + let encoded = encrypt_sensitive_field(plaintext).unwrap(); + let decoded = decrypt_sensitive_field(&encoded).unwrap(); + assert_eq!(plaintext, decoded); + + // All outputs should now be base64-encoded (whether encrypted or fallback) + assert!(base64_decode(&encoded).is_ok()); + + // Just verify it works - don't make assumptions about internal format + // since global encryption state may vary between test runs + } + + #[test] + fn test_fallback_when_encryption_disabled() { + // Temporarily clear encryption keys to test fallback + let old_key = env::var("STORAGE_ENCRYPTION_KEY").ok(); + let old_hex_key = env::var("STORAGE_ENCRYPTION_KEY_HEX").ok(); + + env::remove_var("STORAGE_ENCRYPTION_KEY"); + env::remove_var("STORAGE_ENCRYPTION_KEY_HEX"); + + let plaintext = "fallback test"; + + // Should use fallback mode (base64-encoded JSON) + let encoded = encrypt_sensitive_field(plaintext).unwrap(); + let decoded = decrypt_sensitive_field(&encoded).unwrap(); + assert_eq!(plaintext, decoded); + + // Should be base64-encoded JSON + let expected_json = serde_json::to_string(plaintext).unwrap(); + let expected_b64 = base64_encode(expected_json.as_bytes()); + assert_eq!(encoded, expected_b64); + + // Restore original environment + if let Some(key) = old_key { + env::set_var("STORAGE_ENCRYPTION_KEY", key); + } + if let Some(hex_key) = old_hex_key { + env::set_var("STORAGE_ENCRYPTION_KEY_HEX", hex_key); + } + } + + #[test] + fn test_core_encryption_methods() { + let key = [9u8; 32]; + let encryption = FieldEncryption::new_with_key(&key).unwrap(); + let plaintext = "core encryption test"; + + // Test core encryption methods directly + let encrypted = encryption.encrypt_string(plaintext).unwrap(); + let decrypted = encryption.decrypt_string(&encrypted).unwrap(); + assert_eq!(plaintext, decrypted); + + // Should be base64-encoded + assert!(base64_decode(&encrypted).is_ok()); + // Should not contain readable structure + assert!(!encrypted.contains("nonce")); + assert!(!encrypted.contains("ciphertext")); + assert!(!encrypted.contains("{")); + } + + #[test] + fn test_base64_encoding_hides_structure() { + let key = [7u8; 32]; + let encryption = FieldEncryption::new_with_key(&key).unwrap(); + + let plaintext = "secret message"; + let encrypted = encryption.encrypt_string(plaintext).unwrap(); + + // Should be valid base64 + assert!(base64_decode(&encrypted).is_ok()); + + // Should not contain readable JSON structure + assert!(!encrypted.contains("nonce")); + assert!(!encrypted.contains("ciphertext")); + assert!(!encrypted.contains("version")); + assert!(!encrypted.contains("{")); + assert!(!encrypted.contains("}")); + + // Should decrypt correctly + let decrypted = encryption.decrypt_string(&encrypted).unwrap(); + assert_eq!(plaintext, decrypted); + } +} diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index a8a997b4e..ae97da3ff 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -10,10 +10,10 @@ pub mod mockutils { config::{EvmNetworkConfig, NetworkConfigCommon, RepositoryStorageType, ServerConfig}, jobs::MockJobProducerTrait, models::{ - AppState, EvmTransactionData, EvmTransactionRequest, LocalSignerConfig, + AppState, EvmTransactionData, EvmTransactionRequest, LocalSignerConfigStorage, NetworkRepoModel, NetworkTransactionData, NetworkType, NotificationRepoModel, PluginModel, RelayerEvmPolicy, RelayerNetworkPolicy, RelayerRepoModel, SecretString, - SignerConfig, SignerRepoModel, TransactionRepoModel, TransactionStatus, + SignerConfigStorage, SignerRepoModel, TransactionRepoModel, TransactionStatus, }, repositories::{ NetworkRepositoryStorage, NotificationRepositoryStorage, PluginRepositoryStorage, @@ -59,7 +59,7 @@ pub mod mockutils { let raw_key = SecretVec::new(32, |v| v.copy_from_slice(&seed)); SignerRepoModel { id: "test".to_string(), - config: SignerConfig::Local(LocalSignerConfig { raw_key }), + config: SignerConfigStorage::Local(LocalSignerConfigStorage { raw_key }), } } @@ -226,6 +226,8 @@ pub mod mockutils { provider_max_failovers: 3, repository_storage_type: storage_type, reset_storage_on_start: false, + storage_encryption_key: None, + storage_encryption_key_hex: None, } } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index c8c242b8f..cde9ab7f2 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -35,5 +35,8 @@ pub use service_info_log::*; mod uuid; pub use uuid::*; +mod encryption; +pub use encryption::*; + #[cfg(test)] pub mod mocks; diff --git a/src/utils/serde/mod.rs b/src/utils/serde/mod.rs index a8e265450..554ef2ea5 100644 --- a/src/utils/serde/mod.rs +++ b/src/utils/serde/mod.rs @@ -5,3 +5,6 @@ mod u64_deserializer; pub use u64_deserializer::*; pub mod field_as_string; + +mod repository_encryption; +pub use repository_encryption::*; diff --git a/src/utils/serde/repository_encryption.rs b/src/utils/serde/repository_encryption.rs new file mode 100644 index 000000000..499e42a64 --- /dev/null +++ b/src/utils/serde/repository_encryption.rs @@ -0,0 +1,397 @@ +//! Helper functions to serialize and deserialize secrets as encrypted base64 for storage + +use secrets::SecretVec; +use serde::{Deserialize, Deserializer, Serializer}; + +use crate::{ + models::SecretString, + utils::{base64_decode, base64_encode, decrypt_sensitive_field, encrypt_sensitive_field}, +}; + +/// Helper function to serialize secrets as encrypted base64 for storage +pub fn serialize_secret_vec(secret: &SecretVec, serializer: S) -> Result +where + S: Serializer, +{ + // First encode the raw secret as base64 + let base64 = base64_encode(secret.borrow().as_ref()); + + // Then encrypt the base64 string for secure storage + let encrypted = encrypt_sensitive_field(&base64) + .map_err(|e| serde::ser::Error::custom(format!("Encryption failed: {}", e)))?; + + serializer.serialize_str(&encrypted) +} + +/// Helper function to deserialize secrets from encrypted base64 storage +pub fn deserialize_secret_vec<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let encrypted_str = String::deserialize(deserializer)?; + + // First decrypt the encrypted string to get the base64 string + let base64_str = decrypt_sensitive_field(&encrypted_str) + .map_err(|e| serde::de::Error::custom(format!("Decryption failed: {}", e)))?; + + // Then decode the base64 to get the raw secret bytes + let decoded = base64_decode(&base64_str) + .map_err(|e| serde::de::Error::custom(format!("Invalid base64: {}", e)))?; + + Ok(SecretVec::new(decoded.len(), |v| { + v.copy_from_slice(&decoded) + })) +} + +/// Helper function to serialize secrets as encrypted base64 for storage +pub fn serialize_secret_string(secret: &SecretString, serializer: S) -> Result +where + S: Serializer, +{ + let secret_content = secret.to_str(); + let encrypted = encrypt_sensitive_field(&secret_content) + .map_err(|e| serde::ser::Error::custom(format!("Encryption failed: {}", e)))?; + + let encoded = base64_encode(encrypted.as_bytes()); + + serializer.serialize_str(&encoded) +} + +/// Helper function to deserialize secrets from encrypted base64 storage +pub fn deserialize_secret_string<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let base64_str = String::deserialize(deserializer)?; + + // First decode the base64 to get the encrypted bytes + let encrypted_bytes = base64_decode(&base64_str) + .map_err(|e| serde::de::Error::custom(format!("Invalid base64: {}", e)))?; + + // Convert encrypted bytes back to string + let encrypted_str = String::from_utf8(encrypted_bytes) + .map_err(|e| serde::de::Error::custom(format!("Invalid UTF-8: {}", e)))?; + + // Then decrypt the encrypted string to get the original content + let decrypted = decrypt_sensitive_field(&encrypted_str) + .map_err(|e| serde::de::Error::custom(format!("Decryption failed: {}", e)))?; + + Ok(SecretString::new(&decrypted)) +} + +/// Helper function to serialize optional secrets as encrypted base64 for storage +pub fn serialize_option_secret_string( + secret: &Option, + serializer: S, +) -> Result +where + S: Serializer, +{ + match secret { + Some(secret_string) => { + let secret_content = secret_string.to_str(); + let encrypted = encrypt_sensitive_field(&secret_content) + .map_err(|e| serde::ser::Error::custom(format!("Encryption failed: {}", e)))?; + + let encoded = base64_encode(encrypted.as_bytes()); + serializer.serialize_some(&encoded) + } + None => serializer.serialize_none(), + } +} + +/// Helper function to deserialize optional secrets from encrypted base64 storage +pub fn deserialize_option_secret_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt_base64_str: Option = Option::deserialize(deserializer)?; + + match opt_base64_str { + Some(base64_str) => { + // First decode the base64 to get the encrypted bytes + let encrypted_bytes = base64_decode(&base64_str) + .map_err(|e| serde::de::Error::custom(format!("Invalid base64: {}", e)))?; + + // Convert encrypted bytes back to string + let encrypted_str = String::from_utf8(encrypted_bytes) + .map_err(|e| serde::de::Error::custom(format!("Invalid UTF-8: {}", e)))?; + + // Then decrypt the encrypted string to get the original content + let decrypted = decrypt_sensitive_field(&encrypted_str) + .map_err(|e| serde::de::Error::custom(format!("Decryption failed: {}", e)))?; + + Ok(Some(SecretString::new(&decrypted))) + } + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use secrets::SecretVec; + use serde_json; + + #[test] + fn test_serialize_deserialize_secret_string() { + let secret = SecretString::new("test-secret-content"); + + // Create a test struct that uses the secret string serialization + #[derive(serde::Serialize, serde::Deserialize)] + struct TestStruct { + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + secret: SecretString, + } + + let test_struct = TestStruct { + secret: secret.clone(), + }; + + // Test serialization + let serialized = serde_json::to_string(&test_struct).unwrap(); + + // Test deserialization + let deserialized: TestStruct = serde_json::from_str(&serialized).unwrap(); + + // Verify content matches + assert_eq!(secret.to_str(), deserialized.secret.to_str()); + } + + #[test] + fn test_serialize_deserialize_secret_vec() { + let original_data = vec![1, 2, 3, 4, 5]; + let secret = SecretVec::new(original_data.len(), |v| v.copy_from_slice(&original_data)); + + // Create a test struct that uses the secret vec serialization + #[derive(serde::Serialize, serde::Deserialize)] + struct TestStruct { + #[serde( + serialize_with = "serialize_secret_vec", + deserialize_with = "deserialize_secret_vec" + )] + secret_data: SecretVec, + } + + let test_struct = TestStruct { + secret_data: secret, + }; + + // Test serialization + let serialized = serde_json::to_string(&test_struct).unwrap(); + + // Test deserialization + let deserialized: TestStruct = serde_json::from_str(&serialized).unwrap(); + + // Verify content matches + let original_borrowed = original_data; + let deserialized_borrowed = deserialized.secret_data.borrow(); + assert_eq!(original_borrowed, *deserialized_borrowed); + } + + #[test] + fn test_serialize_deserialize_option_secret_string_some() { + let secret = Some(SecretString::new("test-optional-secret")); + + // Create a test struct that uses the option secret string serialization + #[derive(serde::Serialize, serde::Deserialize)] + struct TestStruct { + #[serde( + serialize_with = "serialize_option_secret_string", + deserialize_with = "deserialize_option_secret_string" + )] + optional_secret: Option, + } + + let test_struct = TestStruct { + optional_secret: secret.clone(), + }; + + // Test serialization + let serialized = serde_json::to_string(&test_struct).unwrap(); + + // Test deserialization + let deserialized: TestStruct = serde_json::from_str(&serialized).unwrap(); + + // Verify content matches + assert!(deserialized.optional_secret.is_some()); + assert_eq!( + secret.unwrap().to_str(), + deserialized.optional_secret.unwrap().to_str() + ); + } + + #[test] + fn test_serialize_deserialize_option_secret_string_none() { + let secret: Option = None; + + // Create a test struct that uses the option secret string serialization + #[derive(serde::Serialize, serde::Deserialize)] + struct TestStruct { + #[serde( + serialize_with = "serialize_option_secret_string", + deserialize_with = "deserialize_option_secret_string" + )] + optional_secret: Option, + } + + let test_struct = TestStruct { + optional_secret: secret, + }; + + // Test serialization + let serialized = serde_json::to_string(&test_struct).unwrap(); + + // Test deserialization + let deserialized: TestStruct = serde_json::from_str(&serialized).unwrap(); + + // Verify it's None + assert!(deserialized.optional_secret.is_none()); + } + + #[test] + fn test_round_trip_secret_string() { + let original = SecretString::new("complex-secret-with-special-chars-!@#$%^&*()"); + + #[derive(serde::Serialize, serde::Deserialize)] + struct TestStruct { + #[serde( + serialize_with = "serialize_secret_string", + deserialize_with = "deserialize_secret_string" + )] + secret: SecretString, + } + + let test_struct = TestStruct { + secret: original.clone(), + }; + + // Serialize to JSON + let json = serde_json::to_string(&test_struct).unwrap(); + + // Deserialize back + let deserialized: TestStruct = serde_json::from_str(&json).unwrap(); + + // Verify the content is identical + assert_eq!(original.to_str(), deserialized.secret.to_str()); + } + + #[test] + fn test_round_trip_option_secret_string_with_multiple_values() { + let test_cases = vec![ + Some(SecretString::new("test1")), + None, + Some(SecretString::new("")), + Some(SecretString::new("test-with-unicode-🔐")), + Some(SecretString::new(&"very-long-secret-".repeat(100))), + ]; + + #[derive(serde::Serialize, serde::Deserialize)] + struct TestStruct { + #[serde( + serialize_with = "serialize_option_secret_string", + deserialize_with = "deserialize_option_secret_string" + )] + optional_secret: Option, + } + + for test_case in test_cases { + let test_struct = TestStruct { + optional_secret: test_case.clone(), + }; + + // Serialize to JSON + let json = serde_json::to_string(&test_struct).unwrap(); + + // Deserialize back + let deserialized: TestStruct = serde_json::from_str(&json).unwrap(); + + // Verify the content matches + match (test_case, deserialized.optional_secret) { + (Some(original), Some(deserialized_secret)) => { + assert_eq!(original.to_str(), deserialized_secret.to_str()); + } + (None, None) => { + // Both are None, this is correct + } + _ => panic!("Mismatch between original and deserialized optional secret"), + } + } + } + + #[test] + fn test_serialized_content_is_encrypted() { + let secret = SecretString::new("plaintext-secret"); + + #[derive(serde::Serialize)] + struct TestStruct { + #[serde(serialize_with = "serialize_secret_string")] + secret: SecretString, + } + + let test_struct = TestStruct { secret }; + let json = serde_json::to_string(&test_struct).unwrap(); + + // The serialized JSON should not contain the plaintext secret + assert!(!json.contains("plaintext-secret")); + + // It should be base64 encoded (contains only valid base64 characters) + let json_value: serde_json::Value = serde_json::from_str(&json).unwrap(); + let serialized_secret = json_value["secret"].as_str().unwrap(); + + // Verify it's valid base64 by attempting to decode it + assert!(base64_decode(serialized_secret).is_ok()); + } + + #[test] + fn test_serialized_option_content_when_some() { + let secret = Some(SecretString::new("plaintext-secret")); + + #[derive(serde::Serialize)] + struct TestStruct { + #[serde(serialize_with = "serialize_option_secret_string")] + optional_secret: Option, + } + + let test_struct = TestStruct { + optional_secret: secret, + }; + let json = serde_json::to_string(&test_struct).unwrap(); + + // The serialized JSON should not contain the plaintext secret + assert!(!json.contains("plaintext-secret")); + + // Parse the JSON to verify structure + let json_value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(json_value["optional_secret"].is_string()); + + let serialized_secret = json_value["optional_secret"].as_str().unwrap(); + // Verify it's valid base64 + assert!(base64_decode(serialized_secret).is_ok()); + } + + #[test] + fn test_serialized_option_content_when_none() { + let secret: Option = None; + + #[derive(serde::Serialize)] + struct TestStruct { + #[serde(serialize_with = "serialize_option_secret_string")] + optional_secret: Option, + } + + let test_struct = TestStruct { + optional_secret: secret, + }; + let json = serde_json::to_string(&test_struct).unwrap(); + + // Parse the JSON to verify structure + let json_value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(json_value["optional_secret"].is_null()); + } +} diff --git a/tests/integration/metrics.rs b/tests/integration/metrics.rs index 1707c49d7..75e559aa1 100644 --- a/tests/integration/metrics.rs +++ b/tests/integration/metrics.rs @@ -30,6 +30,8 @@ async fn test_authorization_middleware_success() { provider_max_failovers: 3, repository_storage_type: RepositoryStorageType::InMemory, reset_storage_on_start: false, + storage_encryption_key: None, + storage_encryption_key_hex: None, }); let app = test::init_service( @@ -86,6 +88,8 @@ async fn test_authorization_middleware_failure() { provider_max_failovers: 3, repository_storage_type: RepositoryStorageType::InMemory, reset_storage_on_start: false, + storage_encryption_key: None, + storage_encryption_key_hex: None, }); let app = test::init_service( From 42c507e26e17e2c049f1a8d75e6fe2c141283671 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Sat, 26 Jul 2025 23:57:27 +0200 Subject: [PATCH 45/59] chore: allow starting service with empty config --- Cargo.toml | 12 ----- src/bootstrap/initialize_app_state.rs | 8 ++- src/config/config_file/mod.rs | 71 ++------------------------- src/config/server_config.rs | 5 -- src/models/notification/config.rs | 2 +- src/models/relayer/config.rs | 17 +------ src/models/signer/config.rs | 2 +- src/utils/encryption.rs | 59 ++++++---------------- src/utils/mocks.rs | 5 +- tests/integration/metrics.rs | 2 - 10 files changed, 27 insertions(+), 156 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index df7f00714..bba3d61f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,17 +121,5 @@ path = "helpers/generate_uuid.rs" name = "generate_openapi" path = "helpers/generate_openapi.rs" -[[example]] -name = "test_encryption" -path = "helpers/test_encryption.rs" - -[[example]] -name = "test_signer_encryption" -path = "helpers/test_signer_encryption.rs" - -[[example]] -name = "test_storage_encryption" -path = "helpers/test_storage_encryption.rs" - [lib] path = "src/lib.rs" diff --git a/src/bootstrap/initialize_app_state.rs b/src/bootstrap/initialize_app_state.rs index e2bf36fd6..c840c43ea 100644 --- a/src/bootstrap/initialize_app_state.rs +++ b/src/bootstrap/initialize_app_state.rs @@ -49,11 +49,9 @@ pub async fn initialize_repositories(config: &ServerConfig) -> eyre::Result { warn!("⚠️ Redis repository storage support is experimental. Use with caution."); - if config.storage_encryption_key.is_none() - && config.storage_encryption_key_hex.is_none() - { - warn!("⚠️ Storage encryption key is not set. Please set the STORAGE_ENCRYPTION_KEY or STORAGE_ENCRYPTION_KEY_HEX environment variable."); - return Err(eyre::eyre!("Storage encryption key is not set. Please set the STORAGE_ENCRYPTION_KEY or STORAGE_ENCRYPTION_KEY_HEX environment variable.")); + if config.storage_encryption_key.is_none() { + warn!("⚠️ Storage encryption key is not set. Please set the STORAGE_ENCRYPTION_KEY environment variable."); + return Err(eyre::eyre!("Storage encryption key is not set. Please set the STORAGE_ENCRYPTION_KEY environment variable.")); } let connection_manager = initialize_redis_connection(config).await?; diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index 7895777f8..81f5dcf21 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -71,18 +71,6 @@ impl Config { /// # Errors /// Returns a `ConfigFileError` if any validation checks fail. pub fn validate(&self) -> Result<(), ConfigFileError> { - if self.relayers.is_empty() && self.signers.is_empty() && self.notifications.is_empty() { - return Err(ConfigFileError::MissingField( - "config must contain at least one relayer, signer or notification".into(), - )); - } - - if self.networks.is_empty() { - return Err(ConfigFileError::MissingField( - "config must contain at least one network".into(), - )); - } - self.validate_networks()?; self.validate_relayers(&self.networks)?; self.validate_signers()?; @@ -286,10 +274,7 @@ mod tests { .unwrap(), plugins: Some(vec![]), }; - assert!(matches!( - config.validate(), - Err(ConfigFileError::MissingField(_)) - )); + assert!(config.validate().is_ok()); } #[test] @@ -316,10 +301,7 @@ mod tests { .unwrap(), plugins: Some(vec![]), }; - assert!(matches!( - config.validate(), - Err(ConfigFileError::MissingField(_)) - )); + assert!(config.validate().is_ok()); } #[test] @@ -1000,40 +982,6 @@ mod tests { assert!(matches!(result.unwrap_err(), ConfigFileError::JsonError(_))); } - #[test] - fn test_load_config_validation_failure() { - let dir = tempdir().expect("Failed to create temp dir"); - let config_path = dir.path().join("invalid_validation.json"); - - let invalid_config = serde_json::json!({ - "relayers": [], - "signers": [], - "notifications": [], - "plugins": [], - "networks": [{ - "type": "evm", - "network": "test-network", - "chain_id": 31337, - "required_confirmations": 1, - "symbol": "ETH", - "rpc_urls": ["https://rpc.test.example.com"] - }] - }); - - setup_config_file( - dir.path(), - "invalid_validation.json", - &invalid_config.to_string(), - ); - - let result = load_config(config_path.to_str().unwrap()); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConfigFileError::MissingField(_) - )); - } - #[test] fn test_load_config_with_unicode_content() { let dir = tempdir().expect("Failed to create temp dir"); @@ -1266,13 +1214,7 @@ mod tests { }; let result = config.validate(); - // This should fail because SignersFileConfig::validate() requires non-empty signers - // but the main Config::validate() also requires non-empty relayers - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConfigFileError::MissingField(_) - )); + assert!(result.is_ok()); } #[test] @@ -1306,12 +1248,7 @@ mod tests { }; let result = config.validate(); - // This should fail because the validation requires non-empty relayers AND signers - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - ConfigFileError::MissingField(_) - )); + assert!(result.is_ok()); } #[test] diff --git a/src/config/server_config.rs b/src/config/server_config.rs index eb72c7197..9464c1649 100644 --- a/src/config/server_config.rs +++ b/src/config/server_config.rs @@ -62,8 +62,6 @@ pub struct ServerConfig { pub reset_storage_on_start: bool, /// The encryption key for the storage. pub storage_encryption_key: Option, - /// The encryption key for the storage in hex format. - pub storage_encryption_key_hex: Option, } impl ServerConfig { @@ -175,9 +173,6 @@ impl ServerConfig { storage_encryption_key: env::var("STORAGE_ENCRYPTION_KEY") .map(|v| SecretString::new(&v)) .ok(), - storage_encryption_key_hex: env::var("STORAGE_ENCRYPTION_KEY_HEX") - .map(|v| SecretString::new(&v)) - .ok(), } } } diff --git a/src/models/notification/config.rs b/src/models/notification/config.rs index b49598bb7..73d1cfd8a 100644 --- a/src/models/notification/config.rs +++ b/src/models/notification/config.rs @@ -128,7 +128,7 @@ impl NotificationConfigs { /// Validates all notification configurations pub fn validate(&self) -> Result<(), ConfigFileError> { if self.notifications.is_empty() { - return Err(ConfigFileError::MissingField("notifications".into())); + return Ok(()); } let mut ids = HashSet::new(); diff --git a/src/models/relayer/config.rs b/src/models/relayer/config.rs index 7f32e5d53..c26f9f2a0 100644 --- a/src/models/relayer/config.rs +++ b/src/models/relayer/config.rs @@ -427,7 +427,7 @@ impl RelayersFileConfig { pub fn validate(&self, networks: &NetworksFileConfig) -> Result<(), ConfigFileError> { if self.relayers.is_empty() { - return Err(ConfigFileError::MissingField("relayers".into())); + return Ok(()); } let mut ids = HashSet::new(); @@ -1102,21 +1102,6 @@ mod tests { } } - #[test] - fn test_relayers_file_config_validation_empty_relayers() { - let relayers_config = RelayersFileConfig::new(vec![]); - let networks_config = create_test_networks_config(); - - let result = relayers_config.validate(&networks_config); - assert!(result.is_err()); - - if let Err(ConfigFileError::MissingField(field)) = result { - assert_eq!(field, "relayers"); - } else { - panic!("Expected MissingField error for empty relayers"); - } - } - #[test] fn test_relayers_file_config_validation_duplicate_ids() { let relayer_config1 = RelayerFileConfig { diff --git a/src/models/signer/config.rs b/src/models/signer/config.rs index fc1e3c5b1..2a9508544 100644 --- a/src/models/signer/config.rs +++ b/src/models/signer/config.rs @@ -184,7 +184,7 @@ impl SignersFileConfig { pub fn validate(&self) -> Result<(), ConfigFileError> { if self.signers.is_empty() { - return Err(ConfigFileError::MissingField("signers".into())); + return Ok(()); } let mut ids = HashSet::new(); diff --git a/src/utils/encryption.rs b/src/utils/encryption.rs index ddce1c2ae..17ebf447e 100644 --- a/src/utils/encryption.rs +++ b/src/utils/encryption.rs @@ -52,7 +52,6 @@ impl FieldEncryption { /// /// # Environment Variables /// - `STORAGE_ENCRYPTION_KEY`: Base64-encoded 32-byte encryption key - /// - `STORAGE_ENCRYPTION_KEY_HEX`: Hex-encoded 32-byte encryption key (alternative) /// /// # Example /// ```bash @@ -73,35 +72,19 @@ impl FieldEncryption { /// Loads encryption key from environment variables fn load_key_from_env() -> Result, EncryptionError> { - // Try base64-encoded key first - if let Ok(key_b64) = env::var("STORAGE_ENCRYPTION_KEY") { - let key_bytes = base64_decode(&key_b64).map_err(|e| { - EncryptionError::KeyDerivationFailed(format!("Invalid base64 key: {}", e)) - })?; - - if key_bytes.len() != 32 { - return Err(EncryptionError::InvalidKeyLength(key_bytes.len())); - } - - return Ok(*Key::::from_slice(&key_bytes)); - } - - // Try hex-encoded key as alternative - if let Ok(key_hex) = env::var("STORAGE_ENCRYPTION_KEY_HEX") { - let key_bytes = hex::decode(&key_hex).map_err(|e| { - EncryptionError::KeyDerivationFailed(format!("Invalid hex key: {}", e)) - })?; + let key_b64 = env::var("STORAGE_ENCRYPTION_KEY").map_err(|_| { + EncryptionError::MissingKey("STORAGE_ENCRYPTION_KEY must be set".to_string()) + })?; - if key_bytes.len() != 32 { - return Err(EncryptionError::InvalidKeyLength(key_bytes.len())); - } + let key_bytes = base64_decode(&key_b64).map_err(|e| { + EncryptionError::KeyDerivationFailed(format!("Invalid base64 key: {}", e)) + })?; - return Ok(*Key::::from_slice(&key_bytes)); + if key_bytes.len() != 32 { + return Err(EncryptionError::InvalidKeyLength(key_bytes.len())); } - Err(EncryptionError::MissingKey( - "Either STORAGE_ENCRYPTION_KEY (base64) or STORAGE_ENCRYPTION_KEY_HEX (hex) must be set".to_string() - )) + Ok(*Key::::from_slice(&key_bytes)) } /// Encrypts plaintext data and returns an EncryptedData structure @@ -204,7 +187,7 @@ impl FieldEncryption { /// Checks if encryption is properly configured pub fn is_configured() -> bool { - env::var("STORAGE_ENCRYPTION_KEY").is_ok() || env::var("STORAGE_ENCRYPTION_KEY_HEX").is_ok() + env::var("STORAGE_ENCRYPTION_KEY").is_ok() } } @@ -380,7 +363,6 @@ mod tests { // Test base64 key let test_key = FieldEncryption::generate_key(); env::set_var("STORAGE_ENCRYPTION_KEY", &test_key); - env::remove_var("STORAGE_ENCRYPTION_KEY_HEX"); let encryption = FieldEncryption::new().unwrap(); let plaintext = "test message"; @@ -388,20 +370,12 @@ mod tests { let decrypted = encryption.decrypt_string(&encrypted).unwrap(); assert_eq!(plaintext, decrypted); - // Test hex key + // Test missing key env::remove_var("STORAGE_ENCRYPTION_KEY"); - let key_bytes = base64_decode(&test_key).unwrap(); - let key_hex = hex::encode(&key_bytes); - env::set_var("STORAGE_ENCRYPTION_KEY_HEX", &key_hex); - - let encryption2 = FieldEncryption::new().unwrap(); - let encrypted2 = encryption2.encrypt_string(plaintext).unwrap(); - let decrypted2 = encryption2.decrypt_string(&encrypted2).unwrap(); - assert_eq!(plaintext, decrypted2); + assert!(FieldEncryption::new().is_err()); // Clean up - env::remove_var("STORAGE_ENCRYPTION_KEY"); - env::remove_var("STORAGE_ENCRYPTION_KEY_HEX"); + env::set_var("STORAGE_ENCRYPTION_KEY", &test_key); } #[test] @@ -422,12 +396,10 @@ mod tests { #[test] fn test_fallback_when_encryption_disabled() { - // Temporarily clear encryption keys to test fallback + // Temporarily clear encryption key to test fallback let old_key = env::var("STORAGE_ENCRYPTION_KEY").ok(); - let old_hex_key = env::var("STORAGE_ENCRYPTION_KEY_HEX").ok(); env::remove_var("STORAGE_ENCRYPTION_KEY"); - env::remove_var("STORAGE_ENCRYPTION_KEY_HEX"); let plaintext = "fallback test"; @@ -445,9 +417,6 @@ mod tests { if let Some(key) = old_key { env::set_var("STORAGE_ENCRYPTION_KEY", key); } - if let Some(hex_key) = old_hex_key { - env::set_var("STORAGE_ENCRYPTION_KEY_HEX", hex_key); - } } #[test] diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index ae97da3ff..3449b8d3b 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -226,8 +226,9 @@ pub mod mockutils { provider_max_failovers: 3, repository_storage_type: storage_type, reset_storage_on_start: false, - storage_encryption_key: None, - storage_encryption_key_hex: None, + storage_encryption_key: Some(SecretString::new( + "test_encryption_key_1234567890_test_key_32", + )), } } } diff --git a/tests/integration/metrics.rs b/tests/integration/metrics.rs index 75e559aa1..3d455291d 100644 --- a/tests/integration/metrics.rs +++ b/tests/integration/metrics.rs @@ -31,7 +31,6 @@ async fn test_authorization_middleware_success() { repository_storage_type: RepositoryStorageType::InMemory, reset_storage_on_start: false, storage_encryption_key: None, - storage_encryption_key_hex: None, }); let app = test::init_service( @@ -89,7 +88,6 @@ async fn test_authorization_middleware_failure() { repository_storage_type: RepositoryStorageType::InMemory, reset_storage_on_start: false, storage_encryption_key: None, - storage_encryption_key_hex: None, }); let app = test::init_service( From 1d9eb55f0b0b7041a22d2cd23e80ed3401bd407d Mon Sep 17 00:00:00 2001 From: Zeljko Date: Sun, 27 Jul 2025 23:33:23 +0200 Subject: [PATCH 46/59] chore: improvements --- src/api/controllers/relayer.rs | 34 ++++++++++++++++++++++++++++++++ src/utils/encryption.rs | 36 ++++++++++++++++++---------------- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 279c91082..24835256c 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -791,7 +791,13 @@ mod tests { }, }; use actix_web::body::to_bytes; + use lazy_static::lazy_static; use std::env; + use std::sync::Mutex; + + lazy_static! { + static ref ENV_MUTEX: Mutex<()> = Mutex::new(()); + } fn setup_test_env() { env::set_var("API_KEY", "7EF1CB7C-5003-4696-B384-C72AF8C3E15D"); // noboost nosemgrep @@ -875,6 +881,10 @@ mod tests { #[actix_web::test] async fn test_create_relayer_success() { + let _lock = match ENV_MUTEX.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); @@ -908,6 +918,10 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_evm_policies() { + let _lock = match ENV_MUTEX.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); @@ -957,6 +971,10 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_partial_evm_policies() { + let _lock = match ENV_MUTEX.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); @@ -1002,6 +1020,10 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_solana_policies() { + let _lock = match ENV_MUTEX.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; setup_test_env(); let network = create_mock_solana_network(); let signer = create_mock_signer(); @@ -1072,6 +1094,10 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_stellar_policies() { + let _lock = match ENV_MUTEX.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; setup_test_env(); let network = create_mock_stellar_network(); let signer = create_mock_signer(); @@ -1118,6 +1144,10 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_policy_type_mismatch() { + let _lock = match ENV_MUTEX.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); @@ -1154,6 +1184,10 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_notification() { + let _lock = match ENV_MUTEX.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); diff --git a/src/utils/encryption.rs b/src/utils/encryption.rs index 17ebf447e..adabff8eb 100644 --- a/src/utils/encryption.rs +++ b/src/utils/encryption.rs @@ -12,7 +12,10 @@ use std::env; use thiserror::Error; use zeroize::Zeroize; -use crate::utils::{base64_decode, base64_encode}; +use crate::{ + models::SecretString, + utils::{base64_decode, base64_encode}, +}; #[derive(Error, Debug, Clone)] pub enum EncryptionError { @@ -52,10 +55,6 @@ impl FieldEncryption { /// /// # Environment Variables /// - `STORAGE_ENCRYPTION_KEY`: Base64-encoded 32-byte encryption key - /// - /// # Example - /// ```bash - /// export STORAGE_ENCRYPTION_KEY=$(openssl rand -base64 32) /// ``` pub fn new() -> Result { let key = Self::load_key_from_env()?; @@ -72,19 +71,22 @@ impl FieldEncryption { /// Loads encryption key from environment variables fn load_key_from_env() -> Result, EncryptionError> { - let key_b64 = env::var("STORAGE_ENCRYPTION_KEY").map_err(|_| { - EncryptionError::MissingKey("STORAGE_ENCRYPTION_KEY must be set".to_string()) - })?; - - let key_bytes = base64_decode(&key_b64).map_err(|e| { - EncryptionError::KeyDerivationFailed(format!("Invalid base64 key: {}", e)) - })?; - - if key_bytes.len() != 32 { - return Err(EncryptionError::InvalidKeyLength(key_bytes.len())); - } + let key = env::var("STORAGE_ENCRYPTION_KEY") + .map(|v| SecretString::new(&v)) + .map_err(|_| { + EncryptionError::MissingKey("STORAGE_ENCRYPTION_KEY must be set".to_string()) + })?; + + key.as_str(|key_b64| { + let mut key_bytes = base64_decode(key_b64) + .map_err(|e| EncryptionError::KeyDerivationFailed(e.to_string()))?; + if key_bytes.len() != 32 { + key_bytes.zeroize(); // Explicit cleanup on error path + return Err(EncryptionError::InvalidKeyLength(key_bytes.len())); + } - Ok(*Key::::from_slice(&key_bytes)) + Ok(*Key::::from_slice(&key_bytes)) + }) } /// Encrypts plaintext data and returns an EncryptedData structure From 9476fabe0b533d766c95cba84e1a4e6c09b5c73f Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 28 Jul 2025 09:07:16 +0200 Subject: [PATCH 47/59] chore: clippy --- config/config-v1.json | 93 +++++++ src/api/controllers/relayer.rs | 37 +-- src/api/routes/docs/mod.rs | 1 + src/api/routes/docs/signer_docs.rs | 315 +++++++++++++++++++++++ src/openapi.rs | 9 +- src/utils/encryption.rs | 2 +- src/utils/serde/repository_encryption.rs | 6 +- 7 files changed, 429 insertions(+), 34 deletions(-) create mode 100644 config/config-v1.json create mode 100644 src/api/routes/docs/signer_docs.rs diff --git a/config/config-v1.json b/config/config-v1.json new file mode 100644 index 000000000..0fd04b5a6 --- /dev/null +++ b/config/config-v1.json @@ -0,0 +1,93 @@ +{ + "relayers": [ + { + "id": "sepolia-example", + "name": "Sepolia Example", + "network": "sepolia", + "paused": false, + "notification_id": "notification-example", + "signer_id": "local-signer", + "network_type": "evm", + "custom_rpc_urls": ["https://eth-sepolia.public.blastapi.io"], + "policies": { + "min_balance": 0 + } + }, + { + "id": "solana-example", + "name": "Solana Example", + "network": "devnet", + "paused": false, + "notification_id": "notification-example", + "signer_id": "local-signer", + "network_type": "solana", + "policies": { + "fee_payment_strategy":"relayer", + "min_balance": 0, + "allowed_tokens": [ + { + "mint": "Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr", + "max_allowed_fee": 100000000 + } + + ] + } + } + ], + "notifications": [ + { + "id": "notification-example", + "type": "webhook", + "url": "https://webhook.site/1972ef96-2704-4a81-835d-7c81ffa799b6", + "signing_key": { + "type": "env", + "value": "WEBHOOK_SIGNING_KEY" + } + } + ], + "signers": [ + { + "id": "local-signer", + "type": "local", + "config": { + "path": "config/keys/local-signer.json", + "passphrase": { + "type": "plain", + "value": "test" + } + } + }, + { + "id": "turnkey-signer-evm", + "type": "turnkey", + "config": { + "api_public_key": "02722613643282c5af96ccb3b1224ee4fec97f9e0e925b7dbafcffc454e4a262b0", + "api_private_key": { + "type": "plain", + "value": "b4f8fcdb3fff65d74fa1b1c2d4e2f542f6d625e238f92234e82ebc8ae6138612" + }, + "organization_id": "ff00650a-2eca-4fb4-b257-87b031ddfd09", + "private_key_id": "a9b3a2a2-945c-4e77-9298-459a31dbd92c", + "public_key": "047d3bb8e0317927700cf19fed34e0627367be1390ec247dddf8c239e4b4321a49aea80090e49b206b6a3e577a4f11d721ab063482001ee10db40d6f2963233eec" + } + }, + { + "id": "local-vault", + "type": "vault", + "config": { + "address": "http://0.0.0.0:8200", + "role_id": { + "type": "plain", + "value": "82708e4e-ecff-b103-71fd-c3c0136c84f4" + }, + "secret_id": { + "type": "plain", + "value": "be01cffa-fc0d-295f-54d0-0bf0cc45e390" + }, + "key_name": "my-app" + } + } + ], + "networks": "./config/networks", + "plugins": [] +} diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 24835256c..f15c94880 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -793,7 +793,7 @@ mod tests { use actix_web::body::to_bytes; use lazy_static::lazy_static; use std::env; - use std::sync::Mutex; + use tokio::sync::Mutex; lazy_static! { static ref ENV_MUTEX: Mutex<()> = Mutex::new(()); @@ -881,10 +881,7 @@ mod tests { #[actix_web::test] async fn test_create_relayer_success() { - let _lock = match ENV_MUTEX.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; + let _lock = ENV_MUTEX.lock().await; setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); @@ -918,10 +915,7 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_evm_policies() { - let _lock = match ENV_MUTEX.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; + let _lock = ENV_MUTEX.lock().await; setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); @@ -971,10 +965,7 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_partial_evm_policies() { - let _lock = match ENV_MUTEX.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; + let _lock = ENV_MUTEX.lock().await; setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); @@ -1020,10 +1011,7 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_solana_policies() { - let _lock = match ENV_MUTEX.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; + let _lock = ENV_MUTEX.lock().await; setup_test_env(); let network = create_mock_solana_network(); let signer = create_mock_signer(); @@ -1094,10 +1082,7 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_stellar_policies() { - let _lock = match ENV_MUTEX.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; + let _lock = ENV_MUTEX.lock().await; setup_test_env(); let network = create_mock_stellar_network(); let signer = create_mock_signer(); @@ -1144,10 +1129,7 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_policy_type_mismatch() { - let _lock = match ENV_MUTEX.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; + let _lock = ENV_MUTEX.lock().await; setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); @@ -1184,10 +1166,7 @@ mod tests { #[actix_web::test] async fn test_create_relayer_with_notification() { - let _lock = match ENV_MUTEX.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; + let _lock = ENV_MUTEX.lock().await; setup_test_env(); let network = create_mock_network(); let signer = create_mock_signer(); diff --git a/src/api/routes/docs/mod.rs b/src/api/routes/docs/mod.rs index 4cd713eae..daed0da76 100644 --- a/src/api/routes/docs/mod.rs +++ b/src/api/routes/docs/mod.rs @@ -1,3 +1,4 @@ pub mod notification_docs; pub mod plugin_docs; pub mod relayer_docs; +pub mod signer_docs; diff --git a/src/api/routes/docs/signer_docs.rs b/src/api/routes/docs/signer_docs.rs new file mode 100644 index 000000000..82c434b17 --- /dev/null +++ b/src/api/routes/docs/signer_docs.rs @@ -0,0 +1,315 @@ +use crate::models::{ApiResponse, SignerCreateRequest, SignerResponse, SignerUpdateRequest}; + +/// Notification routes implementation +/// +/// Note: OpenAPI documentation for these endpoints can be found in the `openapi.rs` file +/// +/// Lists all signers with pagination support. +#[utoipa::path( + get, + path = "/api/v1/signers", + tag = "Signers", + operation_id = "listSigners", + security( + ("bearer_auth" = []) + ), + params( + ("page" = Option, Query, description = "Page number for pagination (starts at 1)"), + ("per_page" = Option, Query, description = "Number of items per page (default: 10)") + ), + responses( + ( + status = 200, + description = "Signer list retrieved successfully", + body = ApiResponse> + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_list_signers() {} + +/// Retrieves details of a specific notification by ID. +#[utoipa::path( + get, + path = "/api/v1/signers/{signer_id}", + tag = "Signers", + operation_id = "getSigner", + security( + ("bearer_auth" = []) + ), + params( + ("signer_id" = String, Path, description = "Signer ID") + ), + responses( + ( + status = 200, + description = "Signer retrieved successfully", + body = ApiResponse + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 404, + description = "Signer not found", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Signer not found", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_get_signer() {} + +/// Creates a new notification. +#[utoipa::path( + post, + path = "/api/v1/signers", + tag = "Signers", + operation_id = "createSigner", + security( + ("bearer_auth" = []) + ), + request_body = SignerCreateRequest, + responses( + ( + status = 201, + description = "Signer created successfully", + body = ApiResponse + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 409, + description = "Signer with this ID already exists", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Signer with this ID already exists", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_create_signer() {} + +/// Updates an existing notification. +#[utoipa::path( + patch, + path = "/api/v1/signers/{signer_id}", + tag = "Signers", + operation_id = "updateSigner", + security( + ("bearer_auth" = []) + ), + params( + ("signer_id" = String, Path, description = "Signer ID") + ), + request_body = SignerUpdateRequest, + responses( + ( + status = 200, + description = "Signer updated successfully", + body = ApiResponse + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 404, + description = "Signer not found", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Signer not found", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_update_signer() {} + +/// Deletes a notification by ID. +#[utoipa::path( + delete, + path = "/api/v1/signers/{signer_id}", + tag = "Signers", + operation_id = "deleteSigner", + security( + ("bearer_auth" = []) + ), + params( + ("signer_id" = String, Path, description = "Signer ID") + ), + responses( + ( + status = 200, + description = "Signer deleted successfully", + body = ApiResponse, + example = json!({ + "success": true, + "message": "Signer deleted successfully", + "data": "Signer deleted successfully" + }) + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 404, + description = "Signer not found", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Signer not found", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_delete_signer() {} diff --git a/src/openapi.rs b/src/openapi.rs index 4c7411242..1541de2f8 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -1,6 +1,6 @@ use crate::{ api::routes::{ - docs::{notification_docs, plugin_docs, relayer_docs}, + docs::{notification_docs, plugin_docs, relayer_docs, signer_docs}, health, metrics, }, domain, models, @@ -31,6 +31,8 @@ impl Modify for SecurityAddon { tags( (name = "Relayers", description = "Relayers are the core components of the OpenZeppelin Relayer API. They are responsible for executing transactions on behalf of users and providing a secure and reliable way to interact with the blockchain."), (name = "Plugins", description = "Plugins are TypeScript functions that can be used to extend the OpenZeppelin Relayer API functionality."), + (name = "Notifications", description = "Notifications are responsible for showing the notifications related to the relayers."), + (name = "Signers", description = "Signers are responsible for signing the transactions related to the relayers."), (name = "Metrics", description = "Metrics are responsible for showing the metrics related to the relayers."), (name = "Health", description = "Health is responsible for showing the health of the relayers.") ), @@ -71,6 +73,11 @@ impl Modify for SecurityAddon { notification_docs::doc_create_notification, notification_docs::doc_update_notification, notification_docs::doc_delete_notification, + signer_docs::doc_list_signers, + signer_docs::doc_get_signer, + signer_docs::doc_create_signer, + signer_docs::doc_update_signer, + signer_docs::doc_delete_signer, ), components(schemas( models::RelayerResponse, diff --git a/src/utils/encryption.rs b/src/utils/encryption.rs index adabff8eb..5c9e41a05 100644 --- a/src/utils/encryption.rs +++ b/src/utils/encryption.rs @@ -200,7 +200,7 @@ static ENCRYPTION_INSTANCE: std::sync::OnceLock Result<&'static FieldEncryption, &'static EncryptionError> { ENCRYPTION_INSTANCE - .get_or_init(|| FieldEncryption::new()) + .get_or_init(FieldEncryption::new) .as_ref() } diff --git a/src/utils/serde/repository_encryption.rs b/src/utils/serde/repository_encryption.rs index 499e42a64..84fb2db6b 100644 --- a/src/utils/serde/repository_encryption.rs +++ b/src/utils/serde/repository_encryption.rs @@ -196,7 +196,7 @@ mod tests { #[test] fn test_serialize_deserialize_option_secret_string_some() { - let secret = Some(SecretString::new("test-optional-secret")); + let secret = SecretString::new("test-optional-secret"); // Create a test struct that uses the option secret string serialization #[derive(serde::Serialize, serde::Deserialize)] @@ -209,7 +209,7 @@ mod tests { } let test_struct = TestStruct { - optional_secret: secret.clone(), + optional_secret: Some(secret.clone()), }; // Test serialization @@ -221,7 +221,7 @@ mod tests { // Verify content matches assert!(deserialized.optional_secret.is_some()); assert_eq!( - secret.unwrap().to_str(), + secret.to_str(), deserialized.optional_secret.unwrap().to_str() ); } From 957117cdb3b127152de6321ec17d0edf566f86dc Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 28 Jul 2025 09:14:08 +0200 Subject: [PATCH 48/59] chore: pr suggestions --- src/api/controllers/relayer.rs | 19 ++++--------------- src/bootstrap/config_processor.rs | 7 ++++--- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 42b9ffff4..59f0ddc0c 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -139,7 +139,7 @@ where PR: PluginRepositoryTrait + Send + Sync + 'static, { // Convert request to domain relayer (validates automatically) - let relayer = crate::models::Relayer::try_from(request)?; + let relayer = RelayerDomainModel::try_from(request)?; // Check if signer exists let signer_model = state @@ -779,8 +779,9 @@ mod tests { use super::*; use crate::{ models::{ - ApiResponse, CreateRelayerRequest, RelayerNetworkPolicyResponse, RelayerNetworkType, - RelayerResponse, + ApiResponse, CreateRelayerPolicyRequest, CreateRelayerRequest, RelayerEvmPolicy, + RelayerNetworkPolicyResponse, RelayerNetworkType, RelayerResponse, + RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, RelayerStellarPolicy, }, utils::mocks::mockutils::{ create_mock_app_state, create_mock_network, create_mock_notification, @@ -920,7 +921,6 @@ mod tests { ); // Add EVM policies - use crate::models::relayer::{CreateRelayerPolicyRequest, RelayerEvmPolicy}; request.policies = Some(CreateRelayerPolicyRequest::Evm(RelayerEvmPolicy { gas_price_cap: Some(50000000000), min_balance: Some(1000000000000000000), @@ -969,7 +969,6 @@ mod tests { ); // Add partial EVM policies - use crate::models::relayer::{CreateRelayerPolicyRequest, RelayerEvmPolicy}; request.policies = Some(CreateRelayerPolicyRequest::Evm(RelayerEvmPolicy { gas_price_cap: Some(30000000000), eip1559_pricing: Some(false), @@ -1014,10 +1013,6 @@ mod tests { ); // Change network type to Solana and add Solana policies - use crate::models::relayer::{ - CreateRelayerPolicyRequest, RelayerNetworkType, RelayerSolanaFeePaymentStrategy, - RelayerSolanaPolicy, - }; request.network_type = RelayerNetworkType::Solana; request.policies = Some(CreateRelayerPolicyRequest::Solana(RelayerSolanaPolicy { fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), @@ -1084,9 +1079,6 @@ mod tests { ); // Change network type to Stellar and add Stellar policies - use crate::models::relayer::{ - CreateRelayerPolicyRequest, RelayerNetworkType, RelayerStellarPolicy, - }; request.network_type = RelayerNetworkType::Stellar; request.policies = Some(CreateRelayerPolicyRequest::Stellar(RelayerStellarPolicy { min_balance: Some(10000000), @@ -1130,9 +1122,6 @@ mod tests { ); // Set network type to EVM but provide Solana policies (should fail) - use crate::models::relayer::{ - CreateRelayerPolicyRequest, RelayerNetworkType, RelayerSolanaPolicy, - }; request.network_type = RelayerNetworkType::Evm; request.policies = Some(CreateRelayerPolicyRequest::Solana( RelayerSolanaPolicy::default(), diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index c69729993..b1f4c15d1 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -6,8 +6,9 @@ use crate::{ config::{Config, RepositoryStorageType, ServerConfig}, jobs::JobProducerTrait, models::{ - signer::Signer, NetworkRepoModel, NotificationRepoModel, PluginModel, RelayerRepoModel, - SignerFileConfig, SignerRepoModel, ThinDataAppState, TransactionRepoModel, + signer::Signer, NetworkRepoModel, NotificationRepoModel, PluginModel, Relayer, + RelayerRepoModel, SignerFileConfig, SignerRepoModel, ThinDataAppState, + TransactionRepoModel, }, repositories::{ NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, @@ -210,7 +211,7 @@ where let relayer_futures = config_file.relayers.iter().map(|relayer| async { // Convert config to domain model first, then to repository model - let domain_relayer = crate::models::Relayer::try_from(relayer.clone()) + let domain_relayer = Relayer::try_from(relayer.clone()) .wrap_err("Failed to convert relayer config to domain model")?; let mut repo_model = RelayerRepoModel::from(domain_relayer); let signer_model = signers From 13fe94d10c88ec60bd27b98e3216f2cf6a0849f1 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 28 Jul 2025 09:19:57 +0200 Subject: [PATCH 49/59] chore: impr --- config/config-v1.json | 93 ------------------------------ src/api/routes/docs/signer_docs.rs | 10 ++-- 2 files changed, 5 insertions(+), 98 deletions(-) delete mode 100644 config/config-v1.json diff --git a/config/config-v1.json b/config/config-v1.json deleted file mode 100644 index 0fd04b5a6..000000000 --- a/config/config-v1.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "relayers": [ - { - "id": "sepolia-example", - "name": "Sepolia Example", - "network": "sepolia", - "paused": false, - "notification_id": "notification-example", - "signer_id": "local-signer", - "network_type": "evm", - "custom_rpc_urls": ["https://eth-sepolia.public.blastapi.io"], - "policies": { - "min_balance": 0 - } - }, - { - "id": "solana-example", - "name": "Solana Example", - "network": "devnet", - "paused": false, - "notification_id": "notification-example", - "signer_id": "local-signer", - "network_type": "solana", - "policies": { - "fee_payment_strategy":"relayer", - "min_balance": 0, - "allowed_tokens": [ - { - "mint": "Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr", - "max_allowed_fee": 100000000 - } - - ] - } - } - ], - "notifications": [ - { - "id": "notification-example", - "type": "webhook", - "url": "https://webhook.site/1972ef96-2704-4a81-835d-7c81ffa799b6", - "signing_key": { - "type": "env", - "value": "WEBHOOK_SIGNING_KEY" - } - } - ], - "signers": [ - { - "id": "local-signer", - "type": "local", - "config": { - "path": "config/keys/local-signer.json", - "passphrase": { - "type": "plain", - "value": "test" - } - } - }, - { - "id": "turnkey-signer-evm", - "type": "turnkey", - "config": { - "api_public_key": "02722613643282c5af96ccb3b1224ee4fec97f9e0e925b7dbafcffc454e4a262b0", - "api_private_key": { - "type": "plain", - "value": "b4f8fcdb3fff65d74fa1b1c2d4e2f542f6d625e238f92234e82ebc8ae6138612" - }, - "organization_id": "ff00650a-2eca-4fb4-b257-87b031ddfd09", - "private_key_id": "a9b3a2a2-945c-4e77-9298-459a31dbd92c", - "public_key": "047d3bb8e0317927700cf19fed34e0627367be1390ec247dddf8c239e4b4321a49aea80090e49b206b6a3e577a4f11d721ab063482001ee10db40d6f2963233eec" - } - }, - { - "id": "local-vault", - "type": "vault", - "config": { - "address": "http://0.0.0.0:8200", - "role_id": { - "type": "plain", - "value": "82708e4e-ecff-b103-71fd-c3c0136c84f4" - }, - "secret_id": { - "type": "plain", - "value": "be01cffa-fc0d-295f-54d0-0bf0cc45e390" - }, - "key_name": "my-app" - } - } - ], - "networks": "./config/networks", - "plugins": [] -} diff --git a/src/api/routes/docs/signer_docs.rs b/src/api/routes/docs/signer_docs.rs index 82c434b17..c85463a18 100644 --- a/src/api/routes/docs/signer_docs.rs +++ b/src/api/routes/docs/signer_docs.rs @@ -1,6 +1,6 @@ use crate::models::{ApiResponse, SignerCreateRequest, SignerResponse, SignerUpdateRequest}; -/// Notification routes implementation +/// Signer routes implementation /// /// Note: OpenAPI documentation for these endpoints can be found in the `openapi.rs` file /// @@ -58,7 +58,7 @@ use crate::models::{ApiResponse, SignerCreateRequest, SignerResponse, SignerUpda #[allow(dead_code)] fn doc_list_signers() {} -/// Retrieves details of a specific notification by ID. +/// Retrieves details of a specific signer by ID. #[utoipa::path( get, path = "/api/v1/signers/{signer_id}", @@ -121,7 +121,7 @@ fn doc_list_signers() {} #[allow(dead_code)] fn doc_get_signer() {} -/// Creates a new notification. +/// Creates a new signer. #[utoipa::path( post, path = "/api/v1/signers", @@ -182,7 +182,7 @@ fn doc_get_signer() {} #[allow(dead_code)] fn doc_create_signer() {} -/// Updates an existing notification. +/// Updates an existing signer. #[utoipa::path( patch, path = "/api/v1/signers/{signer_id}", @@ -246,7 +246,7 @@ fn doc_create_signer() {} #[allow(dead_code)] fn doc_update_signer() {} -/// Deletes a notification by ID. +/// Deletes a signer by ID. #[utoipa::path( delete, path = "/api/v1/signers/{signer_id}", From 03a0cdb92d97388e4e499cbf1c28df9902cfd8a9 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 28 Jul 2025 09:27:28 +0200 Subject: [PATCH 50/59] chore: improvements --- Cargo.toml | 4 + docs/ENCRYPTION.md | 249 ------------------------------ helpers/generate_encyption_key.rs | 85 ++++++++++ 3 files changed, 89 insertions(+), 249 deletions(-) delete mode 100644 docs/ENCRYPTION.md create mode 100644 helpers/generate_encyption_key.rs diff --git a/Cargo.toml b/Cargo.toml index bba3d61f1..59f67cb87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -117,6 +117,10 @@ path = "helpers/create_key.rs" name = "generate_uuid" path = "helpers/generate_uuid.rs" +[[example]] +name = "generate_encryption_key" +path = "helpers/generate_encyption_key.rs" + [[example]] name = "generate_openapi" path = "helpers/generate_openapi.rs" diff --git a/docs/ENCRYPTION.md b/docs/ENCRYPTION.md deleted file mode 100644 index f3225f62c..000000000 --- a/docs/ENCRYPTION.md +++ /dev/null @@ -1,249 +0,0 @@ -# Field-Level Encryption for Sensitive Data - -The OpenZeppelin Relayer now includes field-level encryption to protect sensitive data at rest in Redis. This feature ensures that private keys, API secrets, and other sensitive information are encrypted before being stored. - -## Overview - -The encryption system uses **AES-256-GCM** encryption with the following features: - -- **Transparent encryption/decryption**: Happens automatically in the repository layer -- **Field-level encryption**: Only sensitive fields are encrypted, not entire records -- **Backward compatibility**: Can read both encrypted and legacy base64-encoded data -- **Memory protection**: Uses `SecretString` and `SecretVec` for in-memory protection -- **Authenticated encryption**: AES-GCM provides both confidentiality and integrity - -## Protected Data - -The following sensitive fields are automatically encrypted: - -### Signer Configurations -- **Private keys** (`raw_key` in LocalSignerConfig) -- **API secrets** (Turnkey, Vault, Google Cloud KMS credentials) -- **Role IDs and Secret IDs** (Vault authentication) -- **Service account private keys** (Google Cloud KMS) - -### Other Sensitive Fields -- Any field using `SecretString` type -- Custom sensitive fields marked for encryption - -## Setup - -### 1. Generate Encryption Key - -Generate a 32-byte encryption key using OpenSSL: - -```bash -# Generate and export the key -export STORAGE_ENCRYPTION_KEY=$(openssl rand -base64 32) - -# Or generate hex-encoded key (alternative) -export STORAGE_ENCRYPTION_KEY_HEX=$(openssl rand -hex 32) -``` - -### 2. Environment Configuration - -Set one of the following environment variables: - -```bash -# Option 1: Base64-encoded key (recommended) -export STORAGE_ENCRYPTION_KEY="your-base64-encoded-32-byte-key" - -# Option 2: Hex-encoded key (alternative) -export STORAGE_ENCRYPTION_KEY_HEX="your-hex-encoded-32-byte-key" -``` - -### 3. Production Deployment - -For production deployments, consider using: - -- **Container secrets**: Mount the key as a secret volume -- **Environment injection**: Use your orchestration platform's secret management -- **External secret management**: AWS Secrets Manager, HashiCorp Vault, etc. - -Example Docker Compose: -```yaml -services: - relayer: - image: openzeppelin/relayer - environment: - - STORAGE_ENCRYPTION_KEY=${ENCRYPTION_KEY} - secrets: - - encryption_key - -secrets: - encryption_key: - external: true -``` - -## How It Works - -### Encryption Process - -1. **Data Input**: Sensitive data enters as plaintext -2. **Encryption**: Data is encrypted with AES-256-GCM using a random nonce -3. **Storage**: Encrypted data structure (nonce + ciphertext + version) is stored directly as JSON - -### Decryption Process - -1. **Data Retrieval**: JSON-encoded encrypted data is retrieved from storage -2. **Decryption**: Data is decrypted using the configured encryption key -3. **Fallback**: If encryption is not configured, data is treated as JSON-encoded strings - -### Data Format - -Encrypted data is stored directly as JSON with this structure: -```json -{ - "nonce": "base64-encoded-12-byte-nonce", - "ciphertext": "base64-encoded-encrypted-data-with-auth-tag", - "version": 1 -} -``` - -When encryption is disabled (development mode), data is stored as simple JSON strings. - -## Migration - -### For New Deployments -- Set up encryption key before first run -- All sensitive data will be encrypted from the start - -### For Existing Deployments -- **Data migration required** if you have existing sensitive data -- Export existing data before upgrading -- Set up encryption key -- Re-import data (will be encrypted during import) - -### Migration Steps -If you have existing deployments with sensitive data: - -1. **Backup**: Export all existing signer configurations -2. **Setup**: Configure encryption keys in your environment -3. **Clear**: Remove existing signer data from Redis (optional but recommended) -4. **Import**: Re-create signers through the API (data will be automatically encrypted) -5. **Verify**: Test that encrypted data can be properly read and used - -## Security Considerations - -### Key Management -- **Never commit keys to version control** -- **Rotate keys periodically** (requires data re-encryption) -- **Use secure key storage** in production -- **Limit key access** to essential personnel only - -### Operational Security -- **Monitor key access logs** -- **Use different keys per environment** -- **Implement key backup and recovery procedures** -- **Consider HSM integration** for high-security environments - -### Development vs Production -- **Development**: Can run without encryption (falls back to base64) -- **Production**: Always require encryption keys -- **Testing**: Use test-specific keys, never production keys - -## Troubleshooting - -### Common Issues - -#### 1. Key Not Found Error -``` -Missing encryption key environment variable: Either STORAGE_ENCRYPTION_KEY (base64) or STORAGE_ENCRYPTION_KEY_HEX (hex) must be set -``` -**Solution**: Set one of the required environment variables. - -#### 2. Invalid Key Length -``` -Invalid key length: expected 32 bytes, got X -``` -**Solution**: Ensure your key is exactly 32 bytes when decoded. - -#### 3. Decryption Failed -``` -Failed to decrypt raw_key: Decryption failed -``` -**Possible causes**: -- Wrong encryption key -- Corrupted data -- Mixed data from different keys - -### Validation - -Test your encryption setup: - -```bash -# Check if encryption is configured -curl http://localhost:3000/health - -# Create a test signer to verify encryption works -curl -X POST http://localhost:3000/relayers \ - -H "Content-Type: application/json" \ - -d '{"id": "test", "type": "local", "config": {...}}' -``` - -### Logging - -Enable debug logging to see encryption operations: -```bash -RUST_LOG=debug ./openzeppelin-relayer -``` - -Look for log messages like: -- `"Retrieved signer with ID: ..."` -- `"Created signer with ID: ..."` - -## Performance Impact - -### Encryption Overhead -- **CPU**: Minimal overhead (~1-5% for typical workloads) -- **Memory**: Slight increase due to JSON structure -- **Storage**: ~15-25% increase due to encryption metadata (reduced from previous base64-in-base64 format) - -### Optimization Tips -- **Batch operations**: Already optimized for bulk reads/writes -- **Connection pooling**: Use Redis connection pooling (already implemented) -- **Monitoring**: Monitor Redis performance metrics - -## Compliance and Auditing - -### Standards Compliance -- **Encryption**: AES-256-GCM (NIST approved) -- **Key size**: 256-bit keys -- **Nonces**: Cryptographically secure random generation -- **Authentication**: Integrated with GCM mode - -### Audit Trail -- All encryption/decryption operations are logged -- Failed decryption attempts are logged as warnings -- Key access should be monitored externally - -### Data Protection -- **Data at rest**: Encrypted in Redis -- **Data in transit**: Use TLS for Redis connections -- **Data in memory**: Protected with `SecretString`/`SecretVec` -- **Data in logs**: Sensitive data is redacted from logs - -## Example Implementation - -Here's how encryption works in practice: - -```rust -// Creating a signer (automatically encrypted) -let signer = SignerRepoModel { - id: "my-signer".to_string(), - config: SignerConfig::Local(LocalSignerConfig { - raw_key: SecretVec::new(32, |buf| { - buf.copy_from_slice(&private_key_bytes); - }), - }), -}; - -// This will be automatically encrypted before storage -repository.create(signer).await?; - -// Reading a signer (automatically decrypted) -let retrieved_signer = repository.get_by_id("my-signer".to_string()).await?; -// retrieved_signer contains decrypted data, ready to use -``` - -The encryption/decryption happens transparently - your application code doesn't need to change. \ No newline at end of file diff --git a/helpers/generate_encyption_key.rs b/helpers/generate_encyption_key.rs new file mode 100644 index 000000000..a2d5bc55f --- /dev/null +++ b/helpers/generate_encyption_key.rs @@ -0,0 +1,85 @@ +//! Encryption Key Generation Tool +//! +//! This tool generates a random 32-byte encryption key using OpenSSL and prints it to the console. +//! +//! # Usage +//! +//! ```bash +//! cargo run --example generate_encryption_key +//! ``` +//! +//! # Requirements +//! +//! This tool requires OpenSSL to be installed and available in the system PATH. +use eyre::{eyre, Result}; +use std::process::Command; + +/// Main entry point for encryption key generation tool +fn main() -> Result<()> { + let encryption_key = generate_encryption_key()?; + println!("Generated new encryption key: {}", encryption_key); + Ok(()) +} + +/// Generates a 32-byte base64-encoded encryption key using OpenSSL +fn generate_encryption_key() -> Result { + let output = Command::new("openssl") + .args(["rand", "-base64", "32"]) + .output() + .map_err(|e| eyre!("Failed to execute openssl command: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(eyre!("OpenSSL command failed: {}", stderr)); + } + + let key = String::from_utf8(output.stdout) + .map_err(|e| eyre!("Failed to parse openssl output as UTF-8: {}", e))? + .trim() + .to_string(); + + if key.is_empty() { + return Err(eyre!("OpenSSL returned empty key")); + } + + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose, Engine as _}; + + #[test] + fn test_encryption_key_generation() { + let key = generate_encryption_key(); + assert!(key.is_ok(), "Failed to generate encryption key"); + + let key_string = key.unwrap(); + assert!(!key_string.is_empty(), "Generated key should not be empty"); + + // Verify it's valid base64 + let decoded = general_purpose::STANDARD.decode(&key_string); + assert!(decoded.is_ok(), "Generated key is not valid base64"); + + // Verify it's 32 bytes when decoded + let decoded_bytes = decoded.unwrap(); + assert_eq!(decoded_bytes.len(), 32, "Decoded key should be 32 bytes"); + } + + #[test] + fn test_multiple_keys_are_different() { + let key1 = generate_encryption_key(); + let key2 = generate_encryption_key(); + + assert!( + key1.is_ok() && key2.is_ok(), + "Both key generations should succeed" + ); + assert_ne!( + key1.unwrap(), + key2.unwrap(), + "Two generated keys should be different" + ); + } +} From 070c916e633bad6f496118799f1541f9ba53ea61 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 28 Jul 2025 09:39:38 +0200 Subject: [PATCH 51/59] chore: format --- src/api/routes/relayer.rs | 71 +++++++++++++++---------------- src/bootstrap/config_processor.rs | 5 +-- 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/src/api/routes/relayer.rs b/src/api/routes/relayer.rs index b728389e8..424dee183 100644 --- a/src/api/routes/relayer.rs +++ b/src/api/routes/relayer.rs @@ -210,7 +210,12 @@ mod tests { use crate::{ config::{EvmNetworkConfig, NetworkConfigCommon}, jobs::MockJobProducerTrait, - models::AppState, + models::{ + AppState, EvmTransactionData, LocalSignerConfigStorage, NetworkConfigData, + NetworkRepoModel, NetworkTransactionData, NetworkType, RelayerEvmPolicy, + RelayerNetworkPolicy, RelayerRepoModel, SignerConfigStorage, SignerRepoModel, + TransactionRepoModel, TransactionStatus, U256, + }, repositories::{ NetworkRepositoryStorage, NotificationRepositoryStorage, PluginRepositoryStorage, RelayerRepositoryStorage, Repository, SignerRepositoryStorage, @@ -239,11 +244,11 @@ mod tests { // Create test entities so routes don't return 404 // Create test network configuration first - let test_network = crate::models::NetworkRepoModel { + let test_network = NetworkRepoModel { id: "evm:ethereum".to_string(), name: "ethereum".to_string(), - network_type: crate::models::NetworkType::Evm, - config: crate::models::NetworkConfigData::Evm(EvmNetworkConfig { + network_type: NetworkType::Evm, + config: NetworkConfigData::Evm(EvmNetworkConfig { common: NetworkConfigCommon { network: "ethereum".to_string(), from: None, @@ -262,65 +267,59 @@ mod tests { network_repo.create(test_network).await.unwrap(); // Create local signer first - let test_signer = crate::models::SignerRepoModel { + let test_signer = SignerRepoModel { id: "test-signer".to_string(), - config: crate::models::SignerConfigStorage::Local( - crate::models::LocalSignerConfigStorage { - raw_key: secrets::SecretVec::new(32, |v| v.copy_from_slice(&[0u8; 32])), - }, - ), + config: SignerConfigStorage::Local(LocalSignerConfigStorage { + raw_key: secrets::SecretVec::new(32, |v| v.copy_from_slice(&[0u8; 32])), + }), }; signer_repo.create(test_signer).await.unwrap(); // Create test relayer - let test_relayer = crate::models::RelayerRepoModel { + let test_relayer = RelayerRepoModel { id: "test-id".to_string(), name: "Test Relayer".to_string(), network: "ethereum".to_string(), - network_type: crate::models::NetworkType::Evm, + network_type: NetworkType::Evm, signer_id: "test-signer".to_string(), address: "0x1234567890123456789012345678901234567890".to_string(), paused: false, system_disabled: false, - policies: crate::models::RelayerNetworkPolicy::Evm( - crate::models::RelayerEvmPolicy::default(), - ), + policies: RelayerNetworkPolicy::Evm(RelayerEvmPolicy::default()), notification_id: None, custom_rpc_urls: None, }; relayer_repo.create(test_relayer).await.unwrap(); // Create test transaction - let test_transaction = crate::models::TransactionRepoModel { + let test_transaction = TransactionRepoModel { id: "tx-123".to_string(), relayer_id: "test-id".to_string(), - status: crate::models::TransactionStatus::Pending, + status: TransactionStatus::Pending, status_reason: None, created_at: chrono::Utc::now().to_rfc3339(), sent_at: None, confirmed_at: None, valid_until: None, - network_data: crate::models::NetworkTransactionData::Evm( - crate::models::EvmTransactionData { - gas_price: Some(20000000000u128), - gas_limit: Some(21000u64), - nonce: Some(1u64), - value: crate::models::U256::from(0u64), - data: Some("0x".to_string()), - from: "0x1234567890123456789012345678901234567890".to_string(), - to: Some("0x9876543210987654321098765432109876543210".to_string()), - chain_id: 1u64, - hash: Some("0xabcdef".to_string()), - signature: None, - speed: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - raw: None, - }, - ), + network_data: NetworkTransactionData::Evm(EvmTransactionData { + gas_price: Some(20000000000u128), + gas_limit: Some(21000u64), + nonce: Some(1u64), + value: U256::from(0u64), + data: Some("0x".to_string()), + from: "0x1234567890123456789012345678901234567890".to_string(), + to: Some("0x9876543210987654321098765432109876543210".to_string()), + chain_id: 1u64, + hash: Some("0xabcdef".to_string()), + signature: None, + speed: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + raw: None, + }), priced_at: None, hashes: vec!["0xabcdef".to_string()], - network_type: crate::models::NetworkType::Evm, + network_type: NetworkType::Evm, noop_count: None, is_canceled: Some(false), }; diff --git a/src/bootstrap/config_processor.rs b/src/bootstrap/config_processor.rs index 2a1bb4c64..50dc3036e 100644 --- a/src/bootstrap/config_processor.rs +++ b/src/bootstrap/config_processor.rs @@ -6,10 +6,9 @@ use crate::{ config::{Config, RepositoryStorageType, ServerConfig}, jobs::JobProducerTrait, models::{ - - NetworkRepoModel, NotificationRepoModel, PluginModel, RelayerRepoModel, + NetworkRepoModel, NotificationRepoModel, PluginModel, Relayer, RelayerRepoModel, Signer as SignerDomainModel, SignerFileConfig, SignerRepoModel, ThinDataAppState, - Relayer, TransactionRepoModel, + TransactionRepoModel, }, repositories::{ NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository, From 0e43020ac28f3ce40761e4dd84f5117594b3af94 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 28 Jul 2025 11:35:58 +0200 Subject: [PATCH 52/59] chore: improvements --- src/models/notification/request.rs | 5 +++++ src/models/relayer/request.rs | 6 ++++++ src/models/relayer/response.rs | 7 +++++++ src/models/signer/request.rs | 5 +++++ 4 files changed, 23 insertions(+) diff --git a/src/models/notification/request.rs b/src/models/notification/request.rs index 11281899a..1937047aa 100644 --- a/src/models/notification/request.rs +++ b/src/models/notification/request.rs @@ -17,10 +17,13 @@ use utoipa::ToSchema; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] #[serde(deny_unknown_fields)] pub struct NotificationCreateRequest { + #[schema(nullable = false)] pub id: Option, + #[schema(nullable = false)] pub r#type: Option, pub url: String, /// Optional signing key for securing webhook notifications + #[schema(nullable = false)] pub signing_key: Option, } @@ -28,7 +31,9 @@ pub struct NotificationCreateRequest { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] #[serde(deny_unknown_fields)] pub struct NotificationUpdateRequest { + #[schema(nullable = false)] pub r#type: Option, + #[schema(nullable = false)] pub url: Option, /// Optional signing key for securing webhook notifications. /// - None: don't change the existing signing key diff --git a/src/models/relayer/request.rs b/src/models/relayer/request.rs index db57faa2e..ce9e35eed 100644 --- a/src/models/relayer/request.rs +++ b/src/models/relayer/request.rs @@ -23,6 +23,7 @@ use utoipa::ToSchema; #[derive(Debug, Clone, Serialize, ToSchema)] #[serde(deny_unknown_fields)] pub struct CreateRelayerRequest { + #[schema(nullable = false)] pub id: Option, pub name: String, pub network: String, @@ -30,9 +31,13 @@ pub struct CreateRelayerRequest { pub network_type: RelayerNetworkType, /// Policies - will be deserialized based on the network_type field #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub policies: Option, + #[schema(nullable = false)] pub signer_id: String, + #[schema(nullable = false)] pub notification_id: Option, + #[schema(nullable = false)] pub custom_rpc_urls: Option>, } @@ -158,6 +163,7 @@ pub fn deserialize_policy_for_network_type( #[serde(deny_unknown_fields)] pub struct UpdateRelayerRequest { pub name: Option, + #[schema(nullable = false)] pub paused: Option, /// Raw policy JSON - will be validated against relayer's network type during application #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/models/relayer/response.rs b/src/models/relayer/response.rs index 8891c966b..e6e1ae4ec 100644 --- a/src/models/relayer/response.rs +++ b/src/models/relayer/response.rs @@ -72,12 +72,19 @@ pub struct RelayerResponse { /// Policies without redundant network_type tag - network type is available at top level /// Only included if user explicitly provided policies (not shown for empty/default policies) #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub policies: Option, pub signer_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub notification_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub custom_rpc_urls: Option>, // Runtime fields from repository model + #[schema(nullable = false)] pub address: Option, + #[schema(nullable = false)] pub system_disabled: Option, } diff --git a/src/models/signer/request.rs b/src/models/signer/request.rs index 302c0c680..6ee7942d5 100644 --- a/src/models/signer/request.rs +++ b/src/models/signer/request.rs @@ -39,10 +39,12 @@ pub struct AwsKmsSignerRequestConfig { #[serde(deny_unknown_fields)] pub struct VaultSignerRequestConfig { pub address: String, + #[schema(nullable = false)] pub namespace: Option, pub role_id: String, pub secret_id: String, pub key_name: String, + #[schema(nullable = false)] pub mount_point: Option, } @@ -52,10 +54,12 @@ pub struct VaultSignerRequestConfig { pub struct VaultTransitSignerRequestConfig { pub key_name: String, pub address: String, + #[schema(nullable = false)] pub namespace: Option, pub role_id: String, pub secret_id: String, pub pubkey: String, + #[schema(nullable = false)] pub mount_point: Option, } @@ -143,6 +147,7 @@ impl zeroize::Zeroize for SignerTypeRequest { #[serde(deny_unknown_fields)] pub struct SignerCreateRequest { /// Optional ID - if not provided, a UUID will be generated + #[schema(nullable = false)] pub id: Option, /// The type of signer #[serde(rename = "type")] From 2f94b8ce278652b6108eee15f7c2788f0d2f02c7 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 28 Jul 2025 12:30:28 +0200 Subject: [PATCH 53/59] feat: plat-6864 impre response schema and revert some model name changes to avoid sdk breaking changes --- src/api/controllers/relayer.rs | 12 +- src/domain/relayer/solana/dex/mod.rs | 8 +- .../solana/rpc/methods/fee_estimate.rs | 4 +- .../rpc/methods/get_supported_tokens.rs | 3 +- .../solana/rpc/methods/prepare_transaction.rs | 4 +- .../relayer/solana/rpc/methods/test_setup.rs | 6 +- .../rpc/methods/transfer_transaction.rs | 4 +- .../relayer/solana/rpc/methods/utils.rs | 4 +- .../relayer/solana/rpc/methods/validations.rs | 5 +- src/domain/relayer/solana/solana_relayer.rs | 7 +- src/models/mod.rs | 6 +- src/models/relayer/config.rs | 57 +++--- src/models/relayer/mod.rs | 172 ++++++++++-------- src/models/relayer/repository.rs | 16 +- src/models/relayer/request.rs | 13 +- src/models/relayer/response.rs | 32 ++-- 16 files changed, 178 insertions(+), 175 deletions(-) diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 862891963..586df4fbc 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -783,8 +783,8 @@ mod tests { use crate::{ models::{ ApiResponse, CreateRelayerPolicyRequest, CreateRelayerRequest, RelayerEvmPolicy, - RelayerNetworkPolicyResponse, RelayerNetworkType, RelayerResponse, - RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, RelayerStellarPolicy, + RelayerNetworkPolicyResponse, RelayerNetworkType, RelayerResponse, RelayerSolanaPolicy, + RelayerStellarPolicy, SolanaFeePaymentStrategy, }, utils::mocks::mockutils::{ create_mock_app_state, create_mock_network, create_mock_notification, @@ -1028,7 +1028,7 @@ mod tests { // Change network type to Solana and add Solana policies request.network_type = RelayerNetworkType::Solana; request.policies = Some(CreateRelayerPolicyRequest::Solana(RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), min_balance: Some(5000000), max_signatures: Some(10), max_tx_data_size: Some(1232), @@ -1063,7 +1063,7 @@ mod tests { if let RelayerNetworkPolicyResponse::Solana(solana_policy) = policies { assert_eq!( solana_policy.fee_payment_strategy, - Some(RelayerSolanaFeePaymentStrategy::Relayer) + Some(SolanaFeePaymentStrategy::Relayer) ); assert_eq!(solana_policy.min_balance, 5000000); assert_eq!(solana_policy.max_signatures, Some(10)); @@ -1837,7 +1837,7 @@ mod tests { #[actix_web::test] async fn test_update_relayer_solana_policies() { use crate::models::{ - NetworkType, RelayerNetworkPolicy, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, + NetworkType, RelayerNetworkPolicy, RelayerSolanaPolicy, SolanaFeePaymentStrategy, }; // Create a Solana relayer (not the default EVM one) @@ -1882,7 +1882,7 @@ mod tests { if let RelayerNetworkPolicyResponse::Solana(solana_policy) = policies { assert_eq!( solana_policy.fee_payment_strategy, - Some(RelayerSolanaFeePaymentStrategy::User) + Some(SolanaFeePaymentStrategy::User) ); assert_eq!(solana_policy.min_balance, 2000000); assert_eq!(solana_policy.max_signatures, Some(5)); diff --git a/src/domain/relayer/solana/dex/mod.rs b/src/domain/relayer/solana/dex/mod.rs index 26539d432..498fb55f2 100644 --- a/src/domain/relayer/solana/dex/mod.rs +++ b/src/domain/relayer/solana/dex/mod.rs @@ -148,7 +148,7 @@ mod tests { use crate::{ models::{ - LocalSignerConfigStorage, RelayerSolanaPolicy, RelayerSolanaSwapPolicy, + LocalSignerConfigStorage, RelayerSolanaPolicy, RelayerSolanaSwapConfig, SignerConfigStorage, SignerRepoModel, }, services::{MockSolanaProviderTrait, SolanaSignerFactory}, @@ -169,7 +169,7 @@ mod tests { fn test_create_network_dex_jupiter_swap_explicit() { let mut relayer = RelayerRepoModel::default(); let policy = crate::models::RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - swap_config: Some(RelayerSolanaSwapPolicy { + swap_config: Some(RelayerSolanaSwapConfig { strategy: Some(SolanaSwapStrategy::JupiterSwap), cron_schedule: None, min_balance_threshold: None, @@ -201,7 +201,7 @@ mod tests { fn test_create_network_dex_jupiter_ultra_explicit() { let mut relayer = RelayerRepoModel::default(); let policy = crate::models::RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - swap_config: Some(RelayerSolanaSwapPolicy { + swap_config: Some(RelayerSolanaSwapConfig { strategy: Some(SolanaSwapStrategy::JupiterUltra), cron_schedule: None, min_balance_threshold: None, @@ -233,7 +233,7 @@ mod tests { fn test_create_network_dex_default_when_no_strategy() { let mut relayer = RelayerRepoModel::default(); let policy = crate::models::RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - swap_config: Some(RelayerSolanaSwapPolicy { + swap_config: Some(RelayerSolanaSwapConfig { strategy: None, cron_schedule: None, min_balance_threshold: None, diff --git a/src/domain/relayer/solana/rpc/methods/fee_estimate.rs b/src/domain/relayer/solana/rpc/methods/fee_estimate.rs index 05e5831f0..c998c7e52 100644 --- a/src/domain/relayer/solana/rpc/methods/fee_estimate.rs +++ b/src/domain/relayer/solana/rpc/methods/fee_estimate.rs @@ -214,8 +214,8 @@ mod tests { setup_test_context, setup_test_context_single_tx_user_fee_strategy, SolanaRpcMethods, }, models::{ - AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, RelayerNetworkPolicy, - RelayerSolanaPolicy, SolanaAllowedTokensPolicy, + RelayerNetworkPolicy, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, + SolanaAllowedTokensSwapConfig, }, services::{ MockSolanaProviderTrait, QuoteResponse, RoutePlan, SolanaProviderError, SwapInfo, diff --git a/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs b/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs index 9f574218c..3e2505dc2 100644 --- a/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs +++ b/src/domain/relayer/solana/rpc/methods/get_supported_tokens.rs @@ -74,9 +74,8 @@ mod tests { use crate::{ domain::{setup_test_context, SolanaRpcMethodsImpl}, models::{ - AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, GetSupportedTokensRequestParams, RelayerNetworkPolicy, RelayerSolanaPolicy, - SolanaAllowedTokensPolicy, + SolanaAllowedTokensPolicy, SolanaAllowedTokensSwapConfig, }, }; diff --git a/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs b/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs index 5b984e95d..5021860d9 100644 --- a/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs @@ -234,8 +234,8 @@ mod tests { use crate::{ constants::WRAPPED_SOL_MINT, models::{ - AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, RelayerNetworkPolicy, - RelayerSolanaPolicy, SolanaAllowedTokensPolicy, + RelayerNetworkPolicy, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, + SolanaAllowedTokensSwapConfig, }, services::{QuoteResponse, RoutePlan, SwapInfo}, }; diff --git a/src/domain/relayer/solana/rpc/methods/test_setup.rs b/src/domain/relayer/solana/rpc/methods/test_setup.rs index 2813689f6..5f014c1e0 100644 --- a/src/domain/relayer/solana/rpc/methods/test_setup.rs +++ b/src/domain/relayer/solana/rpc/methods/test_setup.rs @@ -10,9 +10,9 @@ use std::str::FromStr; use crate::{ jobs::MockJobProducerTrait, models::{ - AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, EncodedSerializedTransaction, - NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerSolanaPolicy, - SolanaAllowedTokensPolicy, SolanaFeePaymentStrategy, + EncodedSerializedTransaction, NetworkType, RelayerNetworkPolicy, RelayerRepoModel, + RelayerSolanaPolicy, SolanaAllowedTokensPolicy, SolanaAllowedTokensSwapConfig, + SolanaFeePaymentStrategy, }, services::{MockJupiterServiceTrait, MockSolanaProviderTrait, MockSolanaSignTrait}, }; diff --git a/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs b/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs index 51b0886c5..bdd821227 100644 --- a/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs @@ -241,8 +241,8 @@ mod tests { use crate::{ constants::WRAPPED_SOL_MINT, models::{ - AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, NetworkType, - RelayerNetworkPolicy, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, + NetworkType, RelayerNetworkPolicy, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, + SolanaAllowedTokensSwapConfig, }, services::{QuoteResponse, RoutePlan, SwapInfo}, }; diff --git a/src/domain/relayer/solana/rpc/methods/utils.rs b/src/domain/relayer/solana/rpc/methods/utils.rs index 5c11b41a4..886e6fb4e 100644 --- a/src/domain/relayer/solana/rpc/methods/utils.rs +++ b/src/domain/relayer/solana/rpc/methods/utils.rs @@ -887,8 +887,8 @@ mod tests { use crate::{ constants::WRAPPED_SOL_MINT, models::{ - AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, RelayerNetworkPolicy, - RelayerSolanaPolicy, SolanaAllowedTokensPolicy, + RelayerNetworkPolicy, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, + SolanaAllowedTokensSwapConfig, }, services::{QuoteResponse, RoutePlan, SwapInfo}, }; diff --git a/src/domain/relayer/solana/rpc/methods/validations.rs b/src/domain/relayer/solana/rpc/methods/validations.rs index 34f664a64..d0154db41 100644 --- a/src/domain/relayer/solana/rpc/methods/validations.rs +++ b/src/domain/relayer/solana/rpc/methods/validations.rs @@ -561,10 +561,7 @@ impl SolanaTransactionValidator { #[cfg(test)] mod tests { use crate::{ - models::{ - AllowedToken as SolanaAllowedTokensPolicy, - AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, - }, + models::{relayer::SolanaAllowedTokensSwapConfig, SolanaAllowedTokensPolicy}, services::{MockSolanaProviderTrait, SolanaProviderError}, }; diff --git a/src/domain/relayer/solana/solana_relayer.rs b/src/domain/relayer/solana/solana_relayer.rs index ca2df9929..2c748d04d 100644 --- a/src/domain/relayer/solana/solana_relayer.rs +++ b/src/domain/relayer/solana/solana_relayer.rs @@ -743,10 +743,9 @@ mod tests { domain::create_network_dex_generic, jobs::MockJobProducerTrait, models::{ - AllowedTokenSwapConfig as SolanaAllowedTokensSwapConfig, EncodedSerializedTransaction, - FeeEstimateRequestParams, GetFeaturesEnabledRequestParams, JsonRpcId, - NetworkConfigData, NetworkRepoModel, - RelayerSolanaSwapPolicy as RelayerSolanaSwapConfig, SolanaRpcResult, + EncodedSerializedTransaction, FeeEstimateRequestParams, + GetFeaturesEnabledRequestParams, JsonRpcId, NetworkConfigData, NetworkRepoModel, + RelayerSolanaSwapConfig, SolanaAllowedTokensSwapConfig, SolanaRpcResult, SolanaSwapStrategy, }, repositories::{MockNetworkRepository, MockRelayerRepository, MockTransactionRepository}, diff --git a/src/models/mod.rs b/src/models/mod.rs index 5a4e6c540..e44847aeb 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -18,11 +18,7 @@ pub mod relayer; pub use relayer::*; // Type aliases for backward compatibility with domain logic -pub use relayer::{ - AllowedToken as SolanaAllowedTokensPolicy, - RelayerSolanaFeePaymentStrategy as SolanaFeePaymentStrategy, - RelayerSolanaSwapStrategy as SolanaSwapStrategy, -}; +pub use relayer::{SolanaAllowedTokensPolicy, SolanaFeePaymentStrategy, SolanaSwapStrategy}; mod error; pub use error::*; diff --git a/src/models/relayer/config.rs b/src/models/relayer/config.rs index c26f9f2a0..bc321d9be 100644 --- a/src/models/relayer/config.rs +++ b/src/models/relayer/config.rs @@ -61,7 +61,7 @@ pub struct AllowedToken { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "lowercase")] -pub enum ConfigFileRelayerSolanaFeePaymentStrategy { +pub enum ConfigFileSolanaFeePaymentStrategy { User, Relayer, } @@ -85,7 +85,7 @@ pub struct JupiterSwapOptions { #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] #[serde(deny_unknown_fields)] -pub struct ConfigFileRelayerSolanaSwapPolicy { +pub struct ConfigFileRelayerSolanaSwapConfig { /// DEX strategy to use for token swaps. pub strategy: Option, @@ -103,7 +103,7 @@ pub struct ConfigFileRelayerSolanaSwapPolicy { #[serde(deny_unknown_fields)] pub struct ConfigFileRelayerSolanaPolicy { /// Determines if the relayer pays the transaction fee or the user. Optional. - pub fee_payment_strategy: Option, + pub fee_payment_strategy: Option, /// Fee margin percentage for the relayer. Optional. pub fee_margin_percentage: Option, @@ -136,7 +136,7 @@ pub struct ConfigFileRelayerSolanaPolicy { pub max_allowed_fee_lamports: Option, /// Swap dex config to use for token swaps. Optional. - pub swap_config: Option, + pub swap_config: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] @@ -344,13 +344,13 @@ fn convert_config_policies_to_domain( } ConfigFileRelayerNetworkPolicy::Solana(solana_policy) => { let swap_config = if let Some(config_swap) = solana_policy.swap_config { - Some(super::RelayerSolanaSwapPolicy { + Some(super::RelayerSolanaSwapConfig { strategy: config_swap.strategy.map(|s| match s { ConfigFileRelayerSolanaSwapStrategy::JupiterSwap => { - super::RelayerSolanaSwapStrategy::JupiterSwap + super::SolanaSwapStrategy::JupiterSwap } ConfigFileRelayerSolanaSwapStrategy::JupiterUltra => { - super::RelayerSolanaSwapStrategy::JupiterUltra + super::SolanaSwapStrategy::JupiterUltra } }), cron_schedule: config_swap.cron_schedule, @@ -375,26 +375,28 @@ fn convert_config_policies_to_domain( allowed_tokens: solana_policy.allowed_tokens.map(|tokens| { tokens .into_iter() - .map(|t| super::AllowedToken { + .map(|t| super::SolanaAllowedTokensPolicy { mint: t.mint, decimals: t.decimals, symbol: t.symbol, max_allowed_fee: t.max_allowed_fee, - swap_config: t.swap_config.map(|sc| super::AllowedTokenSwapConfig { - slippage_percentage: sc.slippage_percentage, - min_amount: sc.min_amount, - max_amount: sc.max_amount, - retain_min_amount: sc.retain_min_amount, + swap_config: t.swap_config.map(|sc| { + super::SolanaAllowedTokensSwapConfig { + slippage_percentage: sc.slippage_percentage, + min_amount: sc.min_amount, + max_amount: sc.max_amount, + retain_min_amount: sc.retain_min_amount, + } }), }) .collect() }), fee_payment_strategy: solana_policy.fee_payment_strategy.map(|s| match s { - ConfigFileRelayerSolanaFeePaymentStrategy::User => { - super::RelayerSolanaFeePaymentStrategy::User + ConfigFileSolanaFeePaymentStrategy::User => { + super::SolanaFeePaymentStrategy::User } - ConfigFileRelayerSolanaFeePaymentStrategy::Relayer => { - super::RelayerSolanaFeePaymentStrategy::Relayer + ConfigFileSolanaFeePaymentStrategy::Relayer => { + super::SolanaFeePaymentStrategy::Relayer } }), fee_margin_percentage: solana_policy.fee_margin_percentage, @@ -488,7 +490,7 @@ impl RelayersFileConfig { mod tests { use super::*; use crate::config::ConfigFileNetworkType; - use crate::models::relayer::{RelayerSolanaFeePaymentStrategy, RelayerSolanaSwapStrategy}; + use crate::models::relayer::{SolanaFeePaymentStrategy, SolanaSwapStrategy}; use serde_json; fn create_test_networks_config() -> NetworksFileConfig { @@ -607,7 +609,7 @@ mod tests { if let Some(ConfigFileRelayerNetworkPolicy::Solana(solana_policy)) = config.policies { assert_eq!( solana_policy.fee_payment_strategy, - Some(ConfigFileRelayerSolanaFeePaymentStrategy::Relayer) + Some(ConfigFileSolanaFeePaymentStrategy::Relayer) ); assert_eq!(solana_policy.min_balance, Some(5000000)); assert_eq!(solana_policy.max_signatures, Some(8)); @@ -799,7 +801,7 @@ mod tests { #[test] fn test_convert_config_policies_to_domain_solana() { let config_policy = ConfigFileRelayerNetworkPolicy::Solana(ConfigFileRelayerSolanaPolicy { - fee_payment_strategy: Some(ConfigFileRelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(ConfigFileSolanaFeePaymentStrategy::User), fee_margin_percentage: Some(1.5), min_balance: Some(3000000), allowed_tokens: Some(vec![AllowedToken { @@ -820,7 +822,7 @@ mod tests { max_tx_data_size: Some(2048), max_signatures: Some(10), max_allowed_fee_lamports: Some(100000), - swap_config: Some(ConfigFileRelayerSolanaSwapPolicy { + swap_config: Some(ConfigFileRelayerSolanaSwapConfig { strategy: Some(ConfigFileRelayerSolanaSwapStrategy::JupiterUltra), cron_schedule: Some("0 */6 * * *".to_string()), min_balance_threshold: Some(2000000), @@ -837,7 +839,7 @@ mod tests { if let RelayerNetworkPolicy::Solana(solana_policy) = domain_policy { assert_eq!( solana_policy.fee_payment_strategy, - Some(RelayerSolanaFeePaymentStrategy::User) + Some(SolanaFeePaymentStrategy::User) ); assert_eq!(solana_policy.fee_margin_percentage, Some(1.5)); assert_eq!(solana_policy.min_balance, Some(3000000)); @@ -856,10 +858,7 @@ mod tests { // Test swap config conversion assert!(solana_policy.swap_config.is_some()); let swap_config = solana_policy.swap_config.unwrap(); - assert_eq!( - swap_config.strategy, - Some(RelayerSolanaSwapStrategy::JupiterUltra) - ); + assert_eq!(swap_config.strategy, Some(SolanaSwapStrategy::JupiterUltra)); assert_eq!(swap_config.cron_schedule, Some("0 */6 * * *".to_string())); assert_eq!(swap_config.min_balance_threshold, Some(2000000)); } else { @@ -946,7 +945,7 @@ mod tests { network_type: ConfigFileNetworkType::Solana, policies: Some(ConfigFileRelayerNetworkPolicy::Solana( ConfigFileRelayerSolanaPolicy { - fee_payment_strategy: Some(ConfigFileRelayerSolanaFeePaymentStrategy::Relayer), + fee_payment_strategy: Some(ConfigFileSolanaFeePaymentStrategy::Relayer), fee_margin_percentage: None, min_balance: Some(4000000), allowed_tokens: None, @@ -977,7 +976,7 @@ mod tests { if let Some(RelayerNetworkPolicy::Solana(solana_policy)) = domain_relayer.policies { assert_eq!( solana_policy.fee_payment_strategy, - Some(RelayerSolanaFeePaymentStrategy::Relayer) + Some(SolanaFeePaymentStrategy::Relayer) ); assert_eq!(solana_policy.min_balance, Some(4000000)); assert_eq!(solana_policy.max_signatures, Some(7)); @@ -1199,7 +1198,7 @@ mod tests { assert_eq!(evm_policy, deserialized); let solana_policy = ConfigFileRelayerSolanaPolicy { - fee_payment_strategy: Some(ConfigFileRelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(ConfigFileSolanaFeePaymentStrategy::User), fee_margin_percentage: Some(3.0), min_balance: Some(6000000), allowed_tokens: None, diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index 1647189d8..0f75fb562 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -108,38 +108,46 @@ pub struct RelayerEvmPolicy { /// Solana token swap configuration #[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq, Default)] #[serde(deny_unknown_fields)] -pub struct AllowedTokenSwapConfig { +pub struct SolanaAllowedTokensSwapConfig { /// Conversion slippage percentage for token. Optional. + #[schema(nullable = false)] pub slippage_percentage: Option, /// Minimum amount of tokens to swap. Optional. + #[schema(nullable = false)] pub min_amount: Option, /// Maximum amount of tokens to swap. Optional. + #[schema(nullable = false)] pub max_amount: Option, /// Minimum amount of tokens to retain after swap. Optional. + #[schema(nullable = false)] pub retain_min_amount: Option, } /// Configuration for allowed token handling on Solana #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] #[serde(deny_unknown_fields)] -pub struct AllowedToken { +pub struct SolanaAllowedTokensPolicy { pub mint: String, #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub decimals: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub symbol: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub max_allowed_fee: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub swap_config: Option, + #[schema(nullable = false)] + pub swap_config: Option, } -impl AllowedToken { +impl SolanaAllowedTokensPolicy { /// Create a new AllowedToken with required parameters pub fn new( mint: String, max_allowed_fee: Option, - swap_config: Option, + swap_config: Option, ) -> Self { Self { mint, @@ -154,7 +162,7 @@ impl AllowedToken { pub fn new_partial( mint: String, max_allowed_fee: Option, - swap_config: Option, + swap_config: Option, ) -> Self { Self::new(mint, max_allowed_fee, swap_config) } @@ -163,7 +171,7 @@ impl AllowedToken { /// Solana fee payment strategy #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Default)] #[serde(rename_all = "lowercase")] -pub enum RelayerSolanaFeePaymentStrategy { +pub enum SolanaFeePaymentStrategy { #[default] User, Relayer, @@ -172,7 +180,7 @@ pub enum RelayerSolanaFeePaymentStrategy { /// Solana swap strategy #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Default)] #[serde(rename_all = "kebab-case")] -pub enum RelayerSolanaSwapStrategy { +pub enum SolanaSwapStrategy { JupiterSwap, JupiterUltra, #[default] @@ -184,23 +192,30 @@ pub enum RelayerSolanaSwapStrategy { #[serde(deny_unknown_fields)] pub struct JupiterSwapOptions { /// Maximum priority fee (in lamports) for a transaction. Optional. + #[schema(nullable = false)] pub priority_fee_max_lamports: Option, /// Priority. Optional. + #[schema(nullable = false)] pub priority_level: Option, + #[schema(nullable = false)] pub dynamic_compute_unit_limit: Option, } /// Solana swap policy configuration #[derive(Debug, Serialize, Deserialize, Clone, ToSchema, PartialEq, Default)] #[serde(deny_unknown_fields)] -pub struct RelayerSolanaSwapPolicy { +pub struct RelayerSolanaSwapConfig { /// DEX strategy to use for token swaps. - pub strategy: Option, + #[schema(nullable = false)] + pub strategy: Option, /// Cron schedule for executing token swap logic to keep relayer funded. Optional. + #[schema(nullable = false)] pub cron_schedule: Option, /// Min sol balance to execute token swap logic to keep relayer funded. Optional. + #[schema(nullable = false)] pub min_balance_threshold: Option, /// Swap options for JupiterSwap strategy. Optional. + #[schema(nullable = false)] pub jupiter_swap_options: Option, } @@ -217,9 +232,9 @@ pub struct RelayerSolanaPolicy { #[serde(skip_serializing_if = "Option::is_none")] pub min_balance: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub allowed_tokens: Option>, + pub allowed_tokens: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub fee_payment_strategy: Option, + pub fee_payment_strategy: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fee_margin_percentage: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -229,17 +244,17 @@ pub struct RelayerSolanaPolicy { #[serde(skip_serializing_if = "Option::is_none")] pub max_allowed_fee_lamports: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub swap_config: Option, + pub swap_config: Option, } impl RelayerSolanaPolicy { /// Get allowed tokens for this policy - pub fn get_allowed_tokens(&self) -> Vec { + pub fn get_allowed_tokens(&self) -> Vec { self.allowed_tokens.clone().unwrap_or_default() } /// Get allowed token entry by mint address - pub fn get_allowed_token_entry(&self, mint: &str) -> Option { + pub fn get_allowed_token_entry(&self, mint: &str) -> Option { self.allowed_tokens .clone() .unwrap_or_default() @@ -248,7 +263,7 @@ impl RelayerSolanaPolicy { } /// Get swap configuration for this policy - pub fn get_swap_config(&self) -> Option { + pub fn get_swap_config(&self) -> Option { self.swap_config.clone() } @@ -500,12 +515,12 @@ impl Relayer { /// Validates Solana swap configuration fn validate_solana_swap_config( &self, - swap_config: &RelayerSolanaSwapPolicy, + swap_config: &RelayerSolanaSwapConfig, policy: &RelayerSolanaPolicy, ) -> Result<(), RelayerValidationError> { // Swap config only supported for user fee payment strategy if let Some(fee_payment_strategy) = &policy.fee_payment_strategy { - if *fee_payment_strategy == RelayerSolanaFeePaymentStrategy::Relayer { + if *fee_payment_strategy == SolanaFeePaymentStrategy::Relayer { return Err(RelayerValidationError::InvalidPolicy( "Swap config only supported for user fee payment strategy".into(), )); @@ -515,8 +530,7 @@ impl Relayer { // Validate strategy-specific restrictions if let Some(strategy) = &swap_config.strategy { match strategy { - RelayerSolanaSwapStrategy::JupiterSwap - | RelayerSolanaSwapStrategy::JupiterUltra => { + SolanaSwapStrategy::JupiterSwap | SolanaSwapStrategy::JupiterUltra => { if self.network != "mainnet-beta" { return Err(RelayerValidationError::InvalidPolicy(format!( "{:?} strategy is only supported on mainnet-beta", @@ -524,7 +538,7 @@ impl Relayer { ))); } } - RelayerSolanaSwapStrategy::Noop => { + SolanaSwapStrategy::Noop => { // No-op strategy doesn't need validation } } @@ -546,7 +560,7 @@ impl Relayer { // Validate Jupiter swap options if let Some(jupiter_options) = &swap_config.jupiter_swap_options { // Jupiter options only valid for JupiterSwap strategy - if swap_config.strategy != Some(RelayerSolanaSwapStrategy::JupiterSwap) { + if swap_config.strategy != Some(SolanaSwapStrategy::JupiterSwap) { return Err(RelayerValidationError::InvalidPolicy( "JupiterSwap options are only valid for JupiterSwap strategy".into(), )); @@ -799,7 +813,7 @@ mod tests { #[test] fn test_allowed_token_new() { - let token = AllowedToken::new( + let token = SolanaAllowedTokensPolicy::new( "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), Some(100000), None, @@ -814,14 +828,14 @@ mod tests { #[test] fn test_allowed_token_new_partial() { - let swap_config = AllowedTokenSwapConfig { + let swap_config = SolanaAllowedTokensSwapConfig { slippage_percentage: Some(0.5), min_amount: Some(1000), max_amount: Some(10000000), retain_min_amount: Some(500), }; - let token = AllowedToken::new_partial( + let token = SolanaAllowedTokensPolicy::new_partial( "TokenMint123".to_string(), Some(50000), Some(swap_config.clone()), @@ -843,14 +857,14 @@ mod tests { #[test] fn test_relayer_solana_fee_payment_strategy_default() { - let default_strategy = RelayerSolanaFeePaymentStrategy::default(); - assert_eq!(default_strategy, RelayerSolanaFeePaymentStrategy::User); + let default_strategy = SolanaFeePaymentStrategy::default(); + assert_eq!(default_strategy, SolanaFeePaymentStrategy::User); } #[test] fn test_relayer_solana_swap_strategy_default() { - let default_strategy = RelayerSolanaSwapStrategy::default(); - assert_eq!(default_strategy, RelayerSolanaSwapStrategy::Noop); + let default_strategy = SolanaSwapStrategy::default(); + assert_eq!(default_strategy, SolanaSwapStrategy::Noop); } #[test] @@ -863,7 +877,7 @@ mod tests { #[test] fn test_relayer_solana_swap_policy_default() { - let policy = RelayerSolanaSwapPolicy::default(); + let policy = RelayerSolanaSwapConfig::default(); assert_eq!(policy.strategy, None); assert_eq!(policy.cron_schedule, None); assert_eq!(policy.min_balance_threshold, None); @@ -888,8 +902,8 @@ mod tests { #[test] fn test_relayer_solana_policy_get_allowed_tokens() { - let token1 = AllowedToken::new("mint1".to_string(), Some(1000), None); - let token2 = AllowedToken::new("mint2".to_string(), Some(2000), None); + let token1 = SolanaAllowedTokensPolicy::new("mint1".to_string(), Some(1000), None); + let token2 = SolanaAllowedTokensPolicy::new("mint2".to_string(), Some(2000), None); let policy = RelayerSolanaPolicy { allowed_tokens: Some(vec![token1.clone(), token2.clone()]), @@ -909,8 +923,8 @@ mod tests { #[test] fn test_relayer_solana_policy_get_allowed_token_entry() { - let token1 = AllowedToken::new("mint1".to_string(), Some(1000), None); - let token2 = AllowedToken::new("mint2".to_string(), Some(2000), None); + let token1 = SolanaAllowedTokensPolicy::new("mint1".to_string(), Some(1000), None); + let token2 = SolanaAllowedTokensPolicy::new("mint2".to_string(), Some(2000), None); let policy = RelayerSolanaPolicy { allowed_tokens: Some(vec![token1.clone(), token2.clone()]), @@ -931,8 +945,8 @@ mod tests { #[test] fn test_relayer_solana_policy_get_swap_config() { - let swap_config = RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + let swap_config = RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterSwap), cron_schedule: Some("0 0 * * *".to_string()), min_balance_threshold: Some(1000000), jupiter_swap_options: None, @@ -953,10 +967,10 @@ mod tests { #[test] fn test_relayer_solana_policy_get_allowed_token_decimals() { - let mut token1 = AllowedToken::new("mint1".to_string(), Some(1000), None); + let mut token1 = SolanaAllowedTokensPolicy::new("mint1".to_string(), Some(1000), None); token1.decimals = Some(9); - let token2 = AllowedToken::new("mint2".to_string(), Some(2000), None); + let token2 = SolanaAllowedTokensPolicy::new("mint2".to_string(), Some(2000), None); // token2.decimals is None let policy = RelayerSolanaPolicy { @@ -1411,14 +1425,14 @@ mod tests { #[test] fn test_relayer_validation_solana_swap_config_wrong_fee_payment_strategy() { - let swap_config = RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), - ..RelayerSolanaSwapPolicy::default() + let swap_config = RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterSwap), + ..RelayerSolanaSwapConfig::default() }; let policy = RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), // Relayer strategy - swap_config: Some(swap_config), // But has swap config + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), // Relayer strategy + swap_config: Some(swap_config), // But has swap config ..RelayerSolanaPolicy::default() }; @@ -1445,13 +1459,13 @@ mod tests { #[test] fn test_relayer_validation_solana_jupiter_strategy_wrong_network() { - let swap_config = RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), - ..RelayerSolanaSwapPolicy::default() + let swap_config = RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterSwap), + ..RelayerSolanaSwapConfig::default() }; let policy = RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), swap_config: Some(swap_config), ..RelayerSolanaPolicy::default() }; @@ -1479,14 +1493,14 @@ mod tests { #[test] fn test_relayer_validation_solana_empty_cron_schedule() { - let swap_config = RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + let swap_config = RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterSwap), cron_schedule: Some("".to_string()), // Empty cron schedule - ..RelayerSolanaSwapPolicy::default() + ..RelayerSolanaSwapConfig::default() }; let policy = RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), swap_config: Some(swap_config), ..RelayerSolanaPolicy::default() }; @@ -1514,14 +1528,14 @@ mod tests { #[test] fn test_relayer_validation_solana_invalid_cron_schedule() { - let swap_config = RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + let swap_config = RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterSwap), cron_schedule: Some("invalid cron".to_string()), // Invalid cron format - ..RelayerSolanaSwapPolicy::default() + ..RelayerSolanaSwapConfig::default() }; let policy = RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), swap_config: Some(swap_config), ..RelayerSolanaPolicy::default() }; @@ -1555,14 +1569,14 @@ mod tests { dynamic_compute_unit_limit: Some(true), }; - let swap_config = RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterUltra), // Wrong strategy + let swap_config = RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterUltra), // Wrong strategy jupiter_swap_options: Some(jupiter_options), - ..RelayerSolanaSwapPolicy::default() + ..RelayerSolanaSwapConfig::default() }; let policy = RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), swap_config: Some(swap_config), ..RelayerSolanaPolicy::default() }; @@ -1596,14 +1610,14 @@ mod tests { dynamic_compute_unit_limit: Some(true), }; - let swap_config = RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + let swap_config = RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterSwap), jupiter_swap_options: Some(jupiter_options), - ..RelayerSolanaSwapPolicy::default() + ..RelayerSolanaSwapConfig::default() }; let policy = RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), swap_config: Some(swap_config), ..RelayerSolanaPolicy::default() }; @@ -1637,14 +1651,14 @@ mod tests { dynamic_compute_unit_limit: Some(true), }; - let swap_config = RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + let swap_config = RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterSwap), jupiter_swap_options: Some(jupiter_options), - ..RelayerSolanaSwapPolicy::default() + ..RelayerSolanaSwapConfig::default() }; let policy = RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), swap_config: Some(swap_config), ..RelayerSolanaPolicy::default() }; @@ -1678,14 +1692,14 @@ mod tests { dynamic_compute_unit_limit: Some(true), }; - let swap_config = RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + let swap_config = RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterSwap), jupiter_swap_options: Some(jupiter_options), - ..RelayerSolanaSwapPolicy::default() + ..RelayerSolanaSwapConfig::default() }; let policy = RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), swap_config: Some(swap_config), ..RelayerSolanaPolicy::default() }; @@ -1719,14 +1733,14 @@ mod tests { dynamic_compute_unit_limit: Some(true), }; - let swap_config = RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + let swap_config = RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterSwap), jupiter_swap_options: Some(jupiter_options), - ..RelayerSolanaSwapPolicy::default() + ..RelayerSolanaSwapConfig::default() }; let policy = RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), swap_config: Some(swap_config), ..RelayerSolanaPolicy::default() }; @@ -1760,14 +1774,14 @@ mod tests { dynamic_compute_unit_limit: Some(true), }; - let swap_config = RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + let swap_config = RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterSwap), jupiter_swap_options: Some(jupiter_options), - ..RelayerSolanaSwapPolicy::default() + ..RelayerSolanaSwapConfig::default() }; let policy = RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), swap_config: Some(swap_config), ..RelayerSolanaPolicy::default() }; diff --git a/src/models/relayer/repository.rs b/src/models/relayer/repository.rs index d6729f72a..d3c89b93f 100644 --- a/src/models/relayer/repository.rs +++ b/src/models/relayer/repository.rs @@ -131,8 +131,8 @@ impl From for RelayerRepoModel { #[cfg(test)] mod tests { use crate::models::{ - AllowedToken, RelayerEvmPolicy, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, - RelayerStellarPolicy, + RelayerEvmPolicy, RelayerSolanaPolicy, RelayerStellarPolicy, SolanaAllowedTokensPolicy, + SolanaFeePaymentStrategy, }; use super::*; @@ -163,7 +163,7 @@ mod tests { network_type: NetworkType::Solana, signer_id: "test_signer".to_string(), policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), min_balance: Some(1000000), max_signatures: Some(5), allowed_tokens: None, @@ -314,7 +314,7 @@ mod tests { assert_eq!(solana_policy.max_signatures, Some(5)); assert_eq!( solana_policy.fee_payment_strategy, - Some(RelayerSolanaFeePaymentStrategy::Relayer) + Some(SolanaFeePaymentStrategy::Relayer) ); } else { panic!("Expected Solana policy"); @@ -394,10 +394,10 @@ mod tests { paused: false, network_type: RelayerNetworkType::Solana, policies: Some(RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), min_balance: Some(5000000), max_signatures: Some(8), - allowed_tokens: Some(vec![AllowedToken::new( + allowed_tokens: Some(vec![SolanaAllowedTokensPolicy::new( "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), Some(100000), None, @@ -423,7 +423,7 @@ mod tests { if let RelayerNetworkPolicy::Solana(solana_policy) = repo_model.policies { assert_eq!( solana_policy.fee_payment_strategy, - Some(RelayerSolanaFeePaymentStrategy::User) + Some(SolanaFeePaymentStrategy::User) ); assert_eq!(solana_policy.min_balance, Some(5000000)); assert_eq!(solana_policy.max_signatures, Some(8)); @@ -874,7 +874,7 @@ mod tests { paused: true, network_type: RelayerNetworkType::Solana, policies: Some(RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::User), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::User), min_balance: Some(3000000), max_signatures: None, allowed_tokens: None, diff --git a/src/models/relayer/request.rs b/src/models/relayer/request.rs index ce9e35eed..4e431eaa6 100644 --- a/src/models/relayer/request.rs +++ b/src/models/relayer/request.rs @@ -258,8 +258,7 @@ impl TryFrom for Relayer { mod tests { use super::*; use crate::models::relayer::{ - RelayerEvmPolicy, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, - RelayerStellarPolicy, + RelayerEvmPolicy, RelayerSolanaPolicy, RelayerStellarPolicy, SolanaFeePaymentStrategy, }; #[test] @@ -331,7 +330,7 @@ mod tests { paused: false, network_type: RelayerNetworkType::Solana, policies: Some(CreateRelayerPolicyRequest::Solana(RelayerSolanaPolicy { - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), min_balance: Some(1000000), max_signatures: Some(5), allowed_tokens: None, @@ -360,7 +359,7 @@ mod tests { assert_eq!(solana_policy.max_signatures, Some(5)); assert_eq!( solana_policy.fee_payment_strategy, - Some(RelayerSolanaFeePaymentStrategy::Relayer) + Some(SolanaFeePaymentStrategy::Relayer) ); } else { panic!("Expected Solana policy"); @@ -397,7 +396,7 @@ mod tests { network_type: RelayerNetworkType::Solana, policies: Some(CreateRelayerPolicyRequest::Solana(RelayerSolanaPolicy { fee_payment_strategy: Some( - crate::models::relayer::RelayerSolanaFeePaymentStrategy::Relayer, + crate::models::relayer::SolanaFeePaymentStrategy::Relayer, ), min_balance: Some(1000000), allowed_tokens: None, @@ -633,7 +632,7 @@ mod tests { assert_eq!(solana_policy.fee_margin_percentage, Some(2.5)); assert_eq!( solana_policy.fee_payment_strategy, - Some(RelayerSolanaFeePaymentStrategy::Relayer) + Some(SolanaFeePaymentStrategy::Relayer) ); } else { panic!("Expected Solana policy"); @@ -1134,7 +1133,7 @@ mod tests { if let RelayerNetworkPolicy::Solana(policy) = solana_policy { assert_eq!( policy.fee_payment_strategy, - Some(RelayerSolanaFeePaymentStrategy::User) + Some(SolanaFeePaymentStrategy::User) ); assert_eq!(policy.max_tx_data_size, Some(512)); assert_eq!(policy.fee_margin_percentage, Some(1.5)); diff --git a/src/models/relayer/response.rs b/src/models/relayer/response.rs index e6e1ae4ec..8b3b0a26c 100644 --- a/src/models/relayer/response.rs +++ b/src/models/relayer/response.rs @@ -12,9 +12,9 @@ //! with the domain model for business logic. use super::{ - AllowedToken, Relayer, RelayerEvmPolicy, RelayerNetworkPolicy, RelayerNetworkType, - RelayerRepoModel, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, - RelayerSolanaSwapPolicy, RelayerStellarPolicy, RpcConfig, + Relayer, RelayerEvmPolicy, RelayerNetworkPolicy, RelayerNetworkType, RelayerRepoModel, + RelayerSolanaPolicy, RelayerSolanaSwapConfig, RelayerStellarPolicy, RpcConfig, + SolanaAllowedTokensPolicy, SolanaFeePaymentStrategy, }; use crate::constants::{ DEFAULT_EVM_GAS_LIMIT_ESTIMATION, DEFAULT_EVM_MIN_BALANCE, DEFAULT_SOLANA_MAX_TX_DATA_SIZE, @@ -403,10 +403,10 @@ pub struct SolanaPolicyResponse { pub min_balance: u64, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] - pub allowed_tokens: Option>, + pub allowed_tokens: Option>, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] - pub fee_payment_strategy: Option, + pub fee_payment_strategy: Option, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] pub fee_margin_percentage: Option, @@ -421,7 +421,7 @@ pub struct SolanaPolicyResponse { pub max_allowed_fee_lamports: Option, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] - pub swap_config: Option, + pub swap_config: Option, } /// Stellar policy response model for OpenAPI documentation @@ -488,8 +488,8 @@ impl From for StellarPolicyResponse { mod tests { use super::*; use crate::models::relayer::{ - AllowedToken, RelayerEvmPolicy, RelayerSolanaFeePaymentStrategy, RelayerSolanaPolicy, - RelayerSolanaSwapPolicy, RelayerSolanaSwapStrategy, RelayerStellarPolicy, + RelayerEvmPolicy, RelayerSolanaPolicy, RelayerSolanaSwapConfig, RelayerStellarPolicy, + SolanaAllowedTokensPolicy, SolanaFeePaymentStrategy, SolanaSwapStrategy, }; #[test] @@ -553,8 +553,8 @@ mod tests { allowed_programs: Some(vec!["11111111111111111111111111111111".to_string()]), max_signatures: Some(5), min_balance: Some(1000000), - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), - allowed_tokens: Some(vec![AllowedToken::new( + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), + allowed_tokens: Some(vec![SolanaAllowedTokensPolicy::new( "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), Some(100000), None, @@ -662,18 +662,18 @@ mod tests { max_signatures: Some(5), max_tx_data_size: DEFAULT_SOLANA_MAX_TX_DATA_SIZE, min_balance: 1000000, - allowed_tokens: Some(vec![AllowedToken::new( + allowed_tokens: Some(vec![SolanaAllowedTokensPolicy::new( "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), Some(100000), None, )]), - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), fee_margin_percentage: Some(5.0), allowed_accounts: None, disallowed_accounts: None, max_allowed_fee_lamports: Some(500000), - swap_config: Some(RelayerSolanaSwapPolicy { - strategy: Some(RelayerSolanaSwapStrategy::JupiterSwap), + swap_config: Some(RelayerSolanaSwapConfig { + strategy: Some(SolanaSwapStrategy::JupiterSwap), cron_schedule: Some("0 0 * * *".to_string()), min_balance_threshold: Some(500000), jupiter_swap_options: None, @@ -789,7 +789,7 @@ mod tests { max_tx_data_size: DEFAULT_SOLANA_MAX_TX_DATA_SIZE, min_balance: 1000000, allowed_tokens: None, - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), fee_margin_percentage: None, allowed_accounts: None, disallowed_accounts: None, @@ -1002,7 +1002,7 @@ mod tests { paused: false, policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { max_signatures: Some(5), - fee_payment_strategy: Some(RelayerSolanaFeePaymentStrategy::Relayer), + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), min_balance: Some(1000000), allowed_programs: None, // Some fields can still be None max_tx_data_size: None, From 415013857b89f225f4d96bdef1a544c408a446e8 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 29 Jul 2025 08:56:13 +0200 Subject: [PATCH 54/59] chore: PR suggestion --- helpers/generate_encyption_key.rs | 55 +++++++------------------------ src/utils/encryption.rs | 5 +++ 2 files changed, 17 insertions(+), 43 deletions(-) diff --git a/helpers/generate_encyption_key.rs b/helpers/generate_encyption_key.rs index a2d5bc55f..55728f139 100644 --- a/helpers/generate_encyption_key.rs +++ b/helpers/generate_encyption_key.rs @@ -1,50 +1,28 @@ //! Encryption Key Generation Tool //! -//! This tool generates a random 32-byte encryption key using OpenSSL and prints it to the console. +//! This tool generates a random 32-byte base64-encoded encryption key and prints it to the console. //! -//! # Usage +//! Other tools can be used to generate key like: //! //! ```bash -//! cargo run --example generate_encryption_key +//! openssl rand -base64 32 //! ``` //! -//! # Requirements +//! # Usage //! -//! This tool requires OpenSSL to be installed and available in the system PATH. -use eyre::{eyre, Result}; -use std::process::Command; +//! ```bash +//! cargo run --example generate_encryption_key +//! ``` +use eyre::Result; +use openzeppelin_relayer::utils::generate_encryption_key; /// Main entry point for encryption key generation tool fn main() -> Result<()> { - let encryption_key = generate_encryption_key()?; + let encryption_key = generate_encryption_key(); println!("Generated new encryption key: {}", encryption_key); Ok(()) } -/// Generates a 32-byte base64-encoded encryption key using OpenSSL -fn generate_encryption_key() -> Result { - let output = Command::new("openssl") - .args(["rand", "-base64", "32"]) - .output() - .map_err(|e| eyre!("Failed to execute openssl command: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(eyre!("OpenSSL command failed: {}", stderr)); - } - - let key = String::from_utf8(output.stdout) - .map_err(|e| eyre!("Failed to parse openssl output as UTF-8: {}", e))? - .trim() - .to_string(); - - if key.is_empty() { - return Err(eyre!("OpenSSL returned empty key")); - } - - Ok(key) -} - #[cfg(test)] mod tests { use super::*; @@ -53,9 +31,8 @@ mod tests { #[test] fn test_encryption_key_generation() { let key = generate_encryption_key(); - assert!(key.is_ok(), "Failed to generate encryption key"); - let key_string = key.unwrap(); + let key_string = key; assert!(!key_string.is_empty(), "Generated key should not be empty"); // Verify it's valid base64 @@ -72,14 +49,6 @@ mod tests { let key1 = generate_encryption_key(); let key2 = generate_encryption_key(); - assert!( - key1.is_ok() && key2.is_ok(), - "Both key generations should succeed" - ); - assert_ne!( - key1.unwrap(), - key2.unwrap(), - "Two generated keys should be different" - ); + assert_ne!(key1, key2, "Two generated keys should be different"); } } diff --git a/src/utils/encryption.rs b/src/utils/encryption.rs index 5c9e41a05..86b623d2a 100644 --- a/src/utils/encryption.rs +++ b/src/utils/encryption.rs @@ -250,6 +250,11 @@ pub fn decrypt_sensitive_field(data: &str) -> Result { .map_err(|e| EncryptionError::DecryptionFailed(format!("Invalid JSON string: {}", e))) } +/// Utility function to generate a new encryption key +pub fn generate_encryption_key() -> String { + FieldEncryption::generate_key() +} + #[cfg(test)] mod tests { use super::*; From a7377656eb6712723ee931aa3745b962b671b2bf Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 29 Jul 2025 12:47:37 +0200 Subject: [PATCH 55/59] chore: imprive lcov ingore rules --- .github/workflows/ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 198754a51..26be44ff8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -237,7 +237,7 @@ jobs: LLVM_PROFILE_FILE: unit-%p-%m.profraw RUSTFLAGS: -Cinstrument-coverage RUST_TEST_THREADS: 1 - run: cargo hack llvm-cov --locked --lib --ignore-filename-regex "(.*/relayer_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path unit-lcov.info + run: cargo hack llvm-cov --locked --lib --ignore-filename-regex "(src/routes/docs/.*_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path unit-lcov.info # Integration tests coverage - name: Run Integration Tests and Generate Coverage Report @@ -245,7 +245,7 @@ jobs: LLVM_PROFILE_FILE: integration-%p-%m.profraw RUSTFLAGS: -Cinstrument-coverage RUST_TEST_THREADS: 1 - run: cargo hack llvm-cov --locked --ignore-filename-regex ".*/relayer_docs\.rs$" --lcov --output-path integration-lcov.info --test integration + run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/routes/docs/.*_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path integration-lcov.info --test integration # Properties tests coverage - name: Run Properties Tests @@ -253,7 +253,7 @@ jobs: LLVM_PROFILE_FILE: properties-%p-%m.profraw RUSTFLAGS: -Cinstrument-coverage RUST_TEST_THREADS: 1 - run: cargo hack llvm-cov --locked --ignore-filename-regex ".*/relayer_docs\.rs$" --lcov --output-path properties-lcov.info --test properties + run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/routes/docs/.*_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path properties-lcov.info --test properties # Upload unit coverage - name: Upload Unit Coverage to Codecov From f7b51aa19ce41c388642700ded7f38e6a443404b Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 29 Jul 2025 13:08:48 +0200 Subject: [PATCH 56/59] chore: fix regex --- .github/workflows/ci.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 26be44ff8..cff6fd063 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -237,15 +237,14 @@ jobs: LLVM_PROFILE_FILE: unit-%p-%m.profraw RUSTFLAGS: -Cinstrument-coverage RUST_TEST_THREADS: 1 - run: cargo hack llvm-cov --locked --lib --ignore-filename-regex "(src/routes/docs/.*_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path unit-lcov.info - + run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/api/routes/docs/.*|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path unit-lcov.info --test unit # Integration tests coverage - name: Run Integration Tests and Generate Coverage Report env: LLVM_PROFILE_FILE: integration-%p-%m.profraw RUSTFLAGS: -Cinstrument-coverage RUST_TEST_THREADS: 1 - run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/routes/docs/.*_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path integration-lcov.info --test integration + run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/api/routes/docs/.*|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path integration-lcov.info --test integration # Properties tests coverage - name: Run Properties Tests @@ -253,7 +252,7 @@ jobs: LLVM_PROFILE_FILE: properties-%p-%m.profraw RUSTFLAGS: -Cinstrument-coverage RUST_TEST_THREADS: 1 - run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/routes/docs/.*_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path properties-lcov.info --test properties + run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/api/routes/docs/.*|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path properties-lcov.info --test properties # Upload unit coverage - name: Upload Unit Coverage to Codecov From 0b7c7b86f93aa96609157d0f6bfecfc34617952e Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 29 Jul 2025 13:16:56 +0200 Subject: [PATCH 57/59] chore: attempt --- .github/workflows/ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cff6fd063..298e65f3f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -237,14 +237,14 @@ jobs: LLVM_PROFILE_FILE: unit-%p-%m.profraw RUSTFLAGS: -Cinstrument-coverage RUST_TEST_THREADS: 1 - run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/api/routes/docs/.*|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path unit-lcov.info --test unit + run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/api/routes/docs/.*_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path unit-lcov.info --test unit # Integration tests coverage - name: Run Integration Tests and Generate Coverage Report env: LLVM_PROFILE_FILE: integration-%p-%m.profraw RUSTFLAGS: -Cinstrument-coverage RUST_TEST_THREADS: 1 - run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/api/routes/docs/.*|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path integration-lcov.info --test integration + run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/api/routes/docs/.*_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path integration-lcov.info --test integration # Properties tests coverage - name: Run Properties Tests @@ -252,7 +252,7 @@ jobs: LLVM_PROFILE_FILE: properties-%p-%m.profraw RUSTFLAGS: -Cinstrument-coverage RUST_TEST_THREADS: 1 - run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/api/routes/docs/.*|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path properties-lcov.info --test properties + run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/api/routes/docs/.*_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path properties-lcov.info --test properties # Upload unit coverage - name: Upload Unit Coverage to Codecov From b63d45ed0354f1e6ecb5fc9acfbafdd3dfa8481c Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 29 Jul 2025 13:39:00 +0200 Subject: [PATCH 58/59] chore: attempt --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 298e65f3f..12bcadd9a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -237,7 +237,7 @@ jobs: LLVM_PROFILE_FILE: unit-%p-%m.profraw RUSTFLAGS: -Cinstrument-coverage RUST_TEST_THREADS: 1 - run: cargo hack llvm-cov --locked --ignore-filename-regex "(src/api/routes/docs/.*_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path unit-lcov.info --test unit + run: cargo hack llvm-cov --locked --lib --ignore-filename-regex "(src/api/routes/docs/.*_docs\.rs$|src/repositories/.*/.*_redis\.rs$)" --lcov --output-path unit-lcov.info # Integration tests coverage - name: Run Integration Tests and Generate Coverage Report env: From f4fa14fd70362dd61050858b581792bbda796243 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 30 Jul 2025 16:39:26 +0200 Subject: [PATCH 59/59] chore: remove empty file --- src/api/routes/mod.rs | 1 - src/api/routes/network.rs | 1 - 2 files changed, 2 deletions(-) delete mode 100644 src/api/routes/network.rs diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs index f5346f2a0..02a7e3c1f 100644 --- a/src/api/routes/mod.rs +++ b/src/api/routes/mod.rs @@ -12,7 +12,6 @@ pub mod docs; pub mod health; pub mod metrics; -pub mod network; pub mod notification; pub mod plugin; pub mod relayer; diff --git a/src/api/routes/network.rs b/src/api/routes/network.rs deleted file mode 100644 index 8b1378917..000000000 --- a/src/api/routes/network.rs +++ /dev/null @@ -1 +0,0 @@ -