diff --git a/Cargo.lock b/Cargo.lock index 4b600fd..4619f6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1944,6 +1944,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -2815,6 +2824,26 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3123,6 +3152,26 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3338,6 +3387,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -3462,6 +3512,33 @@ dependencies = [ "memchr", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.10.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -6615,6 +6692,7 @@ dependencies = [ "jsonwebtoken", "lazy_static", "mockall 0.13.1", + "notify", "prometheus 0.14.0", "qp-human-checkphrase", "qp-rusty-crystals-dilithium", diff --git a/Cargo.toml b/Cargo.toml index a4e5f47..9b55db7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,7 @@ lazy_static = "1.5.0" prometheus = {version = "0.14.0", features = ["process"]} subxt = "0.43.0" tiny-keccak = {version = "2.0.2", features = ["keccak"]} +notify = "8.2.0" [dev-dependencies] mockall = "0.13" diff --git a/config/default.toml b/config/default.toml index 29c7da8..5c7e1d3 100644 --- a/config/default.toml +++ b/config/default.toml @@ -77,4 +77,7 @@ sync_interval_in_hours = 24 tweets_req_interval_in_secs = 60 [alert] -webhook_url = "https://www.webhook_url.com" \ No newline at end of file +webhook_url = "https://www.webhook_url.com" + +[feature_flags] +wallet_feature_flags_config_file = "../wallet_feature_flags/default_feature_flags.json" \ No newline at end of file diff --git a/config/example.toml b/config/example.toml index 5da59ba..580f6ba 100644 --- a/config/example.toml +++ b/config/example.toml @@ -89,6 +89,9 @@ tweets_req_interval_in_secs = 60 [alert] webhook_url = "https://www.webhook_url.com" +[feature_flags] +wallet_feature_flags_config_file = "../wallet_feature_flags/default_feature_flags.json" + # Example environment variable overrides: # TASKMASTER_BLOCKCHAIN__NODE_URL="ws://remote-node:9944" # TASKMASTER_BLOCKCHAIN__WALLET_PASSWORD="super_secure_password" diff --git a/config/test.toml b/config/test.toml index 9e11ffd..a5361d3 100644 --- a/config/test.toml +++ b/config/test.toml @@ -77,4 +77,7 @@ sync_interval_in_hours = 24 tweets_req_interval_in_secs = 1 [alert] -webhook_url = "https://www.webhook_url.com" \ No newline at end of file +webhook_url = "https://www.webhook_url.com" + +[feature_flags] +wallet_feature_flags_config_file = "../wallet_feature_flags/test_feature_flags.json" \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index a1092bd..cfb11e2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use rusx::config::OauthConfig; use serde::{Deserialize, Serialize}; use tokio::time; @@ -16,6 +18,12 @@ pub struct Config { pub raid_leaderboard: RaidLeaderboardConfig, pub alert: AlertConfig, pub x_association: XAssociationConfig, + pub feature_flags: FeatureFlagsConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeatureFlagsConfig { + pub wallet_feature_flags_config_file: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -98,20 +106,25 @@ impl Config { .add_source(config::Environment::with_prefix("TASKMASTER")) .build()?; - settings.try_deserialize() + let mut config: Self = settings.try_deserialize()?; + config.resolve_relative_paths(config_path); + Ok(config) } #[cfg(test)] pub fn load_test_env() -> Result { println!("Loading TEST configuration..."); // For demonstration + let test_config_path = "config/test.toml"; let settings = config::Config::builder() // Load the test-specific configuration file - .add_source(config::File::with_name("config/test")) + .add_source(config::File::new(test_config_path, config::FileFormat::Toml)) // You can still layer environment variables for testing if you need to .add_source(config::Environment::with_prefix("TASKMASTER")) .build()?; - settings.try_deserialize() + let mut config: Self = settings.try_deserialize()?; + config.resolve_relative_paths(test_config_path); + Ok(config) } pub fn get_database_url(&self) -> &str { @@ -145,6 +158,16 @@ impl Config { pub fn get_x_association_keywords(&self) -> &str { &self.x_association.keywords } + + fn resolve_relative_paths(&mut self, config_path: &str) { + let feature_flags_path = Path::new(&self.feature_flags.wallet_feature_flags_config_file); + if feature_flags_path.is_absolute() { + return; + } + let base_dir = Path::new(config_path).parent().expect("Failed to get base directory"); + self.feature_flags.wallet_feature_flags_config_file = + base_dir.join(feature_flags_path).to_string_lossy().to_string(); + } } impl Default for Config { @@ -206,6 +229,9 @@ impl Default for Config { x_association: XAssociationConfig { keywords: "quantus".to_string(), }, + feature_flags: FeatureFlagsConfig { + wallet_feature_flags_config_file: "wallet_feature_flags/default_feature_flags.json".to_string(), + }, } } } diff --git a/src/errors.rs b/src/errors.rs index 6b5cfaa..1c2740a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -11,7 +11,7 @@ use crate::{ db_persistence::DbError, handlers::{address::AddressHandlerError, auth::AuthHandlerError, referral::ReferralHandlerError, HandlerError}, models::ModelError, - services::graphql_client::GraphqlError, + services::{graphql_client::GraphqlError, wallet_feature_flags_service::WalletFeatureFlagsError}, }; #[derive(Debug, thiserror::Error)] @@ -26,6 +26,8 @@ pub enum AppError { Database(#[from] DbError), #[error("Server error: {0}")] Server(String), + #[error("Wallet feature flags error: {0}")] + WalletFeatureFlags(#[from] WalletFeatureFlagsError), #[error("Join error: {0}")] Join(#[from] tokio::task::JoinError), #[error("GraphQL error: {0}")] @@ -49,6 +51,9 @@ impl IntoResponse for AppError { err, ), + // --- Wallet Feature Flags --- + AppError::WalletFeatureFlags(err) => map_wallet_feature_flags_error(err), + // --- Model --- AppError::Model(err) => (StatusCode::BAD_REQUEST, err.to_string()), @@ -166,3 +171,7 @@ fn map_db_error(err: DbError) -> (StatusCode, String) { ), } } + +fn map_wallet_feature_flags_error(err: WalletFeatureFlagsError) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 8997b1c..184d472 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -13,6 +13,7 @@ pub mod raid_quest; pub mod referral; pub mod relevant_tweet; pub mod tweet_author; +pub mod wallet_feature_flags; #[derive(Debug, thiserror::Error)] pub enum HandlerError { diff --git a/src/handlers/wallet_feature_flags.rs b/src/handlers/wallet_feature_flags.rs new file mode 100644 index 0000000..d3f9258 --- /dev/null +++ b/src/handlers/wallet_feature_flags.rs @@ -0,0 +1,12 @@ +use axum::{extract::State, Json}; +use serde_json::Value; + +use crate::{handlers::SuccessResponse, http_server::AppState, AppError}; + +pub async fn handle_get_wallet_feature_flags( + State(state): State, +) -> Result>, AppError> { + let flags = state.wallet_feature_flags_service.get_wallet_feature_flags()?; + + Ok(SuccessResponse::new(flags)) +} diff --git a/src/http_server.rs b/src/http_server.rs index 2a00812..b186bdf 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -13,6 +13,7 @@ use crate::{ db_persistence::DbPersistence, metrics::{metrics_handler, track_metrics, Metrics}, routes::api_routes, + services::wallet_feature_flags_service::WalletFeatureFlagsService, Config, GraphqlClient, }; use chrono::{DateTime, Utc}; @@ -23,6 +24,7 @@ pub struct AppState { pub db: Arc, pub metrics: Arc, pub graphql_client: Arc, + pub wallet_feature_flags_service: Arc, pub config: Arc, pub challenges: Arc>>, pub oauth_sessions: Arc>>, @@ -83,6 +85,9 @@ pub async fn start_server( db, metrics: Arc::new(Metrics::new()), graphql_client, + wallet_feature_flags_service: Arc::new(WalletFeatureFlagsService::new( + config.feature_flags.wallet_feature_flags_config_file.clone(), + )?), config, twitter_gateway, challenges: Arc::new(RwLock::new(HashMap::new())), diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 2b8c773..8914884 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,6 +1,7 @@ use auth::auth_routes; use axum::Router; use referral::referral_routes; +use wallet_feature_flags::wallet_feature_flags_routes; use crate::{ http_server::AppState, @@ -16,6 +17,7 @@ pub mod raid_quest; pub mod referral; pub mod relevant_tweet; pub mod tweet_author; +pub mod wallet_feature_flags; pub fn api_routes(state: AppState) -> Router { Router::new() @@ -24,5 +26,6 @@ pub fn api_routes(state: AppState) -> Router { .merge(auth_routes(state.clone())) .merge(relevant_tweet_routes(state.clone())) .merge(tweet_author_routes(state.clone())) + .merge(wallet_feature_flags_routes()) .merge(raid_quest_routes(state)) } diff --git a/src/routes/wallet_feature_flags.rs b/src/routes/wallet_feature_flags.rs new file mode 100644 index 0000000..dceb6c4 --- /dev/null +++ b/src/routes/wallet_feature_flags.rs @@ -0,0 +1,7 @@ +use axum::{routing::get, Router}; + +use crate::{handlers::wallet_feature_flags::handle_get_wallet_feature_flags, http_server::AppState}; + +pub fn wallet_feature_flags_routes() -> Router { + Router::new().route("/feature-flags/wallet", get(handle_get_wallet_feature_flags)) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index dc7bd65..9276193 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -4,3 +4,4 @@ pub mod raid_leaderboard_service; pub mod signature_service; pub mod telegram_service; pub mod tweet_synchronizer_service; +pub mod wallet_feature_flags_service; diff --git a/src/services/wallet_feature_flags_service.rs b/src/services/wallet_feature_flags_service.rs new file mode 100644 index 0000000..1502b33 --- /dev/null +++ b/src/services/wallet_feature_flags_service.rs @@ -0,0 +1,268 @@ +use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher}; +use serde_json::Value; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; +use tokio::{sync::mpsc, task::JoinHandle}; + +#[derive(Debug, thiserror::Error)] +pub enum WalletFeatureFlagsError { + #[error("Failed to read wallet feature flags file: {0}")] + ReadFile(#[from] std::io::Error), + #[error("Failed to parse wallet feature flags JSON: {0}")] + ParseJson(#[from] serde_json::Error), + #[error("Failed to initialize file watcher: {0}")] + Watcher(#[from] notify::Error), + #[error("Failed to read wallet feature flags: {0}")] + ReadLock(String), + #[error("Failed to get parent directory")] + ParentDirectory, +} + +#[derive(Debug)] +pub struct WalletFeatureFlagsService { + wallet_feature_flags: Arc>, + _watcher: RecommendedWatcher, + _watch_task: JoinHandle<()>, +} + +impl WalletFeatureFlagsService { + pub fn new(file_path: impl Into) -> Result { + let file_path = file_path.into(); + + let flags = Self::read_flags_from_file_sync(&file_path)?; + let wallet_feature_flags = Arc::new(RwLock::new(flags)); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut watcher = RecommendedWatcher::new( + move |result| { + if let Err(send_err) = tx.send(result) { + tracing::warn!("Wallet feature flags watcher channel closed: {}", send_err); + } + }, + NotifyConfig::default(), + )?; + + let parent_dir = Path::new(&file_path) + .parent() + .ok_or(WalletFeatureFlagsError::ParentDirectory)?; + watcher.watch(parent_dir, RecursiveMode::NonRecursive)?; + + let wallet_feature_flags_clone = wallet_feature_flags.clone(); + + let watch_task = tokio::spawn(async move { + while let Some(result) = rx.recv().await { + match result { + Ok(event) => { + // This ensures Create, Rename, and Modify events triggered by atomic saves are caught. + let should_reload = event.paths.iter().any(|p| p.file_name() == file_path.file_name()); + + if !should_reload { + continue; + } + + match Self::read_flags_from_file_async(&file_path).await { + Ok(updated_flags) => { + if let Ok(mut write_guard) = wallet_feature_flags_clone.write() { + *write_guard = updated_flags; + tracing::info!("Wallet feature flags reloaded from {}", file_path.display()); + } + } + Err(err) => { + tracing::warn!( + "Failed to reload wallet feature flags from {}: {}. Using last known good flags.", + file_path.display(), + err + ); + } + } + } + Err(err) => { + tracing::error!("Wallet feature flags watcher error: {}", err); + } + } + } + }); + + Ok(Self { + wallet_feature_flags, + _watcher: watcher, + _watch_task: watch_task, + }) + } + + pub fn get_wallet_feature_flags(&self) -> Result { + let guard = self.wallet_feature_flags.read().map_err(|_| { + WalletFeatureFlagsError::ReadLock("Failed to read wallet feature flags from lock".to_string()) + })?; + + Ok(guard.clone()) + } + + // Synchronous read for initial startup + fn read_flags_from_file_sync(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let flags = serde_json::from_str::(&content)?; + Ok(flags) + } + + // Asynchronous read for the background watcher task + async fn read_flags_from_file_async(path: &Path) -> Result { + let content = tokio::fs::read_to_string(path).await?; + // For larger JSON payloads, you might want to wrap this next line in spawn_blocking, + // but for a tiny struct of bools, inline is perfectly fine. + let flags = serde_json::from_str::(&content)?; + Ok(flags) + } +} + +impl Drop for WalletFeatureFlagsService { + fn drop(&mut self) { + self._watch_task.abort(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{path::PathBuf, time::Duration}; + use uuid::Uuid; + + fn unique_temp_flags_path() -> PathBuf { + std::env::temp_dir().join(format!("wallet-feature-flags-{}.json", Uuid::new_v4())) + } + + fn write_flags_file(path: &Path, content: &str) { + std::fs::write(path, content).expect("failed to write flags file"); + } + + async fn wait_until(timeout: Duration, mut predicate: F) + where + F: FnMut() -> bool, + { + let step = Duration::from_millis(50); + let mut elapsed = Duration::ZERO; + + while elapsed < timeout { + if predicate() { + return; + } + tokio::time::sleep(step).await; + elapsed += step; + } + + panic!("condition not met within {:?}", timeout); + } + + #[tokio::test] + async fn new_loads_initial_flags_from_file() { + let path = unique_temp_flags_path(); + write_flags_file( + &path, + r#"{ + "enableTestButtons": false, + "enableKeystoneHardwareWallet": false, + "enableHighSecurity": true, + "enableRemoteNotifications": true, + "enableSwap": true +}"#, + ); + + let service = WalletFeatureFlagsService::new(path.clone()).expect("service should initialize"); + let flags = service.get_wallet_feature_flags().unwrap(); + + assert!(!flags["enableTestButtons"].as_bool().unwrap()); + assert!(!flags["enableKeystoneHardwareWallet"].as_bool().unwrap()); + assert!(flags["enableHighSecurity"].as_bool().unwrap()); + assert!(flags["enableRemoteNotifications"].as_bool().unwrap()); + assert!(flags["enableSwap"].as_bool().unwrap()); + + std::fs::remove_file(path).ok(); + } + + #[tokio::test] + async fn watcher_reloads_flags_when_file_changes() { + let path = unique_temp_flags_path(); + write_flags_file( + &path, + r#"{ + "enableTestButtons": false, + "enableKeystoneHardwareWallet": false, + "enableHighSecurity": true, + "enableRemoteNotifications": true, + "enableSwap": true +}"#, + ); + + let service = WalletFeatureFlagsService::new(path.clone()).expect("service should initialize"); + + write_flags_file( + &path, + r#"{ + "enableTestButtons": true, + "enableKeystoneHardwareWallet": true, + "enableHighSecurity": false, + "enableRemoteNotifications": false, + "enableSwap": false +}"#, + ); + + wait_until(Duration::from_secs(3), || { + let flags = service.get_wallet_feature_flags().unwrap(); + flags["enableTestButtons"].as_bool().unwrap() + && flags["enableKeystoneHardwareWallet"].as_bool().unwrap() + && !flags["enableHighSecurity"].as_bool().unwrap() + && !flags["enableRemoteNotifications"].as_bool().unwrap() + && !flags["enableSwap"].as_bool().unwrap() + }) + .await; + + std::fs::remove_file(path).ok(); + } + + #[tokio::test] + async fn watcher_keeps_last_known_good_flags_when_json_becomes_invalid() { + let path = unique_temp_flags_path(); + write_flags_file( + &path, + r#"{ + "enableTestButtons": false, + "enableKeystoneHardwareWallet": false, + "enableHighSecurity": true, + "enableRemoteNotifications": true, + "enableSwap": true +}"#, + ); + + let service = WalletFeatureFlagsService::new(path.clone()).expect("service should initialize"); + let before = service.get_wallet_feature_flags().unwrap(); + + write_flags_file(&path, r#"{ invalid json }"#); + tokio::time::sleep(Duration::from_millis(300)).await; + + let after = service.get_wallet_feature_flags().unwrap(); + assert_eq!( + before["enableTestButtons"].as_bool().unwrap(), + after["enableTestButtons"].as_bool().unwrap() + ); + assert_eq!( + before["enableKeystoneHardwareWallet"].as_bool().unwrap(), + after["enableKeystoneHardwareWallet"].as_bool().unwrap() + ); + assert_eq!( + before["enableHighSecurity"].as_bool().unwrap(), + after["enableHighSecurity"].as_bool().unwrap() + ); + assert_eq!( + before["enableRemoteNotifications"].as_bool().unwrap(), + after["enableRemoteNotifications"].as_bool().unwrap() + ); + assert_eq!( + before["enableSwap"].as_bool().unwrap(), + after["enableSwap"].as_bool().unwrap() + ); + + std::fs::remove_file(path).ok(); + } +} diff --git a/src/utils/test_app_state.rs b/src/utils/test_app_state.rs index 4a22652..90e912c 100644 --- a/src/utils/test_app_state.rs +++ b/src/utils/test_app_state.rs @@ -1,6 +1,6 @@ use crate::{ - db_persistence::DbPersistence, http_server::AppState, metrics::Metrics, models::auth::TokenClaims, Config, - GraphqlClient, + db_persistence::DbPersistence, http_server::AppState, metrics::Metrics, models::auth::TokenClaims, + services::wallet_feature_flags_service::WalletFeatureFlagsService, Config, GraphqlClient, }; use jsonwebtoken::{encode, EncodingKey, Header}; use rusx::RusxGateway; @@ -18,6 +18,9 @@ pub async fn create_test_app_state() -> AppState { db, metrics: Arc::new(Metrics::new()), graphql_client: Arc::new(graphql_client), + wallet_feature_flags_service: Arc::new( + WalletFeatureFlagsService::new(config.feature_flags.wallet_feature_flags_config_file.clone()).unwrap(), + ), config: Arc::new(config), twitter_gateway: Arc::new(twitter_gateway), oauth_sessions: Arc::new(Mutex::new(std::collections::HashMap::new())), diff --git a/wallet_feature_flags/default_feature_flags.json b/wallet_feature_flags/default_feature_flags.json new file mode 100644 index 0000000..2fce8dd --- /dev/null +++ b/wallet_feature_flags/default_feature_flags.json @@ -0,0 +1,7 @@ +{ + "enableTestButtons": false, + "enableKeystoneHardwareWallet": false, + "enableHighSecurity": true, + "enableRemoteNotifications": true, + "enableSwap": true +} diff --git a/wallet_feature_flags/test_feature_flags.json b/wallet_feature_flags/test_feature_flags.json new file mode 100644 index 0000000..2fce8dd --- /dev/null +++ b/wallet_feature_flags/test_feature_flags.json @@ -0,0 +1,7 @@ +{ + "enableTestButtons": false, + "enableKeystoneHardwareWallet": false, + "enableHighSecurity": true, + "enableRemoteNotifications": true, + "enableSwap": true +}