From fc78adaa473d90569231e5708b12c1d3f1358d04 Mon Sep 17 00:00:00 2001 From: Beast Date: Tue, 24 Mar 2026 15:27:05 +0800 Subject: [PATCH 01/11] feat: add feature flags system --- Cargo.lock | 78 ++++++ Cargo.toml | 1 + config/default.toml | 5 +- config/example.toml | 3 + config/test.toml | 5 +- src/config.rs | 10 + src/handlers/mod.rs | 1 + src/handlers/wallet_feature_flags.rs | 14 + src/http_server.rs | 5 + src/routes/mod.rs | 3 + src/routes/wallet_feature_flags.rs | 10 + src/services/mod.rs | 1 + src/services/wallet_feature_flags_service.rs | 255 ++++++++++++++++++ src/utils/test_app_state.rs | 4 + .../default_feature_flags.json | 7 + 15 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 src/handlers/wallet_feature_flags.rs create mode 100644 src/routes/wallet_feature_flags.rs create mode 100644 src/services/wallet_feature_flags_service.rs create mode 100644 wallet_feature_flags/default_feature_flags.json 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..111ccb2 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..77d1392 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/example_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..ec21cd0 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..7ec79d3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,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)] @@ -206,6 +212,10 @@ 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/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..d581262 --- /dev/null +++ b/src/handlers/wallet_feature_flags.rs @@ -0,0 +1,14 @@ +use axum::{extract::State, Json}; + +use crate::{ + handlers::SuccessResponse, http_server::AppState, services::wallet_feature_flags_service::WalletFeatureFlags, + 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..7c9b4b4 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(state.clone())) .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..0d66c57 --- /dev/null +++ b/src/routes/wallet_feature_flags.rs @@ -0,0 +1,10 @@ +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(_state: AppState) -> 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..9a419b1 --- /dev/null +++ b/src/services/wallet_feature_flags_service.rs @@ -0,0 +1,255 @@ +use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::{Deserialize, Serialize}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; +use tokio::{sync::mpsc, task::JoinHandle}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WalletFeatureFlags { + pub enable_test_buttons: bool, + pub enable_keystone_hardware_wallet: bool, + pub enable_high_security: bool, + pub enable_remote_notifications: bool, + pub enable_swap: bool, +} + +#[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), +} + +#[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() + .unwrap_or(Path::new("wallet_feature_flags")); + 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) -> WalletFeatureFlags { + self.wallet_feature_flags.read().expect("RwLock poisoned").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) + } +} + +#[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(); + + assert!(!flags.enable_test_buttons); + assert!(!flags.enable_keystone_hardware_wallet); + assert!(flags.enable_high_security); + assert!(flags.enable_remote_notifications); + assert!(flags.enable_swap); + + 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(); + flags.enable_test_buttons + && flags.enable_keystone_hardware_wallet + && !flags.enable_high_security + && !flags.enable_remote_notifications + && !flags.enable_swap + }) + .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(); + + write_flags_file(&path, r#"{ invalid json }"#); + tokio::time::sleep(Duration::from_millis(300)).await; + + let after = service.get_wallet_feature_flags(); + assert_eq!(before.enable_test_buttons, after.enable_test_buttons); + assert_eq!( + before.enable_keystone_hardware_wallet, + after.enable_keystone_hardware_wallet + ); + assert_eq!(before.enable_high_security, after.enable_high_security); + assert_eq!( + before.enable_remote_notifications, + after.enable_remote_notifications + ); + assert_eq!(before.enable_swap, after.enable_swap); + + std::fs::remove_file(path).ok(); + } +} diff --git a/src/utils/test_app_state.rs b/src/utils/test_app_state.rs index 4a22652..a2d8a05 100644 --- a/src/utils/test_app_state.rs +++ b/src/utils/test_app_state.rs @@ -1,6 +1,7 @@ use crate::{ db_persistence::DbPersistence, http_server::AppState, metrics::Metrics, models::auth::TokenClaims, Config, GraphqlClient, + services::wallet_feature_flags_service::WalletFeatureFlagsService, }; use jsonwebtoken::{encode, EncodingKey, Header}; use rusx::RusxGateway; @@ -18,6 +19,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 +} From 63d9fcb62827620156a00b9f3cd43e6007b0e885 Mon Sep 17 00:00:00 2001 From: Beast Date: Tue, 24 Mar 2026 15:40:06 +0800 Subject: [PATCH 02/11] chore: formatting --- src/config.rs | 3 +-- src/routes/wallet_feature_flags.rs | 5 +---- src/services/wallet_feature_flags_service.rs | 5 +---- src/utils/test_app_state.rs | 5 ++--- 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/config.rs b/src/config.rs index 7ec79d3..68e6b64 100644 --- a/src/config.rs +++ b/src/config.rs @@ -213,8 +213,7 @@ impl Default for Config { keywords: "quantus".to_string(), }, feature_flags: FeatureFlagsConfig { - wallet_feature_flags_config_file: "wallet_feature_flags/default_feature_flags.json" - .to_string(), + wallet_feature_flags_config_file: "wallet_feature_flags/default_feature_flags.json".to_string(), }, } } diff --git a/src/routes/wallet_feature_flags.rs b/src/routes/wallet_feature_flags.rs index 0d66c57..048bce9 100644 --- a/src/routes/wallet_feature_flags.rs +++ b/src/routes/wallet_feature_flags.rs @@ -1,7 +1,4 @@ -use axum::{ - routing::get, - Router, -}; +use axum::{routing::get, Router}; use crate::{handlers::wallet_feature_flags::handle_get_wallet_feature_flags, http_server::AppState}; diff --git a/src/services/wallet_feature_flags_service.rs b/src/services/wallet_feature_flags_service.rs index 9a419b1..5d2d281 100644 --- a/src/services/wallet_feature_flags_service.rs +++ b/src/services/wallet_feature_flags_service.rs @@ -244,10 +244,7 @@ mod tests { after.enable_keystone_hardware_wallet ); assert_eq!(before.enable_high_security, after.enable_high_security); - assert_eq!( - before.enable_remote_notifications, - after.enable_remote_notifications - ); + assert_eq!(before.enable_remote_notifications, after.enable_remote_notifications); assert_eq!(before.enable_swap, after.enable_swap); std::fs::remove_file(path).ok(); diff --git a/src/utils/test_app_state.rs b/src/utils/test_app_state.rs index a2d8a05..90e912c 100644 --- a/src/utils/test_app_state.rs +++ b/src/utils/test_app_state.rs @@ -1,7 +1,6 @@ use crate::{ - db_persistence::DbPersistence, http_server::AppState, metrics::Metrics, models::auth::TokenClaims, Config, - GraphqlClient, - services::wallet_feature_flags_service::WalletFeatureFlagsService, + 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; From 81311861aaa5108c0ad0cf6f7ab1e5af5362615b Mon Sep 17 00:00:00 2001 From: Beast Date: Tue, 24 Mar 2026 15:42:55 +0800 Subject: [PATCH 03/11] feat: consistent api response --- src/services/wallet_feature_flags_service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/wallet_feature_flags_service.rs b/src/services/wallet_feature_flags_service.rs index 5d2d281..553f8f3 100644 --- a/src/services/wallet_feature_flags_service.rs +++ b/src/services/wallet_feature_flags_service.rs @@ -7,7 +7,7 @@ use std::{ use tokio::{sync::mpsc, task::JoinHandle}; #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all(deserialize = "camelCase"))] pub struct WalletFeatureFlags { pub enable_test_buttons: bool, pub enable_keystone_hardware_wallet: bool, From d38d8ef090c81ef0bc45d36d19057bb42f071c9b Mon Sep 17 00:00:00 2001 From: Beast Date: Tue, 24 Mar 2026 17:44:28 +0800 Subject: [PATCH 04/11] fix: rwlock panic --- src/errors.rs | 16 +++++++++++++++- src/handlers/wallet_feature_flags.rs | 2 +- src/services/wallet_feature_flags_service.rs | 18 ++++++++++++------ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index 6b5cfaa..4a3c50d 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,12 @@ fn map_db_error(err: DbError) -> (StatusCode, String) { ), } } + +fn map_wallet_feature_flags_error(err: WalletFeatureFlagsError) -> (StatusCode, String) { + match err { + WalletFeatureFlagsError::ReadLock(err) => (StatusCode::INTERNAL_SERVER_ERROR, err), + WalletFeatureFlagsError::ReadFile(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + WalletFeatureFlagsError::ParseJson(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + WalletFeatureFlagsError::Watcher(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} diff --git a/src/handlers/wallet_feature_flags.rs b/src/handlers/wallet_feature_flags.rs index d581262..d997390 100644 --- a/src/handlers/wallet_feature_flags.rs +++ b/src/handlers/wallet_feature_flags.rs @@ -8,7 +8,7 @@ use crate::{ pub async fn handle_get_wallet_feature_flags( State(state): State, ) -> Result>, AppError> { - let flags = state.wallet_feature_flags_service.get_wallet_feature_flags(); + let flags = state.wallet_feature_flags_service.get_wallet_feature_flags()?; Ok(SuccessResponse::new(flags)) } diff --git a/src/services/wallet_feature_flags_service.rs b/src/services/wallet_feature_flags_service.rs index 553f8f3..3760f55 100644 --- a/src/services/wallet_feature_flags_service.rs +++ b/src/services/wallet_feature_flags_service.rs @@ -24,6 +24,8 @@ pub enum WalletFeatureFlagsError { 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), } #[derive(Debug)] @@ -98,8 +100,12 @@ impl WalletFeatureFlagsService { }) } - pub fn get_wallet_feature_flags(&self) -> WalletFeatureFlags { - self.wallet_feature_flags.read().expect("RwLock poisoned").clone() + 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 @@ -166,7 +172,7 @@ mod tests { ); let service = WalletFeatureFlagsService::new(path.clone()).expect("service should initialize"); - let flags = service.get_wallet_feature_flags(); + let flags = service.get_wallet_feature_flags().unwrap(); assert!(!flags.enable_test_buttons); assert!(!flags.enable_keystone_hardware_wallet); @@ -205,7 +211,7 @@ mod tests { ); wait_until(Duration::from_secs(3), || { - let flags = service.get_wallet_feature_flags(); + let flags = service.get_wallet_feature_flags().unwrap(); flags.enable_test_buttons && flags.enable_keystone_hardware_wallet && !flags.enable_high_security @@ -232,12 +238,12 @@ mod tests { ); let service = WalletFeatureFlagsService::new(path.clone()).expect("service should initialize"); - let before = service.get_wallet_feature_flags(); + 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(); + let after = service.get_wallet_feature_flags().unwrap(); assert_eq!(before.enable_test_buttons, after.enable_test_buttons); assert_eq!( before.enable_keystone_hardware_wallet, From a9ea49b3a9f56694148d81a1e0150f0503aa20a0 Mon Sep 17 00:00:00 2001 From: Beast Date: Tue, 24 Mar 2026 17:46:33 +0800 Subject: [PATCH 05/11] fix: remove unused state param --- src/routes/mod.rs | 2 +- src/routes/wallet_feature_flags.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 7c9b4b4..8914884 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -26,6 +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(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 index 048bce9..dceb6c4 100644 --- a/src/routes/wallet_feature_flags.rs +++ b/src/routes/wallet_feature_flags.rs @@ -2,6 +2,6 @@ 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(_state: AppState) -> Router { +pub fn wallet_feature_flags_routes() -> Router { Router::new().route("/feature-flags/wallet", get(handle_get_wallet_feature_flags)) } From a8d76b61aaddf3e6862b86356a7d0fe0c32b33f8 Mon Sep 17 00:00:00 2001 From: Beast Date: Tue, 24 Mar 2026 17:48:09 +0800 Subject: [PATCH 06/11] fix: add missing flag json for test --- wallet_feature_flags/test_feature_flags copy.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 wallet_feature_flags/test_feature_flags copy.json diff --git a/wallet_feature_flags/test_feature_flags copy.json b/wallet_feature_flags/test_feature_flags copy.json new file mode 100644 index 0000000..2fce8dd --- /dev/null +++ b/wallet_feature_flags/test_feature_flags copy.json @@ -0,0 +1,7 @@ +{ + "enableTestButtons": false, + "enableKeystoneHardwareWallet": false, + "enableHighSecurity": true, + "enableRemoteNotifications": true, + "enableSwap": true +} From f22627e4a668399127ec5dbff1da4eb72ba46122 Mon Sep 17 00:00:00 2001 From: Beast Date: Tue, 24 Mar 2026 18:24:18 +0800 Subject: [PATCH 07/11] fix: relative path resolving to be reliable --- config/default.toml | 2 +- config/example.toml | 2 +- config/test.toml | 2 +- src/config.rs | 21 +++++++++++++++++++-- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/config/default.toml b/config/default.toml index 111ccb2..5c7e1d3 100644 --- a/config/default.toml +++ b/config/default.toml @@ -80,4 +80,4 @@ tweets_req_interval_in_secs = 60 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 +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 77d1392..e83dd5e 100644 --- a/config/example.toml +++ b/config/example.toml @@ -90,7 +90,7 @@ tweets_req_interval_in_secs = 60 webhook_url = "https://www.webhook_url.com" [feature_flags] -wallet_feature_flags_config_file = "wallet_feature_flags/example_feature_flags.json" +wallet_feature_flags_config_file = "../wallet_feature_flags/example_feature_flags.json" # Example environment variable overrides: # TASKMASTER_BLOCKCHAIN__NODE_URL="ws://remote-node:9944" diff --git a/config/test.toml b/config/test.toml index ec21cd0..a5361d3 100644 --- a/config/test.toml +++ b/config/test.toml @@ -80,4 +80,4 @@ tweets_req_interval_in_secs = 1 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 +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 68e6b64..4a5a394 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; @@ -104,12 +106,15 @@ 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")) @@ -117,7 +122,9 @@ impl Config { .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 { @@ -151,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().unwrap_or(Path::new(".")); + self.feature_flags.wallet_feature_flags_config_file = + base_dir.join(feature_flags_path).to_string_lossy().to_string(); + } } impl Default for Config { From b981ff62438cb7dc70d8958393762d2a41eb237b Mon Sep 17 00:00:00 2001 From: Beast Date: Tue, 24 Mar 2026 20:31:07 +0800 Subject: [PATCH 08/11] fix: bad test file config, typo test feature flags json --- src/config.rs | 2 +- .../{test_feature_flags copy.json => test_feature_flags.json} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename wallet_feature_flags/{test_feature_flags copy.json => test_feature_flags.json} (100%) diff --git a/src/config.rs b/src/config.rs index 4a5a394..a59c2d0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -117,7 +117,7 @@ impl Config { 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()?; diff --git a/wallet_feature_flags/test_feature_flags copy.json b/wallet_feature_flags/test_feature_flags.json similarity index 100% rename from wallet_feature_flags/test_feature_flags copy.json rename to wallet_feature_flags/test_feature_flags.json From e16bdc842582983152792e9167ccf1945a9e2bdb Mon Sep 17 00:00:00 2001 From: Beast Date: Tue, 24 Mar 2026 23:14:15 +0800 Subject: [PATCH 09/11] feat: make we feature flags freely set --- src/handlers/wallet_feature_flags.rs | 8 +-- src/services/wallet_feature_flags_service.rs | 68 ++++++++++---------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/handlers/wallet_feature_flags.rs b/src/handlers/wallet_feature_flags.rs index d997390..d3f9258 100644 --- a/src/handlers/wallet_feature_flags.rs +++ b/src/handlers/wallet_feature_flags.rs @@ -1,13 +1,11 @@ use axum::{extract::State, Json}; +use serde_json::Value; -use crate::{ - handlers::SuccessResponse, http_server::AppState, services::wallet_feature_flags_service::WalletFeatureFlags, - AppError, -}; +use crate::{handlers::SuccessResponse, http_server::AppState, AppError}; pub async fn handle_get_wallet_feature_flags( State(state): State, -) -> Result>, AppError> { +) -> Result>, AppError> { let flags = state.wallet_feature_flags_service.get_wallet_feature_flags()?; Ok(SuccessResponse::new(flags)) diff --git a/src/services/wallet_feature_flags_service.rs b/src/services/wallet_feature_flags_service.rs index 3760f55..5f2c63b 100644 --- a/src/services/wallet_feature_flags_service.rs +++ b/src/services/wallet_feature_flags_service.rs @@ -1,21 +1,11 @@ use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher}; -use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::{ path::{Path, PathBuf}, sync::{Arc, RwLock}, }; use tokio::{sync::mpsc, task::JoinHandle}; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all(deserialize = "camelCase"))] -pub struct WalletFeatureFlags { - pub enable_test_buttons: bool, - pub enable_keystone_hardware_wallet: bool, - pub enable_high_security: bool, - pub enable_remote_notifications: bool, - pub enable_swap: bool, -} - #[derive(Debug, thiserror::Error)] pub enum WalletFeatureFlagsError { #[error("Failed to read wallet feature flags file: {0}")] @@ -30,7 +20,7 @@ pub enum WalletFeatureFlagsError { #[derive(Debug)] pub struct WalletFeatureFlagsService { - wallet_feature_flags: Arc>, + wallet_feature_flags: Arc>, _watcher: RecommendedWatcher, _watch_task: JoinHandle<()>, } @@ -100,7 +90,7 @@ impl WalletFeatureFlagsService { }) } - pub fn get_wallet_feature_flags(&self) -> Result { + 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()) })?; @@ -109,18 +99,18 @@ impl WalletFeatureFlagsService { } // Synchronous read for initial startup - fn read_flags_from_file_sync(path: &Path) -> Result { + fn read_flags_from_file_sync(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; - let flags = serde_json::from_str::(&content)?; + 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 { + 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)?; + let flags = serde_json::from_str::(&content)?; Ok(flags) } } @@ -174,11 +164,11 @@ mod tests { let service = WalletFeatureFlagsService::new(path.clone()).expect("service should initialize"); let flags = service.get_wallet_feature_flags().unwrap(); - assert!(!flags.enable_test_buttons); - assert!(!flags.enable_keystone_hardware_wallet); - assert!(flags.enable_high_security); - assert!(flags.enable_remote_notifications); - assert!(flags.enable_swap); + 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(); } @@ -212,11 +202,11 @@ mod tests { wait_until(Duration::from_secs(3), || { let flags = service.get_wallet_feature_flags().unwrap(); - flags.enable_test_buttons - && flags.enable_keystone_hardware_wallet - && !flags.enable_high_security - && !flags.enable_remote_notifications - && !flags.enable_swap + 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; @@ -244,14 +234,26 @@ mod tests { tokio::time::sleep(Duration::from_millis(300)).await; let after = service.get_wallet_feature_flags().unwrap(); - assert_eq!(before.enable_test_buttons, after.enable_test_buttons); assert_eq!( - before.enable_keystone_hardware_wallet, - after.enable_keystone_hardware_wallet + 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() ); - assert_eq!(before.enable_high_security, after.enable_high_security); - assert_eq!(before.enable_remote_notifications, after.enable_remote_notifications); - assert_eq!(before.enable_swap, after.enable_swap); std::fs::remove_file(path).ok(); } From d05f1d55de8739f905549ccddd7adabfd5085fdc Mon Sep 17 00:00:00 2001 From: Beast Date: Tue, 24 Mar 2026 23:49:18 +0800 Subject: [PATCH 10/11] fix: some fixes --- config/example.toml | 2 +- src/config.rs | 2 +- src/errors.rs | 7 +------ src/services/wallet_feature_flags_service.rs | 10 +++++++++- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/config/example.toml b/config/example.toml index e83dd5e..580f6ba 100644 --- a/config/example.toml +++ b/config/example.toml @@ -90,7 +90,7 @@ tweets_req_interval_in_secs = 60 webhook_url = "https://www.webhook_url.com" [feature_flags] -wallet_feature_flags_config_file = "../wallet_feature_flags/example_feature_flags.json" +wallet_feature_flags_config_file = "../wallet_feature_flags/default_feature_flags.json" # Example environment variable overrides: # TASKMASTER_BLOCKCHAIN__NODE_URL="ws://remote-node:9944" diff --git a/src/config.rs b/src/config.rs index a59c2d0..cfb11e2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -164,7 +164,7 @@ impl Config { if feature_flags_path.is_absolute() { return; } - let base_dir = Path::new(config_path).parent().unwrap_or(Path::new(".")); + 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(); } diff --git a/src/errors.rs b/src/errors.rs index 4a3c50d..1c2740a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -173,10 +173,5 @@ fn map_db_error(err: DbError) -> (StatusCode, String) { } fn map_wallet_feature_flags_error(err: WalletFeatureFlagsError) -> (StatusCode, String) { - match err { - WalletFeatureFlagsError::ReadLock(err) => (StatusCode::INTERNAL_SERVER_ERROR, err), - WalletFeatureFlagsError::ReadFile(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), - WalletFeatureFlagsError::ParseJson(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), - WalletFeatureFlagsError::Watcher(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), - } + (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) } diff --git a/src/services/wallet_feature_flags_service.rs b/src/services/wallet_feature_flags_service.rs index 5f2c63b..88980df 100644 --- a/src/services/wallet_feature_flags_service.rs +++ b/src/services/wallet_feature_flags_service.rs @@ -16,6 +16,8 @@ pub enum WalletFeatureFlagsError { Watcher(#[from] notify::Error), #[error("Failed to read wallet feature flags: {0}")] ReadLock(String), + #[error("Failed to get parent directory")] + ParentDirectory, } #[derive(Debug)] @@ -44,7 +46,7 @@ impl WalletFeatureFlagsService { let parent_dir = Path::new(&file_path) .parent() - .unwrap_or(Path::new("wallet_feature_flags")); + .ok_or_else(|| WalletFeatureFlagsError::ParentDirectory)?; watcher.watch(parent_dir, RecursiveMode::NonRecursive)?; let wallet_feature_flags_clone = wallet_feature_flags.clone(); @@ -115,6 +117,12 @@ impl WalletFeatureFlagsService { } } +impl Drop for WalletFeatureFlagsService { + fn drop(&mut self) { + self._watch_task.abort(); + } +} + #[cfg(test)] mod tests { use super::*; From 016c08aa0dfe0322e4556aa5bbb9f3f590b3aa96 Mon Sep 17 00:00:00 2001 From: Beast Date: Tue, 24 Mar 2026 23:52:12 +0800 Subject: [PATCH 11/11] fix: clippy --- src/services/wallet_feature_flags_service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/wallet_feature_flags_service.rs b/src/services/wallet_feature_flags_service.rs index 88980df..1502b33 100644 --- a/src/services/wallet_feature_flags_service.rs +++ b/src/services/wallet_feature_flags_service.rs @@ -46,7 +46,7 @@ impl WalletFeatureFlagsService { let parent_dir = Path::new(&file_path) .parent() - .ok_or_else(|| WalletFeatureFlagsError::ParentDirectory)?; + .ok_or(WalletFeatureFlagsError::ParentDirectory)?; watcher.watch(parent_dir, RecursiveMode::NonRecursive)?; let wallet_feature_flags_clone = wallet_feature_flags.clone();